Amazon RDS Proxy: Why Your Database Connections Are Killing You in Production

Every Lambda function that talks to RDS opens a new database connection. At low traffic this is invisible. At scale it becomes the thing that takes your database down.

The problem is not unique to Lambda. ECS tasks, auto-scaling EC2 fleets, and containerized workloads all create the same pressure. Your application tier scales horizontally. Your database does not scale connections in the same way. PostgreSQL on a db.r6g.large instance handles around 800 connections before it starts degrading. If you have 200 Lambda functions each holding a connection open during a burst, you hit that ceiling fast.

RDS Proxy sits between your application and your database. It pools and multiplexes connections so your application tier can have thousands of open connections while your database only sees a small, stable pool. In this article I will walk you through how RDS Proxy works, when you need it, when you do not, and how to set it up properly with Terraform.

How RDS Proxy Works

RDS Proxy maintains a pool of long-lived connections to your database. When your application opens a connection to the proxy, it gets assigned a slot from that pool. When the application is done with a query and releases the connection, the underlying database connection is not closed. It goes back into the pool, ready for the next request.

The proxy also handles failover. When your RDS instance fails over to a replica, the proxy detects the new primary and routes traffic to it. Your application sees a brief pause rather than a connection failure, and it does not need to implement failover logic itself.

Authentication is handled through IAM or Secrets Manager. The proxy fetches credentials from Secrets Manager, rotates them automatically, and presents a stable endpoint to your application. You never need to update connection strings when passwords rotate.

When You Actually Need It

RDS Proxy adds latency. Typically 1 to 2 milliseconds per query. For most applications this is invisible, but it is not zero. Before adding it to every environment, understand the tradeoffs.

You need RDS Proxy when your application uses Lambda or other serverless compute that creates a new connection per invocation. You need it when your application tier scales aggressively and you regularly approach your database connection limit. You need it when you want automatic failover handling without custom retry logic in your application.

You probably do not need it when you have a small number of long-running application servers that use a connection pool like PgBouncer or HikariCP. Those already solve the connection multiplexing problem at the application layer.

Setting Up RDS Proxy with Terraform

First, your database credentials need to live in Secrets Manager. The proxy fetches them from there:

resource "aws_secretsmanager_secret" "db_credentials" {
  name                    = "rds/app-database/credentials"
  recovery_window_in_days = 7
}

resource "aws_secretsmanager_secret_version" "db_credentials" {
  secret_id = aws_secretsmanager_secret.db_credentials.id
  secret_string = jsonencode({
    username = "app_user"
    password = var.db_password
    engine   = "postgres"
    host     = aws_db_instance.main.address
    port     = 5432
    dbname   = "appdb"
  })
}

resource "aws_db_instance" "main" {
  identifier           = "app-database"
  engine               = "postgres"
  engine_version       = "16.3"
  instance_class       = "db.r6g.large"
  allocated_storage    = 100
  storage_encrypted    = true
  db_name              = "appdb"
  username             = "app_user"
  password             = var.db_password
  db_subnet_group_name = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  multi_az             = true
  backup_retention_period = 7
  deletion_protection  = true
  skip_final_snapshot  = false
  final_snapshot_identifier = "app-database-final"
}

Now the proxy itself:

resource "aws_iam_role" "rds_proxy" {
  name = "rds-proxy-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "rds.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "rds_proxy_secrets" {
  name = "rds-proxy-secrets-policy"
  role = aws_iam_role.rds_proxy.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue",
          "secretsmanager:DescribeSecret"
        ]
        Resource = aws_secretsmanager_secret.db_credentials.arn
      },
      {
        Effect   = "Allow"
        Action   = ["kms:Decrypt"]
        Resource = aws_kms_key.secrets.arn
        Condition = {
          StringEquals = {
            "kms:ViaService" = "secretsmanager.${var.region}.amazonaws.com"
          }
        }
      }
    ]
  })
}

resource "aws_db_proxy" "main" {
  name                   = "app-database-proxy"
  debug_logging          = false
  engine_family          = "POSTGRESQL"
  idle_client_timeout    = 1800
  require_tls            = true
  role_arn               = aws_iam_role.rds_proxy.arn
  vpc_security_group_ids = [aws_security_group.rds_proxy.id]
  vpc_subnet_ids         = var.private_subnet_ids

  auth {
    auth_scheme               = "SECRETS"
    description               = "App database credentials"
    iam_auth                  = "REQUIRED"
    secret_arn                = aws_secretsmanager_secret.db_credentials.arn
  }
}

resource "aws_db_proxy_default_target_group" "main" {
  db_proxy_name = aws_db_proxy.main.name

  connection_pool_config {
    connection_borrow_timeout    = 120
    max_connections_percent      = 70
    max_idle_connections_percent = 50
    session_pinning_filters      = ["EXCLUDE_VARIABLE_SETS"]
  }
}

resource "aws_db_proxy_target" "main" {
  db_instance_identifier = aws_db_instance.main.identifier
  db_proxy_name          = aws_db_proxy.main.name
  target_group_name      = aws_db_proxy_default_target_group.main.name
}

The connection pool configuration deserves explanation.

max_connections_percent = 70 means the proxy will use at most 70 percent of the database’s max_connections parameter value. Keeping this below 100 percent reserves headroom for direct administrative connections, which you will need during incidents when you want to connect directly to the database without going through the proxy.

connection_borrow_timeout = 120 is how long the proxy waits for a connection from the pool before returning an error to the client. 120 seconds is generous. For latency-sensitive workloads, lower this to 10 or 20 seconds so your application fails fast rather than hanging.

session_pinning_filters = [“EXCLUDE_VARIABLE_SETS”] prevents the proxy from pinning a client to a specific database connection when SET commands are used. Without this filter, any SET statement causes the proxy to pin the client to one connection for the rest of the session, defeating the connection multiplexing entirely. Most ORMs issue SET commands, so this filter is almost always the right setting.

IAM Authentication for Lambda

With iam_auth = “REQUIRED” on the proxy, your Lambda functions authenticate using their execution role rather than a username and password. This is significantly more secure than embedding database credentials in environment variables.

resource "aws_iam_role_policy" "lambda_rds_connect" {
  name = "lambda-rds-proxy-connect"
  role = aws_iam_role.lambda_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["rds-db:connect"]
      Resource = "arn:aws:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_db_proxy.main.id}/app_user"
    }]
  })
}

In your Lambda function, generate a token and use it as the password:

import boto3
import psycopg2

def get_db_connection():
    rds_client = boto3.client("rds", region_name="us-east-1")

    token = rds_client.generate_db_auth_token(
        DBHostname=os.environ["DB_PROXY_ENDPOINT"],
        Port=5432,
        DBUsername="app_user",
        Region="us-east-1"
    )

    conn = psycopg2.connect(
        host=os.environ["DB_PROXY_ENDPOINT"],
        port=5432,
        database="appdb",
        user="app_user",
        password=token,
        sslmode="require"
    )

    return conn

The token is valid for 15 minutes. For Lambda functions that run quickly, generate a new token per invocation. For long-running processes, implement token refresh before expiry.

Security Group Configuration

resource "aws_security_group" "rds_proxy" {
  name   = "rds-proxy-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.lambda.id]
    description     = "PostgreSQL from Lambda functions"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group_rule" "rds_from_proxy" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.rds_proxy.id
  security_group_id        = aws_security_group.rds.id
  description              = "PostgreSQL from RDS Proxy only"
}

The RDS security group should only accept connections from the proxy security group, not from your Lambda or application security groups directly. This ensures all traffic goes through the proxy and you cannot accidentally bypass it during development or debugging.

Monitoring Connection Pool Health

RDS Proxy publishes CloudWatch metrics under the AWS/RDS namespace with the ProxyName dimension. The ones to watch are these.

DatabaseConnectionsCurrentlyBorrowed tells you how many connections the proxy is actively using from the database pool. If this stays consistently near your max_connections_percent ceiling, your pool is undersized for your traffic and you should increase the max connections or scale up your database instance.

ClientConnectionsReceived versus ClientConnectionsSetupSucceeded tells you your connection success rate. A growing gap between these two metrics means clients are timing out waiting for pool connections, which is a sign that connection_borrow_timeout is being hit frequently.

QueryDatabaseResponseLatency tracks the time spent waiting for the database to respond, excluding proxy overhead. If this is climbing, the problem is in the database, not the proxy.

resource "aws_cloudwatch_metric_alarm" "proxy_connections_high" {
  alarm_name          = "rds-proxy-connections-high"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "DatabaseConnectionsCurrentlyBorrowed"
  namespace           = "AWS/RDS"
  period              = 60
  statistic           = "Average"
  threshold           = 500
  alarm_description   = "RDS Proxy borrowed connections exceeding threshold"
  alarm_actions       = [aws_sns_topic.alerts.arn]

  dimensions = {
    ProxyName = aws_db_proxy.main.name
  }
}

Closing Thoughts

RDS Proxy is one of those services that feels optional until the moment you need it, and at that moment you are usually dealing with a production incident. The connection exhaustion problem is predictable. If you are building on Lambda or any compute tier that scales to hundreds of instances, add the proxy before you go to production rather than after.

The setup takes about 30 minutes with Terraform. The IAM authentication pattern removes a class of credential management problems entirely. The automatic failover handling removes the need for retry logic in your application for the most common database disruption scenario.

Enjoy the cloud.

Osama


#AWS #RDSProxy #AmazonRDS #DatabaseEngineering #ServerlessArchitecture #Lambda #PostgreSQL #CloudArchitecture #Terraform #InfrastructureAsCode #CloudNative #AmazonWebServices #SolutionsArchitect #CloudComputing #BackendEngineering #DatabasePerformance #DevOps #TechBlog #CloudSecurity #CloudInfrastructure

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.