IaC AWS

Terraform Module: Amazon RDS Proxy — Pooled, TLS-Enforced Database Connections Without the Footguns

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_db_proxy covering TLS enforcement, Secrets Manager auth, an IAM role for secret access, connection-pool tuning, and instance/cluster targets — production defaults baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "rds_proxy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds-proxy?ref=v1.0.0"

  name                   = "..."           # Proxy name; unique per account/region.
  engine_family          = "..."           # POSTGRESQL, MYSQL, or SQLSERVER.
  vpc_subnet_ids         = ["...", "..."]  # Private subnets the proxy ENIs land in.
  vpc_security_group_ids = ["..."]         # Security groups for the proxy ENIs.
  secret_arns            = ["..."]         # Secrets Manager secret(s) with DB credentials.

  db_instance_identifier = "..."           # OR set db_cluster_identifier for Aurora.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon RDS Proxy (aws_db_proxy) is a fully managed, highly available database proxy that sits between your application and an RDS instance or Aurora cluster. It pools and reuses database connections, which protects the database from connection storms when hundreds of Lambda functions or autoscaled containers each open their own connection; it fails over faster by holding client connections open while the backend promotes a new writer; and it can enforce IAM authentication so applications fetch short-lived tokens instead of long-lived passwords. The proxy authenticates to the backend using credentials stored in Secrets Manager, read through an IAM role you provide.

The resource surface is small but the defaults are unsafe in exactly the ways that matter. require_tls defaults to off, so a misconfigured client can connect in plaintext; the proxy needs an IAM role with precisely the right Secrets Manager permissions, and getting that trust policy or resource scope wrong produces a proxy that creates cleanly but cannot connect to anything. The connection pool lives on a separate resource (aws_db_proxy_default_target_group) whose defaults — 100% max_connections_percent, no borrow timeout tuning — are rarely what a production fleet wants, and that resource behaves oddly under replacement, silently losing track of the pool config unless you wire a replace_triggered_by lifecycle rule. Finally, the proxy is useless until you attach a target (aws_db_proxy_target) pointing at the instance or cluster, which is a third resource people forget.

Wrapping all of this in a module encodes the correct posture once: require_tls = true, a Secrets-Manager auth block, a least-privilege IAM role that can read only the supplied secret ARNs, a tuned connection pool, and the target wired up with the lifecycle guard that keeps the pool config from drifting. App teams hand the module a name, an engine family, subnets, security groups, and a secret ARN, and they get a proxy that passes a security review and actually connects.

When to use it

Reach for direct connections (no proxy) when you run a small number of long-lived application instances with steady connection counts — the proxy adds latency and cost that buys you little there. RDS Proxy shines specifically when connection churn is high or failover speed is critical; for steady, low-cardinality workloads the plain database endpoint is simpler.

Module structure

terraform-module-aws-rds-proxy/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # IAM role, proxy, default target group, target
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # proxy ARN, endpoint, role ARN, and key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      "Name"      = var.name
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-rds-proxy"
    },
    var.tags,
  )

  # Use the caller-supplied role if given, otherwise create a least-privilege
  # role scoped to exactly the provided secret ARNs.
  create_role = var.role_arn == null
  role_arn    = local.create_role ? aws_iam_role.this[0].arn : var.role_arn
}

# Trust policy: only the RDS service may assume this role.
data "aws_iam_policy_document" "assume" {
  count = local.create_role ? 1 : 0

  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["rds.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "this" {
  count = local.create_role ? 1 : 0

  name_prefix        = "${var.name}-proxy-"
  assume_role_policy = data.aws_iam_policy_document.assume[0].json
  tags               = local.tags
}

# Least-privilege: read only the supplied secrets, and decrypt with KMS.
data "aws_iam_policy_document" "secrets" {
  count = local.create_role ? 1 : 0

  statement {
    sid    = "ReadProxySecrets"
    effect = "Allow"
    actions = [
      "secretsmanager:GetSecretValue",
      "secretsmanager:DescribeSecret",
    ]
    resources = var.secret_arns
  }

  dynamic "statement" {
    for_each = var.kms_key_arn == null ? [] : [1]
    content {
      sid       = "DecryptProxySecrets"
      effect    = "Allow"
      actions   = ["kms:Decrypt"]
      resources = [var.kms_key_arn]
      condition {
        test     = "StringEquals"
        variable = "kms:ViaService"
        values   = ["secretsmanager.${var.region}.amazonaws.com"]
      }
    }
  }
}

resource "aws_iam_role_policy" "secrets" {
  count = local.create_role ? 1 : 0

  name   = "${var.name}-secrets-access"
  role   = aws_iam_role.this[0].id
  policy = data.aws_iam_policy_document.secrets[0].json
}

resource "aws_db_proxy" "this" {
  name          = var.name
  engine_family = var.engine_family

  require_tls         = true
  idle_client_timeout = var.idle_client_timeout
  debug_logging       = var.debug_logging

  role_arn               = local.role_arn
  vpc_subnet_ids         = var.vpc_subnet_ids
  vpc_security_group_ids = var.vpc_security_group_ids

  # One auth block per secret. iam_auth can be REQUIRED to force token-based
  # client connections; default REQUIRED for the strongest posture.
  dynamic "auth" {
    for_each = var.secret_arns
    content {
      auth_scheme = "SECRETS"
      description = "${var.name} secret ${auth.key}"
      iam_auth    = var.iam_auth
      secret_arn  = auth.value
    }
  }

  tags = local.tags
}

# The connection pool. This resource is implicitly imported, not created, so it
# needs replace_triggered_by to stay in sync when the proxy is replaced.
resource "aws_db_proxy_default_target_group" "this" {
  db_proxy_name = aws_db_proxy.this.name

  connection_pool_config {
    max_connections_percent      = var.max_connections_percent
    max_idle_connections_percent = var.max_idle_connections_percent
    connection_borrow_timeout    = var.connection_borrow_timeout
    init_query                   = var.init_query
    # Session pinning filters are only honored for the MySQL engine family.
    session_pinning_filters = var.engine_family == "MYSQL" ? var.session_pinning_filters : null
  }

  lifecycle {
    replace_triggered_by = [aws_db_proxy.this.id]
  }
}

# Attach the proxy to a single RDS instance OR an Aurora cluster (not both).
resource "aws_db_proxy_target" "this" {
  db_proxy_name     = aws_db_proxy.this.name
  target_group_name = aws_db_proxy_default_target_group.this.name

  db_instance_identifier = var.db_instance_identifier
  db_cluster_identifier  = var.db_cluster_identifier

  lifecycle {
    replace_triggered_by = [aws_db_proxy.this.id]
  }
}

variables.tf

variable "name" {
  description = "Proxy name; unique per account/region. Letters, digits, hyphens; starts with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9-]{0,62}$", var.name)) && !can(regex("--|-$", var.name))
    error_message = "name must start with a letter, contain only letters/digits/hyphens, and not end in or contain consecutive hyphens."
  }
}

variable "engine_family" {
  description = "Database protocol the proxy speaks: POSTGRESQL, MYSQL, or SQLSERVER."
  type        = string

  validation {
    condition     = contains(["POSTGRESQL", "MYSQL", "SQLSERVER"], var.engine_family)
    error_message = "engine_family must be one of: POSTGRESQL, MYSQL, SQLSERVER."
  }
}

variable "region" {
  description = "AWS region (used to scope the KMS ViaService condition on the secrets policy)."
  type        = string
  default     = "us-east-1"
}

variable "vpc_subnet_ids" {
  description = "Private subnet IDs the proxy ENIs are placed in (at least two AZs recommended)."
  type        = list(string)

  validation {
    condition     = length(var.vpc_subnet_ids) >= 2
    error_message = "vpc_subnet_ids must contain at least two subnets across two availability zones."
  }
}

variable "vpc_security_group_ids" {
  description = "Security group IDs associated with the proxy ENIs."
  type        = list(string)

  validation {
    condition     = length(var.vpc_security_group_ids) > 0
    error_message = "At least one security group ID is required."
  }
}

variable "secret_arns" {
  description = "Secrets Manager secret ARNs holding DB credentials. One auth block is created per secret."
  type        = list(string)

  validation {
    condition     = length(var.secret_arns) > 0
    error_message = "At least one secret ARN is required for SECRETS authentication."
  }
}

variable "role_arn" {
  description = "Existing IAM role ARN for Secrets Manager access. Null lets the module create a least-privilege role."
  type        = string
  default     = null
}

variable "kms_key_arn" {
  description = "KMS key ARN used to encrypt the secrets, so the created role can decrypt them. Null skips the kms:Decrypt statement (AWS-managed key)."
  type        = string
  default     = null
}

variable "iam_auth" {
  description = "Whether clients must use IAM authentication to the proxy: REQUIRED or DISABLED."
  type        = string
  default     = "REQUIRED"

  validation {
    condition     = contains(["REQUIRED", "DISABLED"], var.iam_auth)
    error_message = "iam_auth must be REQUIRED or DISABLED."
  }
}

variable "idle_client_timeout" {
  description = "Seconds a client connection can be idle before the proxy disconnects it."
  type        = number
  default     = 1800

  validation {
    condition     = var.idle_client_timeout >= 1 && var.idle_client_timeout <= 28800
    error_message = "idle_client_timeout must be between 1 and 28800 seconds."
  }
}

variable "debug_logging" {
  description = "Log SQL statement text for debugging. Keep false in production (logs may leak data)."
  type        = bool
  default     = false
}

variable "max_connections_percent" {
  description = "Max pool size as a percentage of the backend's max_connections."
  type        = number
  default     = 90

  validation {
    condition     = var.max_connections_percent >= 1 && var.max_connections_percent <= 100
    error_message = "max_connections_percent must be between 1 and 100."
  }
}

variable "max_idle_connections_percent" {
  description = "How aggressively the proxy keeps idle connections, as a percentage of max_connections."
  type        = number
  default     = 50

  validation {
    condition     = var.max_idle_connections_percent >= 0 && var.max_idle_connections_percent <= 100
    error_message = "max_idle_connections_percent must be between 0 and 100."
  }
}

variable "connection_borrow_timeout" {
  description = "Seconds the proxy waits for a free connection when the pool is saturated."
  type        = number
  default     = 120

  validation {
    condition     = var.connection_borrow_timeout >= 0 && var.connection_borrow_timeout <= 3600
    error_message = "connection_borrow_timeout must be between 0 and 3600 seconds."
  }
}

variable "init_query" {
  description = "Optional SQL run on each new backend connection (e.g. 'SET timezone=UTC'). Empty by default."
  type        = string
  default     = null
}

variable "session_pinning_filters" {
  description = "Operations exempted from session pinning. MySQL only; allowed value EXCLUDE_VARIABLE_SETS."
  type        = list(string)
  default     = ["EXCLUDE_VARIABLE_SETS"]
}

variable "db_instance_identifier" {
  description = "RDS instance identifier to register as the proxy target. Mutually exclusive with db_cluster_identifier."
  type        = string
  default     = null
}

variable "db_cluster_identifier" {
  description = "Aurora cluster identifier to register as the proxy target. Mutually exclusive with db_instance_identifier."
  type        = string
  default     = null

  validation {
    condition     = !(var.db_cluster_identifier != null && var.db_instance_identifier != null)
    error_message = "Set exactly one of db_instance_identifier or db_cluster_identifier, not both."
  }
}

variable "tags" {
  description = "Additional tags merged onto every resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "proxy_arn" {
  description = "ARN of the RDS proxy."
  value       = aws_db_proxy.this.arn
}

output "proxy_name" {
  description = "Name of the RDS proxy."
  value       = aws_db_proxy.this.name
}

output "endpoint" {
  description = "Endpoint hostname applications connect to instead of the database."
  value       = aws_db_proxy.this.endpoint
}

output "role_arn" {
  description = "ARN of the IAM role the proxy uses to read Secrets Manager."
  value       = local.role_arn
}

output "target_group_name" {
  description = "Name of the default target group holding the connection pool config."
  value       = aws_db_proxy_default_target_group.this.name
}

output "target_endpoint" {
  description = "Hostname of the registered RDS instance target (null for Aurora clusters)."
  value       = aws_db_proxy_target.this.endpoint
}

output "target_rds_resource_id" {
  description = "Resource ID of the registered instance or cluster target."
  value       = aws_db_proxy_target.this.rds_resource_id
}

How to use it

module "rds_proxy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds-proxy?ref=v1.0.0"

  name          = "orders-prod-proxy"
  engine_family = "POSTGRESQL"
  region        = "us-east-1"

  vpc_subnet_ids         = aws_db_subnet_group.private.subnet_ids
  vpc_security_group_ids = [aws_security_group.proxy.id]

  # The module builds a least-privilege role scoped to this secret + KMS key.
  secret_arns = [aws_db_instance.orders.master_user_secret[0].secret_arn]
  kms_key_arn = aws_kms_key.secrets.arn

  iam_auth            = "REQUIRED"
  idle_client_timeout = 900

  # Tuned pool: leave headroom under max_connections, fail fast under pressure.
  max_connections_percent      = 90
  max_idle_connections_percent = 30
  connection_borrow_timeout    = 60

  # Point the proxy at the single RDS instance.
  db_instance_identifier = aws_db_instance.orders.identifier

  tags = {
    Environment = "prod"
    Team        = "fulfillment"
    CostCenter  = "ORD-7741"
  }
}

# Downstream: grant a Lambda's execution role permission to connect via IAM auth
# and hand it the proxy endpoint instead of the raw database address.
resource "aws_iam_role_policy" "lambda_rds_connect" {
  name = "orders-lambda-rds-connect"
  role = aws_iam_role.orders_lambda.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["rds-db:connect"]
      Resource = "arn:aws:rds-db:us-east-1:123456789012:dbuser:${module.rds_proxy.target_rds_resource_id}/ordersapp"
    }]
  })
}

resource "aws_lambda_function" "orders_api" {
  function_name = "orders-api"
  role          = aws_iam_role.orders_lambda.arn
  runtime       = "nodejs20.x"
  handler       = "index.handler"
  filename      = "orders-api.zip"

  environment {
    variables = {
      DB_HOST = module.rds_proxy.endpoint # proxy endpoint, not the DB endpoint
      DB_PORT = "5432"
    }
  }

  vpc_config {
    subnet_ids         = aws_db_subnet_group.private.subnet_ids
    security_group_ids = [aws_security_group.lambda.id]
  }
}

Pin the module with ?ref=<tag> so a proxy never silently picks up a breaking module change — replacing a proxy in place disrupts every pooled connection.

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/rds_proxy/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds-proxy?ref=v1.0.0"
}

inputs = {
  name = "..."
  engine_family = "..."
  vpc_subnet_ids = ["...", "..."]
  vpc_security_group_ids = ["..."]
  secret_arns = ["..."]
  db_instance_identifier = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/rds_proxy && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Proxy name; unique per account/region.
engine_family string Yes POSTGRESQL, MYSQL, or SQLSERVER.
vpc_subnet_ids list(string) Yes Private subnets for the proxy ENIs (>= 2 AZs).
vpc_security_group_ids list(string) Yes Security groups for the proxy ENIs.
secret_arns list(string) Yes Secrets Manager secret ARNs with DB credentials.
region string us-east-1 No Region used to scope the KMS ViaService condition.
role_arn string null No Existing IAM role; null creates a least-privilege role.
kms_key_arn string null No KMS key ARN encrypting the secrets, for kms:Decrypt.
iam_auth string REQUIRED No REQUIRED or DISABLED IAM auth for client connections.
idle_client_timeout number 1800 No Idle client disconnect timeout in seconds.
debug_logging bool false No Log SQL statement text (keep off in prod).
max_connections_percent number 90 No Max pool size as % of backend max_connections.
max_idle_connections_percent number 50 No Idle connection retention as % of max_connections.
connection_borrow_timeout number 120 No Seconds to wait for a free pooled connection.
init_query string null No SQL run on each new backend connection.
session_pinning_filters list(string) [“EXCLUDE_VARIABLE_SETS”] No Pinning exemptions (MySQL only).
db_instance_identifier string null No RDS instance target (exclusive with cluster).
db_cluster_identifier string null No Aurora cluster target (exclusive with instance).
tags map(string) {} No Additional tags merged onto every resource.

Outputs

Name Description
proxy_arn ARN of the RDS proxy.
proxy_name Name of the RDS proxy.
endpoint Endpoint hostname applications connect to.
role_arn ARN of the IAM role used for Secrets Manager.
target_group_name Name of the default target group (pool config).
target_endpoint Hostname of the registered instance target.
target_rds_resource_id Resource ID of the registered target.

Enterprise scenario

A fulfillment platform runs its orders, inventory, and shipping APIs as autoscaled Fargate services and a swarm of Lambda functions, all hitting PostgreSQL on RDS. Under a flash-sale traffic spike, the raw database kept hitting max_connections as each new task opened its own pool, so the platform team published this module at v1.0.0 and put a proxy in front of every instance. Each proxy enforces require_tls = true and iam_auth = "REQUIRED", so applications now fetch short-lived IAM tokens instead of carrying passwords; the module’s generated role can read only that instance’s master secret and decrypt it with the team’s KMS key. With max_connections_percent = 90 and connection_borrow_timeout = 60, the proxy absorbs connection churn and fails over Aurora promotions in seconds, and a security audit confirms zero plaintext database paths and no over-scoped secrets roles across the fleet.

Best practices

TerraformAWSRDS ProxyModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading