DEV Community

Cover image for Why Your Lambda Functions Are Silently Killing Your RDS Database, And How RDS Proxy Fixes It
Jeancy Joachim Mukaka
Jeancy Joachim Mukaka

Posted on

Why Your Lambda Functions Are Silently Killing Your RDS Database, And How RDS Proxy Fixes It

You built a serverless API. Lambda functions, API Gateway, RDS MySQL on the backend. Clean architecture, scales automatically, or so you thought.

Traffic spikes. 500 Lambda functions invoke simultaneously. Each one opens a brand new database connection. Your RDS instance supports a maximum of 100 connections.

The result: ERROR 1040: Too many connections.

Your database is on its knees. Your API is down. And your Lambda functions are perfectly healthy, they just have nowhere to connect.

This is one of the most common and painful failure modes in serverless architectures on AWS. And RDS Proxy exists precisely to solve it.

The Root Cause: Lambda's Stateless Nature

To understand why this happens, you need to understand how Lambda manages connections.

A traditional application server, an EC2 instance running your backend, starts once, opens a pool of database connections, and reuses them for every incoming request. The connection lifecycle is controlled and predictable.

Lambda works completely differently. Every invocation is stateless and ephemeral. When a Lambda function needs to query your database, it opens a connection. When the invocation ends, that connection may or may not be reused depending on whether the execution environment is still warm.

At low traffic, this is manageable. But under load:

100 concurrent Lambda invocations  →  100 new RDS connections
500 concurrent Lambda invocations  →  500 new RDS connections
1000 concurrent Lambda invocations →  1000 new RDS connections
Enter fullscreen mode Exit fullscreen mode

Meanwhile, your RDS instance has a hard limit on concurrent connections, determined by its instance class. A db.t3.micro supports roughly 66 connections. A db.t3.medium around 150. Beyond that limit, every new connection attempt fails with:

OperationalError: (1040, 'Too many connections')
Enter fullscreen mode Exit fullscreen mode

Scaling up your RDS instance buys you time, but it doesn't fix the architectural problem. You're treating the symptom, not the cause.

The Solution: RDS Proxy as a Connection Manager

Amazon RDS Proxy is a fully managed, highly available database proxy that sits between your Lambda functions and your RDS instance.

Instead of each Lambda function opening a direct connection to RDS, they all connect to the Proxy, which maintains a pool of persistent, reusable connections to the database.

Without RDS Proxy:
1000 Lambda invocations → 1000 new RDS connections → ❌ Too many connections

With RDS Proxy:
1000 Lambda invocations → RDS Proxy (pool of 20 reused connections) → RDS MySQL ✅
Enter fullscreen mode Exit fullscreen mode

Beyond connection pooling, RDS Proxy brings three additional benefits:

1. Faster failover
In a Multi-AZ setup, if the primary RDS instance fails, the Proxy automatically redirects connections to the standby. Failover time drops from 20-30 seconds to under 10 seconds, your Lambda functions reconnect transparently.

2. IAM Authentication
Instead of hardcoding database credentials inside your Lambda code or environment variables, your functions authenticate through IAM. Credentials are stored in AWS Secrets Manager and never exposed directly.

3. Enhanced security
Your RDS instance no longer needs to be publicly accessible. Only the Proxy endpoint is exposed to the application layer, RDS stays completely private inside your VPC.

The Code: Connecting Lambda to RDS Through the Proxy

Here is how a typical Lambda function connects directly to RDS the problematic pattern:

import pymysql
import os

def lambda_handler(event, context):
    # A new connection is opened on every invocation ❌
    connection = pymysql.connect(
        host=os.environ['RDS_ENDPOINT'],
        user=os.environ['DB_USER'],
        password=os.environ['DB_PASSWORD'],
        database=os.environ['DB_NAME']
    )

    cursor = connection.cursor()
    cursor.execute("SELECT * FROM users WHERE id = %s", (event['user_id'],))
    result = cursor.fetchone()

    connection.close()
    return result
Enter fullscreen mode Exit fullscreen mode

Every invocation opens a new connection and closes it at the end. Under load, this saturates RDS instantly.

Here is the correct pattern connecting through RDS Proxy with connection reuse:

import pymysql
import os

# Connection initialized OUTSIDE the handler — reused across warm invocations ✅
connection = None

def get_connection():
    global connection
    if connection is None or not connection.open:
        connection = pymysql.connect(
            host=os.environ['RDS_PROXY_ENDPOINT'],  # Proxy endpoint, not RDS directly
            user=os.environ['DB_USER'],
            password=os.environ['DB_PASSWORD'],
            database=os.environ['DB_NAME'],
            connect_timeout=5
        )
    return connection

def lambda_handler(event, context):
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM users WHERE id = %s", (event['user_id'],))
    result = cursor.fetchone()
    return result
Enter fullscreen mode Exit fullscreen mode

Two critical changes:

1. The connection is initialized outside the handler, when the execution environment is warm, the same connection is reused across multiple invocations. No unnecessary reconnections.

2. The endpoint points to the Proxy, not RDS directly, the Proxy manages the actual connection pool to RDS. Your Lambda function never touches RDS directly.

Setting Up RDS Proxy: Step by Step

Prerequisites:

  • An existing RDS MySQL instance inside a VPC
  • Your DB credentials stored in AWS Secrets Manager
  • An IAM role allowing RDS Proxy to access Secrets Manager

Step 1: Store credentials in Secrets Manager

  1. Go to Secrets Manager → Store a new secret
  2. Select Credentials for Amazon RDS database
  3. Enter your DB username and password
  4. Select your RDS instance
  5. Name the secret, e.g. rds/myapp/credentials

Step 2: Create the RDS Proxy

  1. Go to RDS → Proxies → Create Proxy
  2. Configure the basics:
    • Proxy identifier: myapp-rds-proxy
    • Engine: MySQL
    • Target: select your RDS instance
  3. Under Authentication, select the Secrets Manager secret you just created
  4. Select the IAM role with Secrets Manager access
  5. Select your VPC, subnets and security group
  6. Click Create Proxy, wait ~5 minutes for status to become Available.

Step 3: Update your Lambda environment variable

Replace your RDS_ENDPOINT environment variable with the Proxy endpoint:

RDS_PROXY_ENDPOINT = myapp-rds-proxy.proxy-xxxxxxxxx.us-east-1.rds.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

That's it. No code changes beyond pointing to the new endpoint, and implementing the connection reuse pattern shown above.

Step 4: Verify

In the RDS Proxy console, under Proxy endpoints, you can monitor:

  • ClientConnections connections coming from Lambda.
  • DatabaseConnections actual connections open to RDS.

You will immediately see the difference: hundreds of Lambda invocations mapping to a small, stable pool of database connections.

Provisioning RDS Proxy with Terraform

If you're managing your infrastructure as code, which you should be, here's how to provision RDS Proxy with Terraform:

# Secrets Manager secret for DB credentials
resource "aws_secretsmanager_secret" "rds_credentials" {
  name = "rds/myapp/credentials"
}

resource "aws_secretsmanager_secret_version" "rds_credentials" {
  secret_id = aws_secretsmanager_secret.rds_credentials.id
  secret_string = jsonencode({
    username = var.db_username
    password = var.db_password
  })
}

# IAM role for RDS Proxy
resource "aws_iam_role" "rds_proxy_role" {
  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_policy" {
  role = aws_iam_role.rds_proxy_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ]
      Resource = aws_secretsmanager_secret.rds_credentials.arn
    }]
  })
}

# RDS Proxy
resource "aws_db_proxy" "myapp" {
  name                   = "myapp-rds-proxy"
  debug_logging          = false
  engine_family          = "MYSQL"
  idle_client_timeout    = 1800
  require_tls            = true
  role_arn               = aws_iam_role.rds_proxy_role.arn
  vpc_subnet_ids         = var.private_subnet_ids
  vpc_security_group_ids = [aws_security_group.rds_proxy_sg.id]

  auth {
    auth_scheme = "SECRETS"
    iam_auth    = "DISABLED"
    secret_arn  = aws_secretsmanager_secret.rds_credentials.arn
  }

  tags = {
    Name        = "myapp-rds-proxy"
    Environment = var.environment
  }
}

# RDS Proxy Target Group
resource "aws_db_proxy_default_target_group" "myapp" {
  db_proxy_name = aws_db_proxy.myapp.name

  connection_pool_config {
    connection_borrow_timeout    = 120
    max_connections_percent      = 100
    max_idle_connections_percent = 50
  }
}

# RDS Proxy Target — points to your RDS instance
resource "aws_db_proxy_target" "myapp" {
  db_instance_identifier = aws_db_instance.myapp.id
  db_proxy_name          = aws_db_proxy.myapp.name
  target_group_name      = aws_db_proxy_default_target_group.myapp.name
}

# Output the Proxy endpoint for Lambda env variable
output "rds_proxy_endpoint" {
  value = aws_db_proxy.myapp.endpoint
}
Enter fullscreen mode Exit fullscreen mode

The Proxy endpoint is exposed as a Terraform output, you can pass it directly as a Lambda environment variable, keeping your entire infrastructure wired together through code.

The Broader Lesson: Serverless Does Not Mean Connectionless

Serverless architectures shift complexity, they don't eliminate it. Lambda removes server management, but it introduces a new class of challenges around state, connections, and resource limits.

RDS Proxy is the bridge between the stateless world of Lambda and the stateful world of relational databases. It absorbs the connection storm, protects your database, and makes your serverless architecture genuinely production-ready.

When should you use RDS Proxy?

  • Your application uses Lambda + RDS, always
  • You need faster Multi-AZ failover, under 10 seconds
  • You want to eliminate hardcoded DB credentials in your Lambda code
  • Your RDS instance frequently hits its max_connections limit

When you don't need it:

  • You're using Aurora Serverless v2, it handles connection scaling natively.
  • Your application has a small, predictable number of connections.
  • You're connecting from EC2 with a proper connection pool already in place.

Key Takeaways

  • Lambda is stateless, every invocation can open a new RDS connection, exhausting the limit under load.
  • RDS Proxy sits between Lambda and RDS, pooling and reusing connections efficiently.
  • Always initialize your database connection outside the Lambda handler to maximize reuse across warm invocations..
  • Use Secrets Manager for credentials, never hardcode them in your Lambda environment variables.
  • Provision RDS Proxy with Terraform to enforce consistency across environments.

This article is part of my AWS architecture series as an AWS Community Builder. I write about real production challenges, hands-on labs, and Infrastructure as Code patterns.

Follow along for more practical AWS and Terraform content.

Top comments (0)