Back to posts

RDS Proxy on AWS — Connection Pooling for ECS with Terraform

Read the full guide on docs.beyondyou.my.id
awsrdsrds-proxyecsterraformpostgresqlsecrets-managerconnection-pooling

RDS Proxy on AWS — Connection Pooling for ECS with Terraform

Table of Contents

SectionTopicDescription
01Why RDS ProxyThe connection problem with serverless and containers.
02ArchitectureECS tasks → RDS Proxy → RDS PostgreSQL.
03Terraform Module StructureSeparated IAM, SG, and RDS modules.
04IAM RoleSecrets Manager read + KMS decrypt permissions.
05Security GroupIngress from ECS, egress to RDS.
06RDS Proxy ResourceProxy config, auth, target group, registration.
07Credential RotationZero-downtime password rotation flow.
08Application ChangesDATABASE_URL update and connection tuning.
09Apply OrderModule dependency chain.

1. Why RDS Proxy

ECS tasks and Lambda functions create many short-lived database connections. Without a proxy, each task opens its own connection to RDS — exhausting the database’s connection limit.

ProblemWithout ProxyWith Proxy
Connection exhaustion50 tasks × 10 conn = 500 connections50 tasks × 3 conn → proxy pools → 500 (but proxy manages)
Credential rotationApp restart requiredZero downtime
FailoverDNS TTL delay (60-120s)Automatic, faster
Connection overheadTCP + TLS handshake per queryPersistent connections to RDS

When to Use RDS Proxy

ScenarioUse Proxy?
ECS with many tasksYes
Lambda with RDSYes
Credential auto-rotationYes
Aurora serverlessYes
Single long-lived connectionNot needed
On-premises appNo (AWS only)

2. Architecture

graph TB
    subgraph ECS["ECS Tasks"]
        task1["Task 1\n(sg-ecs)"]
        task2["Task 2\n(sg-ecs)"]
        task3["Task 3\n(sg-ecs)"]
    end

    subgraph SG_LAYER["Security Group Layer"]
        sg_proxy["RDS Proxy SG\nexample-sg-prd-rds-proxy"]
        sg_rds["RDS SG\nexample-sg-prd-rds"]
    end

    subgraph PROXY["RDS Proxy"]
        proxy["example-rds-proxy-prd\nConnection Pooling\n80% max conn, 50% idle\n30-min idle timeout"]
    end

    subgraph RDS["RDS"]
        db["example-db-prd\nPostgreSQL"]
        sm["Secrets Manager\nAuto-rotate password"]
    end

    task1 -->|"TLS 1.2, port 5432"| sg_proxy
    task2 -->|"TLS 1.2, port 5432"| sg_proxy
    task3 -->|"TLS 1.2, port 5432"| sg_proxy
    sg_proxy --> proxy
    proxy -->|"port 5432"| sg_rds
    sg_rds --> db
    db --> sm
    sm -.->|"rotate password"| proxy

Connection Flow

StepFromToPortProtocol
1ECS TaskRDS Proxy5432TCP + TLS 1.2
2RDS ProxyRDS5432TCP + TLS
3Secrets ManagerRDS Proxy-API (detect rotation)

Security Group Rules

Security GroupDirectionPortSource/DestinationPurpose
Proxy SGIngress5432ECS SGAccept connections from tasks
Proxy SGEgress5432VPC CIDRConnect to RDS
RDS SGIngress5432VPC CIDRAccept connections from proxy
RDS SGEgress--RDS does not initiate outbound

3. Terraform Module Structure

graph TB
    subgraph MODULES["Terraform Modules"]
        subgraph IAM["prd/iam/"]
            iam_role["RDSProxyRole.tf\nIAM role + policies"]
            iam_vars["variables.tf\nrds_proxy_role_name"]
            iam_out["outputs.tf\nrds_proxy_role_arn"]
        end
        subgraph SG["prd/sg/"]
            sg_proxy_tf["proxy.tf\nProxy SG"]
            sg_rds_tf["rds.tf\nRDS SG + proxy rule"]
            sg_out["outputs.tf\nproxy SG ID"]
        end
        subgraph RDS_MOD["prd/rds/"]
            rds_proxy_tf["proxy.tf\nRDS Proxy resource"]
            rds_vars["variables.tf\nrds_proxy_role_arn"]
            rds_out["outputs.tf\nproxy endpoint"]
        end
    end

    IAM --> RDS_MOD
    SG --> RDS_MOD

Why Separate Modules

ReasonDetail
Least privilegeIAM module only manages roles
Network isolationSG module only manages security groups
ReusabilityIAM and SG can be used by other services
Apply orderSG and IAM are independent, can apply in parallel
AuditClear separation of concerns for compliance

4. IAM Role

RDSProxyRole.tf

# RDS Proxy Role
# Used by RDS Proxy to read the RDS master secret from Secrets Manager
# and decrypt it via KMS when connecting to RDS on behalf of applications.
#
# The proxy authenticates to RDS using the auto-rotated master secret.
# When RDS rotates the password, the proxy detects the new secret version
# automatically — no application restart or deployment required.

# Custom policy: Secrets Manager read for RDS-managed master secret
resource "aws_iam_policy" "rds_proxy_secrets_read" {
  name        = "${var.project}-rds-proxy-secrets-read-${var.environment}"
  description = "Allow RDS Proxy to read the RDS master secret from Secrets Manager"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ReadRDSMasterSecret"
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue",
          "secretsmanager:DescribeSecret"
        ]
        Resource = [
          "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:rds!db-${var.db_instance_identifier}-*"
        ]
      }
    ]
  })

  tags = var.tags
}

# Custom policy: KMS decrypt for Secrets Manager key
resource "aws_iam_policy" "rds_proxy_kms_decrypt" {
  name        = "${var.project}-rds-proxy-kms-decrypt-${var.environment}"
  description = "Allow RDS Proxy to decrypt Secrets Manager key via KMS"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "KMSDecryptSecretsManagerKey"
        Effect = "Allow"
        Action = [
          "kms:Decrypt",
          "kms:DescribeKey"
        ]
        Resource = [
          "arn:aws:kms:${var.aws_region}:${var.aws_account_id}:key/alias/aws/secretsmanager"
        ]
      }
    ]
  })

  tags = var.tags
}

# IAM role for RDS Proxy
module "rds_proxy_role" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role"
  version = "~> 5.0"

  role_name = var.rds_proxy_role_name

  trust_role_services = ["rds.amazonaws.com"]

  custom_role_policy_arns = [
    aws_iam_policy.rds_proxy_secrets_read.arn,
    aws_iam_policy.rds_proxy_kms_decrypt.arn
  ]

  tags = var.rds_proxy_role_tags
}

IAM Permissions Breakdown

PolicyActionResourcePurpose
rds_proxy_secrets_readsecretsmanager:GetSecretValue, DescribeSecretrds!db-{instance}-*Read RDS master secret
rds_proxy_kms_decryptkms:Decrypt, kms:DescribeKeyalias/aws/secretsmanagerDecrypt secret value

Trust Relationship

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RDSProxyAssumeRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "rds.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

5. Security Group

Proxy Security Group

module "rds_proxy_security_group" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 6.0"

  name            = "example-sg-prd-rds-proxy"
  use_name_prefix = false
  description     = "Security group for RDS Proxy — example-project prd"
  vpc_id          = local.vpc_id

  ingress_rules = {
    postgres_from_ecs = {
      description                  = "PostgreSQL from ECS tasks"
      from_port                    = 5432
      to_port                      = 5432
      ip_protocol                  = "tcp"
      referenced_security_group_id = module.ecs_security_group.id
    }
  }

  egress_rules = {
    postgres_to_rds = {
      description = "PostgreSQL to RDS instance"
      from_port   = 5432
      to_port     = 5432
      ip_protocol = "tcp"
      cidr_blocks = [local.vpc_cidr]
    }
  }

  tags = var.tags
}

RDS Security Group — Add Proxy Rule

# In sg/rds.tf
ingress_rules = {
  # ... existing rules ...

  postgres_from_proxy = {
    description                  = "PostgreSQL from RDS Proxy"
    from_port                    = 5432
    to_port                      = 5432
    ip_protocol                  = "tcp"
    referenced_security_group_id = module.rds_proxy_security_group.id
  }
}

6. RDS Proxy Resource

proxy.tf

# ─── Data Source: Proxy Security Group ────────────────────────────────────
# The SG is created by prd/sg module — must be applied before this module.

data "aws_security_group" "rds_proxy" {
  filter {
    name   = "group-name"
    values = ["example-sg-prd-rds-proxy"]
  }
}

# ─── RDS Proxy ──────────────────────────────────────────────────────────────

resource "aws_db_proxy" "this" {
  name                   = "example-rds-proxy-${var.environment}"
  debug_logging          = var.rds_proxy_debug_logging
  engine_family          = "POSTGRESQL"
  idle_client_timeout    = var.rds_proxy_idle_client_timeout
  require_tls            = var.rds_proxy_require_tls
  role_arn               = var.rds_proxy_role_arn
  vpc_security_group_ids = [data.aws_security_group.rds_proxy.id]
  vpc_subnet_ids         = var.subnet_ids

  auth {
    auth_scheme = "SECRETS"
    iam_auth    = "DISABLED"
    secret_arn  = module.db.db_instance_master_user_secret_arn
  }

  tags = merge(var.rds_tags, {
    Name = "example-rds-proxy-${var.environment}"
  })
}

# ─── Target Group ───────────────────────────────────────────────────────────

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

  connection_pool_config {
    connection_borrow_timeout    = var.rds_proxy_connection_borrow_timeout
    init_query                   = ""
    max_connections_percent      = var.rds_proxy_max_connections_percent
    max_idle_connections_percent = var.rds_proxy_max_idle_connections_percent
    session_pinning_filters      = []
  }
}

# ─── Target Registration ───────────────────────────────────────────────────

resource "aws_db_proxy_target" "this" {
  db_proxy_name          = aws_db_proxy.this.name
  target_group_name      = aws_db_proxy_default_target_group.this.db_proxy_name
  db_instance_identifier = module.db.db_instance_identifier
}

Configuration Breakdown

ParameterValuePurpose
engine_familyPOSTGRESQLPostgreSQL protocol support
idle_client_timeout1800 (30 min)Close idle client connections
require_tlstrueEnforce TLS 1.2+
auth_schemeSECRETSUse Secrets Manager for auth
iam_authDISABLEDUse password, not IAM auth
max_connections_percent80Leave headroom for admin
max_idle_connections_percent50Keep some connections warm
connection_borrow_timeout120 (sec)Timeout waiting for pooled conn

7. Credential Rotation

How It Works

sequenceDiagram
    participant RDS as RDS PostgreSQL
    participant SM as Secrets Manager
    participant Proxy as RDS Proxy
    participant App as ECS Tasks

    Note over RDS,SM: Credential rotation triggered
    RDS->>SM: Rotate master password
    SM->>SM: Create AWSCURRENT version
    SM->>Proxy: Notify secret change
    Proxy->>SM: Fetch new secret version
    Proxy->>RDS: Reconnect with new password
    Note over Proxy,RDS: Existing app connections unaffected
    App->>Proxy: Continue using existing connections
    Proxy->>RDS: Route through new connections

Rotation Timeline

StepWhat HappensDowntime
1RDS rotates master passwordNone
2Secrets Manager stores new versionNone
3Proxy detects AWSCURRENT changeNone
4Proxy reconnects to RDS with new passwordNone (new connections)
5Existing client connections continueNone

What Stays the Same

ComponentAffected?Detail
ECS tasksNoContinue using proxy endpoint
DATABASE_URLNoProxy endpoint doesn’t change
Connection poolNoExisting connections kept alive
ApplicationNoNo restart, no redeploy

8. Application Changes

DATABASE_URL Update

# Before (direct to RDS):
DATABASE_URL=postgresql://appuser:<pass>@example-rds-prd-db.xxxx.ap-southeast-3.rds.amazonaws.com:5432/appdb

# After (through proxy):
DATABASE_URL=postgresql://appuser:<pass>@example-rds-proxy-prd.proxy-xxxx.ap-southeast-3.rds.amazonaws.com:5432/appdb?connection_limit=3

Connection Tuning

SettingBefore (Direct)After (Proxy)Why
connection_limit10 (default)3Proxy handles pooling
pool_timeout1030Proxy borrows from pool
pool_recycle18003600Proxy manages lifecycle

Prisma Example

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // connection_limit is set in DATABASE_URL query string
  // ?connection_limit=3
}

Connection Pool Math

ComponentValueCalculation
ECS tasks50-
Connections per task3connection_limit=3
Total app connections15050 × 3
Proxy max connections40080% of RDS max (500)
Proxy idle connections20050% of max
Headroom250400 - 150

9. Apply Order

Module Dependency Chain

graph LR
    subgraph STEP_1["Step 1"]
        SG["SG Module\nCreates proxy SG\nUpdates RDS SG"]
    end
    subgraph STEP_2["Step 2"]
        IAM["IAM Module\nCreates proxy role\nSecrets Manager + KMS policies"]
    end
    subgraph STEP_3["Step 3"]
        RDS["RDS Module\nCreates proxy resource\nRegisters RDS target"]
    end

    SG --> RDS
    IAM --> RDS

Apply Commands

# Step 1: Apply SG module first (creates proxy SG)
terraform -chdir=terraform/modules/sg apply -auto-approve

# Step 2: Apply IAM module (creates proxy role)
terraform -chdir=terraform/modules/iam apply -auto-approve

# Step 3: Apply RDS module (creates proxy + target registration)
terraform -chdir=terraform/modules/rds apply -auto-approve

Apply Order Summary

OrderModuleResources CreatedDepends On
1SGProxy SG, RDS SG ruleNone
2IAMProxy role, Secrets Manager policy, KMS policyNone
3RDSProxy, target group, target registrationSG + IAM

Outputs

ModuleOutputValue
SGrds_proxy_security_group_idsg-xxxx
SGrds_proxy_security_group_arnarn:aws:ec2:...
IAMrds_proxy_role_arnarn:aws:iam::xxxx:role/...
IAMrds_proxy_role_nameexample-rds-proxy-role-prd
RDSdb_proxy_endpointproxy-xxxx.rds.amazonaws.com
RDSdb_proxy_security_group_idsg-xxxx

References