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
- You run serverless or highly elastic compute (Lambda, Fargate, autoscaling ECS/EKS) in front of RDS or Aurora and need to cap backend connections so a traffic spike does not exhaust
max_connections. - You want faster, more graceful failover — the proxy holds client connections open during an Aurora writer promotion, cutting the blast radius of a failover from minutes to seconds.
- You need to enforce TLS and IAM authentication to the database so applications stop passing long-lived passwords and instead use short-lived tokens scoped by IAM.
- You operate many app→database paths across teams and want a paved-road module so nobody ships a proxy without TLS, a least-privilege secrets role, or a tuned pool.
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 config — live/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 config — live/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
- Always enforce TLS, and prefer IAM auth. This module hard-codes
require_tls = true; keepiam_auth = "REQUIRED"so applications use short-lived tokens (rds-db:connect) instead of long-lived passwords, and the database never sees an unencrypted client connection. - Scope the secrets role to exactly the secrets you use. Let the module build the least-privilege role (
role_arn = null) so it can read only the suppliedsecret_arnsand decrypt with yourkms_key_arn— never attach a broadsecretsmanager:*policy. - Tune the pool for your backend, not the defaults. Set
max_connections_percentbelow 100 to leave headroom for admin connections, lowermax_idle_connections_percenton bursty workloads to reclaim connections faster, and use a shortconnection_borrow_timeoutso saturated clients fail fast instead of hanging. - Mind the replacement behaviour. The default target group and target are implicitly managed; this module wires
replace_triggered_by = [aws_db_proxy.this.id]so pool config and target stay consistent when the proxy is replaced — keep that lifecycle rule. - Point apps at the proxy endpoint, not the database. Wire the application’s
DB_HOSTto the module’sendpointoutput; pointing at the raw RDS address bypasses pooling and failover protection entirely. - Reserve the proxy for high-churn or failover-sensitive paths. A proxy adds a small latency and cost; use it where connection storms or fast failover matter, and connect directly for steady, low-cardinality workloads.