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