AWS Databases

Can't Connect to RDS? The Connection-Timeout & 'Too Many Connections' Playbook

Quick take: “Can’t connect to RDS” is never one bug — it is one of three gates failing, in a fixed order, and each gate speaks a different error. Gate 1, network reachability: can a TCP packet even reach :5432/:3306? — the DB security group (does it allow the app’s SG?), the NACL, a route / the PubliclyAccessible flag, and resolving the right endpoint (writer vs reader, not a stale one after failover). Gate 2, capacity: the packet reached a healthy engine but every session slot is taken — max_connections exhaustion, leaks, connection storms. Gate 3, auth/TLS: a socket opened but the login or SSL handshake was refused — wrong password / mid-rotation secret, an expired IAM auth token (15-minute life), pg_hba, or rds.force_ssl. The whole skill is reading the error string to jump straight to the right gate: a silent timeout is gate 1, too many connections is gate 2, password authentication failed / SSL required is gate 3. Never start by “resetting the password” for a timeout — that is gate 3 tooling on a gate 1 problem.

Your service was fine at 09:00. By 09:05 the checkout API is throwing psql: error: connection to server at "orders.abc123.ap-south-1.rds.amazonaws.com" (10.0.20.14), port 5432 failed: Connection timed out, and the on-call engineer does what almost everyone does first: opens the RDS console, sees the instance is Available, and concludes “the database is up, so it must be the app.” Both halves are true and neither helps. The instance is up; the app cannot open a session to it; and the gap between those two facts is a dozen distinct failure modes that all surface as some flavour of “can’t connect.” One of them is a security-group rule that was tightened an hour ago. One is a connection pool that leaked until max_connections filled. One is an IAM auth token that expired 15 minutes into a long-lived worker. They are not the same problem and they do not share a fix.

This article is the map you keep open when that pager fires. The organising idea is simple and it is the entire method: a connection to RDS or Aurora passes three gates in order — reachability, capacity, auth/TLS — and the error string tells you which gate stopped you. A Connection timed out never got past gate 1, so touching the password is wasted motion. FATAL: sorry, too many clients already sailed through gate 1 and hit a full engine — a security-group change will do nothing. FATAL: password authentication failed proves the network path and the engine are both fine and the problem is purely credentials. Read the error, pick the gate, run the two commands that confirm it, apply the one fix. We will enumerate every failure in each gate, hand you a master symptom → category → confirm → fix table you can grep during an incident, an error-string reference for both Postgres and MySQL, and a hands-on lab that deliberately breaks reachability, capacity, and TLS so you have seen each failure with your own eyes. Every check shows the aws/nc/dig command and the Terraform that prevents the regression. It maps to the database and networking objectives of SAA-C03, SOA-C02, and DVA-C02.

If you have not yet stood up the instance this playbook debugs, build one first with Launch RDS for MySQL & PostgreSQL: A Hands-On Guide, and understand the failover behaviour that makes endpoints go stale in RDS Multi-AZ & Read Replicas for High Availability. This playbook assumes the database exists and goes straight to why the client can’t open a session.

What problem this solves

In production, “can’t connect to the database” is the single most common and most mis-diagnosed incident class, because the symptom carries almost no information. The application logs Connection timed out or too many connections or authentication failed, an engineer forms a theory in the first ten seconds (“firewall,” “the DB is down,” “someone changed the password”), and then spends an hour proving or disproving that theory instead of asking the only question that matters: which of the three gates rejected the connection? A timeout and a too many connections error demand completely different responses — one is a network problem you fix with a security-group rule, the other is a capacity problem you fix with a connection pool — yet teams routinely apply the wrong one because they never categorised the failure.

What breaks without this discipline is a recognisable parade of wasted incidents. Someone tightens a security group, forgets the DB SG references the app SG, and every service times out — but the on-call engineer restarts the app three times because “it worked yesterday.” A connection pool is misconfigured or a worker leaks idle in transaction sessions until the engine hits max_connections, and the team scales the application (adding more instances, each with its own pool, making it strictly worse) instead of adding an RDS Proxy. A Secrets Manager rotation fires mid-deploy and half the fleet authenticates with the old password for ninety seconds. An engineer enables rds.force_ssl for compliance and every non-TLS client drops with a cryptic no pg_hba.conf entry ... SSL off. A Multi-AZ failover flips the endpoint, but a JVM with a cached DNS entry keeps dialling the old primary’s IP for minutes. Each is a five-minute fix once you know the gate — and a lost afternoon when you don’t.

Who hits this: every team that runs a managed database, from a first serverless function through a thousand-container fleet. It bites hardest at three moments — a security change (SG tightened, force_ssl turned on, a secret rotated), a scale event (traffic doubles and the pool math finally exceeds max_connections), and a failover (planned or not, where endpoints go stale and every client reconnects at once in a storm). The cure is a shared, ordered model of the connection path plus the reflex to read the error string first. Here is the entire field on one page: every failure class, the gate it lives in, the exact error it produces, and the one thing that confirms it.

# Failure class Gate Exact error you see Confirm with
1 DB security group blocks the app Reachability Connection timed out (silent) nc -vz endpoint 5432; describe-security-groups
2 NACL / route / subnet has no path Reachability Connection timed out (silent) Reachability Analyzer; describe-network-acls
3 PubliclyAccessible=false from internet Reachability timed out from outside, works inside describe-db-instances PubliclyAccessible
4 Wrong endpoint (reader vs writer) Reachability cannot execute … in a read-only transaction describe-db-cluster-endpoints
5 Stale endpoint / DNS after failover Reachability timeout to a dead IP after a flip dig endpoint; driver DNS TTL
6 max_connections exhausted Capacity FATAL: sorry, too many clients already / ERROR 1040 CloudWatch DatabaseConnections
7 Leaked idle in transaction sessions Capacity slow climb to the cap, then #6 pg_stat_activity state column
8 Connection storm on failover Capacity mass reconnect spikes to the cap DatabaseConnections spike at failover
9 Wrong / rotated master password Auth FATAL: password authentication failed re-fetch the secret; test manually
10 IAM auth token expired (>15 min) Auth PAM authentication failed / password authentication failed token age; rds_iam grant
11 Missing pg_hba / rds_iam role Auth no pg_hba.conf entry for host … \du role; pg_hba line
12 TLS required but client sent plaintext Auth/TLS SSL required / no pg_hba.conf entry … SSL off rds.force_ssl; require_secure_transport
13 Storage full → read-only State cannot execute INSERT in a read-only transaction FreeStorageSpace=0; status storage-full
14 Parameter change stuck pending-reboot State new setting “does nothing” describe-db-parameters ApplyMethod
15 Client connect_timeout too low Client timeout expired before TCP completes driver connect_timeout value
16 DB mid-reboot / maintenance / failover Availability brief timeouts, then recovers DBInstanceStatus; events

The rest of this article is that table, expanded — one gate at a time, every failure enumerated with how it presents and how to confirm it — followed by the tools and the lab that turn a vague “can’t connect” into a named, fixed root cause.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable launching an RDS or Aurora instance, running the AWS CLI with credentials, and connecting with psql or mysql. You do not need to be a DBA — the goal is to make you the person who localises any connection failure in minutes rather than restarting services and hoping. Familiarity with VPC basics (subnets, route tables, security groups) helps; where a failure is purely a VPC-path problem, this playbook hands off to the network playbooks below.

This article is the connection-layer hub. Each gate has a deeper build-it or concept article that this one references when the fix lives there.

Gate / topic Owned by Deeper article This playbook covers
Build the instance App / platform Launch RDS for MySQL & PostgreSQL Why a healthy instance still refuses connections
Failover & endpoints Platform RDS Multi-AZ & Read Replicas for HA Stale endpoints, reader/writer, failover storms
Security group & NACL rules Split Security Groups & NACLs Deep Dive The SG chain as gate 1; how to confirm a block
Full VPC packet path Networking VPC Connectivity Troubleshooting Playbook When the drop is upstream of the DB entirely

Core concepts

One connection, three gates, in order

A client connecting to RDS is not doing one thing; it is passing three checkpoints in sequence, and it fails at the first one that says no. Internalise the order and diagnosis becomes mechanical: read the error, map it to a gate, and never debug a downstream gate for an upstream failure.

Gate Question it answers Passes when Fails with
1 · Reachability Can a TCP packet reach the listener on :5432/:3306? SG + NACL + route + endpoint DNS all allow the packet Silent Connection timed out (or refused if wrong port)
2 · Capacity Is there a free session slot on the engine? DatabaseConnections < max_connections too many clients already / Too many connections
3 · Auth / TLS Are the credentials valid and is the transport acceptable? Password/IAM token correct, SSL satisfies pg_hba/force_ssl password authentication failed / SSL required

Two facts on this table do most of the work in real incidents. First, the gates are strictly ordered: you cannot get a too many connections error unless the packet already crossed the security group, and you cannot get password authentication failed unless there was a free slot to attempt the login. So the error string is a position marker — it tells you every earlier gate already passed. Second, each gate has different tooling: gate 1 is nc/dig/Reachability Analyzer/SG rules, gate 2 is CloudWatch/pg_stat_activity/RDS Proxy, gate 3 is secrets/tokens/sslmode. Applying gate-3 tooling (resetting a password) to a gate-1 symptom (a timeout) is the most common wasted hour in this whole domain.

What actually happens when you open a connection

Opening a database session is a sequence of distinct steps, and each step is where a specific gate bites. Knowing the sequence lets you place an error precisely — a failure at the TLS negotiation step is a different world from a failure at the backend-process step.

Step What happens Gate Fails with
1 · DNS resolve endpoint → IP (public or private) 1 NXDOMAIN, or a stale/dead IP after failover
2 · TCP handshake SYN/SYN-ACK to :5432/:3306 1 timed out (dropped) or refused (no listener)
3 · Startup packet client announces user/db/params 1/2 rejected if the engine is unavailable
4 · Slot check engine allocates a session slot 2 too many clients already / 1040
5 · TLS negotiation SSL handshake (if required/offered) 3 SSL required, cert verify failed
6 · Authentication password / IAM token / plugin 3 password authentication failed, PAM …
7 · Backend ready process forked, ReadyForQuery ready — or read-only if you’re on a reader

The order matters: steps 1–3 are reachability, step 4 is capacity, steps 5–6 are auth/TLS, and step 7 is where the writer/reader and storage-full state traps show up. An error naming a later step proves every earlier step succeeded.

The three signals: timeout, refused, and a named error

Before you run a single AWS command, the shape of the failure narrows the gate.

What the client reports What it means Gate First thing to check
Connection timed out (hangs ~10–75s) Packet silently dropped — never reached a listener 1 SG → NACL → route → endpoint
Connection refused (instant) Reached a host, nothing listening on that port 1 (edge) Right endpoint? right port? instance rebooting?
Name or service not known / NXDOMAIN DNS didn’t resolve the endpoint 1 Copy the exact endpoint; VPC DNS; resolver
too many clients / ERROR 1040 Reached the engine, no free slot 2 DatabaseConnections vs max_connections
password authentication failed Path + slot fine; bad credentials 3 Secret value, IAM token freshness
no pg_hba.conf entry … SSL off Reached auth; TLS required, client sent plaintext 3 rds.force_ssl; add sslmode=require
cannot execute … in a read-only transaction Connected to a reader / read-only / storage-full 1/State Writer endpoint? storage full?

Burn this in: timeout = gate 1 (reachability); too many = gate 2 (capacity); a named auth/SSL error = gate 3. Half of all mis-diagnoses come from treating a timeout as an auth problem (“let me reset the password”) — but a timeout proves the packet never reached the login prompt, so credentials are irrelevant.

Which endpoint are you even dialling?

Half of “it connects but writes fail” tickets are simply the wrong endpoint. RDS and Aurora expose several, and they are not interchangeable.

Endpoint type DNS shape Points at Read/write Fails with, when misused
RDS instance endpoint name.abc123.rgn.rds.amazonaws.com one specific instance R/W (or RO if a replica) timeout if that instance is down/replaced
Aurora cluster (writer) name.cluster-abc123.rgn.rds.amazonaws.com current writer, follows failover R/W — (this is the one you want for writes)
Aurora reader name.cluster-ro-abc123.rgn… load-balanced readers read-only cannot execute INSERT … read-only transaction
Custom endpoint name.cluster-custom-xyz.rgn… a chosen subset of instances depends surprising routing if members change
RDS Proxy endpoint proxy.proxy-abc123.rgn… the proxy (then the DB) R/W (read-only proxy optional) proxy-specific auth/pinning issues

The two traps: writing to the reader endpoint (or a read replica’s instance endpoint) throws cannot execute … in a read-only transaction, and pinning to an instance endpoint means you do not follow a failover — the cluster writer endpoint does. Always write through the cluster writer (Aurora) or the primary’s endpoint, and let the endpoint — not a cached IP — track the current writer.

Gate 1 — Network reachability: can the packet even arrive?

This is where the largest share of incidents live and where the security group is both the usual culprit and the most misunderstood object. The rule of gate 1: every drop here is silent — the client just times out — so a timeout names no specific layer on its own; you localise it with nc, dig, and Reachability Analyzer.

The security-group chain — the classic

The DB’s inbound security group is deny-by-default. For an in-VPC app to connect, that SG needs an ingress rule allowing the app on the database port. The single most common mistake is what you reference as the source.

SG source you wrote What it actually allows In-VPC app result Verdict
Source = the app’s SG (sg-app) any ENI in that SG, by identity connects correct — survives IP changes
Source = app subnet CIDR (10.0.1.0/24) anything in that subnet connects works, coarser than SG-ref
Source = your office/home IP only that public IP timeout (app has a private IP) wrong for in-VPC apps
Source = the NAT gateway EIP the NAT’s public IP timeout (app→DB stays private) classic mistake: DB traffic never hits the NAT
Source = 0.0.0.0/0 the whole internet connects (and exposed) never do this

The NAT-EIP trap deserves a sentence because it fools experienced people: an app in a private subnet reaches the internet through the NAT gateway (so its outbound public IP is the NAT EIP), but a connection to an RDS endpoint in the same VPC is routed over the VPC’s local route and keeps the app’s private IP as the source — it never touches the NAT. So a DB SG that allows the NAT EIP allows the wrong address entirely. Reference the app’s SG and the whole problem disappears.

# Confirm which SGs the DB uses, then read their inbound rules:
aws rds describe-db-instances --db-instance-identifier orders \
  --query 'DBInstances[0].VpcSecurityGroups[].VpcSecurityGroupId' --output text
aws ec2 describe-security-groups --group-ids sg-0db... \
  --query 'SecurityGroups[0].IpPermissions'
# The fix: allow the APP security group on the DB port (Postgres 5432 / MySQL 3306).
aws ec2 authorize-security-group-ingress --group-id sg-0db... \
  --protocol tcp --port 5432 --source-group sg-0app...
# Terraform — the correct, IP-agnostic rule: DB SG references the app SG.
resource "aws_security_group" "db" {
  name   = "orders-db"
  vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = aws_security_group.app.id   # not a CIDR, not an EIP
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

There is a second, sneakier SG failure: the app’s egress. Custom security groups on the app side sometimes replace the default allow-all egress with a narrow list that forgets the DB port — so the packet dies leaving the app, not arriving at the DB. Check both directions.

Direction Object Rule needed Symptom if missing
Egress App SG allow TCP 5432/3306 to DB SG/CIDR timeout (packet never leaves app)
Ingress DB SG allow TCP 5432/3306 from app SG timeout (packet dropped at DB)
Return (stateful) automatic on SGs — (SGs are stateful)

Public access: PubliclyAccessible, subnet, route, and DNS

To reach RDS from outside the VPC (your laptop, a partner network), four things must all be true, and missing any one produces the same “works from the bastion, not from home” timeout.

Requirement Setting Confirm If missing
Instance opts into a public IP PubliclyAccessible = true describe-db-instances … PubliclyAccessible endpoint resolves to a private IP only
DB sits in a public subnet subnet route 0.0.0.0/0 → igw-… route table of the DB subnet no internet return path
Subnet group spans public subnets DB subnet group members describe-db-subnet-groups placed in a private subnet
SG allows your public IP ingress from your.ip/32 describe-security-groups your packet dropped at the SG

The subtlety is DNS: when PubliclyAccessible=true, the RDS endpoint resolves to the public IP from outside the VPC and to the private IP from inside it — the same hostname, two answers. So dig endpoint from your laptop and from an in-VPC host should differ; if the outside dig returns a 10.x/172.x/private address, public access is off. Flip it and re-check:

# Is it public, and what does the world resolve it to?
aws rds describe-db-instances --db-instance-identifier orders \
  --query 'DBInstances[0].PubliclyAccessible'
dig +short orders.abc123.ap-south-1.rds.amazonaws.com   # from OUTSIDE the VPC

# Turn on public access (⚠️ pair with a tight SG — never 0.0.0.0/0):
aws rds modify-db-instance --db-instance-identifier orders \
  --publicly-accessible --apply-immediately

For most production databases the correct answer is PubliclyAccessible=false and you connect through a bastion, VPN, or SSM port-forward — public access is a lab/edge convenience, not a default.

NACL, subnet group, and AZ

Past the SG, three subnet-level things drop packets silently.

Layer Failure Confirm Fix
NACL (stateless) inbound ALLOW but outbound ephemeral 1024-65535 missing → reply dropped describe-network-acls for the DB subnet add outbound tcp 1024-65535 to the client CIDR
NACL ordering a low-numbered DENY shadows your ALLOW read rules in number order renumber; leave gaps
Subnet group spans too few AZs; after an AZ event the DB has nowhere to fail to describe-db-subnet-groups span ≥2 (Multi-AZ needs ≥2) subnets in different AZs
Route table DB subnet lacks the route back to the client (cross-VPC) route table of the DB subnet add the return route

RDS security groups are stateful, but if your DB subnet uses a custom NACL (many do, for defence in depth), the stateless return-traffic omission is the classic block: the request arrives, the engine answers on the client’s high ephemeral port, and the NACL — having no outbound rule for 1024-65535 — drops the reply. The client times out with the request having arrived. This is covered end-to-end in the Security Groups & NACLs Deep Dive; the confirming signal is a Flow Logs pair showing inbound ACCEPT, outbound REJECT.

Reachability confirmation toolkit

Do not guess at gate 1 — three commands turn a silent timeout into a named layer.

Tool Command Reads Tells you
TCP probe nc -vz endpoint 5432 live socket from the app subnet open (succeeded) vs timed out vs refused
DNS dig +short endpoint resolver answer resolves? public vs private IP; empty = DNS problem
Reachability Analyzer create-network-insights-path src=ENI dst=DB config path NO_ROUTE, ENI_SG_RULES_MISMATCH, SUBNET_ACL_RESTRICTION
Flow Logs filter DB ENI for REJECT live drops which layer dropped it (pair in/out rows)

nc is the fastest first move from the app host: succeeded means gate 1 is clear (go to gate 2/3), timed out means a firewall/route dropped it, Connection refused means you reached a host with nothing listening on that port — usually the wrong port or an instance mid-reboot, not a security group.

Stale endpoints and DNS caching after a failover

A failover (Multi-AZ or Aurora) does not change the endpoint hostname — it re-points the DNS to the new writer’s IP with a short TTL. Reachability is only preserved if your client actually re-resolves. The classic post-failover incident is a client that cached the old IP and keeps dialling a node that no longer serves writes, producing timeouts against a “healthy” cluster.

Client / runtime Default DNS cache TTL Risk Fix
RDS/Aurora endpoint ~5 s (server side) fine if the client honours it
JVM (default) 30 s (or -1/forever with a SecurityManager) caches old IP for minutes networkaddress.cache.ttl=5
Node.js OS resolver, no cache by default usually fine reconnect on error
Python (psycopg) no client cache; per-connect resolve fine on reconnect reconnect on error
App-level pool reuses sockets; won’t re-resolve until a new connection old sockets survive to a dead node evict + reconnect on failover error

The fix is two-part: lower the driver/runtime DNS TTL to ~5 seconds so a new connection resolves the new IP promptly, and reconnect on error (don’t reuse a socket to a node that just lost the writer role). Confirm the trap by comparing dig +short endpoint (the current IP) against the IP the failing client is actually connected to — if they differ, you cached a stale endpoint. This is the reachability face of the failover behaviour covered in RDS Multi-AZ & Read Replicas for High Availability.

Gate 2 — Too many connections: capacity, leaks, and RDS Proxy

Once the packet reaches a healthy engine, the next wall is finite: every database has a hard cap on concurrent sessions, and blowing past it is one of the most common production outages because it appears suddenly, under exactly the load you least want to fail under.

max_connections is a formula, not a number

On RDS and Aurora, the default max_connections is a formula of instance memory, not a fixed value — so a smaller instance class silently means fewer sessions, and “we scaled down to save money” can become “the app can’t connect at peak.”

Engine Default max_connections formula Notes
RDS PostgreSQL LEAST({DBInstanceClassMemory/9531392}, 5000) hard-capped at 5000
Aurora PostgreSQL LEAST({DBInstanceClassMemory/9531392}, 5000) same formula; scales with ACU on Serverless v2
RDS MySQL {DBInstanceClassMemory/12582880} no hard cap in the formula
Aurora MySQL GREATEST({log(DBInstanceClassMemory/805306368)*45}, …) log-scaled; larger classes get proportionally more

DBInstanceClassMemory is the instance RAM minus what RDS reserves for the OS and engine, expressed in bytes — so the real number is a bit below RAM/divisor. Worked approximate values (Postgres) so you can sanity-check without doing the arithmetic mid-incident:

Instance class RAM Approx max_connections (Postgres) Approx (MySQL)
db.t3.micro 1 GiB ≈ 112 ≈ 85
db.t3.small 2 GiB ≈ 225 ≈ 170
db.t3.medium 4 GiB ≈ 450 ≈ 340
db.r6g.large 16 GiB ≈ 1,800 ≈ 1,365
db.r6g.xlarge 32 GiB ≈ 3,600 ≈ 2,730
db.r6g.2xlarge 64 GiB ≈ 5,000 (capped) ≈ 5,460

Two reservations shrink what your app actually gets. PostgreSQL keeps superuser_reserved_connections (default 3) for superusers, and RDS itself reserves a few sessions for the rdsadmin monitoring user — so on a db.t3.micro your application realistically has ~106 slots, not 112. Plan against the usable number.

Bucket Where it goes Typical count (t3.micro)
max_connections (formula) total ceiling ≈ 112
superuser_reserved_connections reserved for superusers 3
rdsadmin / monitoring RDS internal sessions ~2–5
Usable by your app everything else ≈ 104–106

The lesson: alarm and size against usable, not the raw cap — the last few slots are gone before you think you are full, which is exactly what the remaining connection slots are reserved warning is telling you.

You can raise max_connections in a parameter group, but it is not free memory: each Postgres connection is a backend process costing several MB, so setting max_connections=5000 on a 2 GiB instance invites OOM and swap long before you reach the cap. The right lever above a few hundred connections is not a bigger number — it is a pooler.

# What is the cap, and how close are you?
aws rds describe-db-parameters --db-parameter-group-name orders-pg16 \
  --query "Parameters[?ParameterName=='max_connections'].[ParameterValue]"
aws cloudwatch get-metric-statistics --namespace AWS/RDS \
  --metric-name DatabaseConnections --dimensions Name=DBInstanceIdentifier,Value=orders \
  --start-time 2026-07-14T09:00:00Z --end-time 2026-07-14T09:30:00Z \
  --period 60 --statistics Maximum

The exact errors, and the reserved-slot warning

Engine Error at the cap Meaning
PostgreSQL FATAL: sorry, too many clients already all max_connections slots in use
PostgreSQL FATAL: remaining connection slots are reserved for non-replication superuser connections you hit the pool minus superuser_reserved_connections — the last few are reserved
PostgreSQL FATAL: too many connections for role "app" a per-role CONNECTION LIMIT (not the global cap)
MySQL ERROR 1040 (HY000): Too many connections max_connections reached (ER_CON_COUNT_ERROR)
MySQL ERROR 1203 … max_user_connections a per-user MAX_USER_CONNECTIONS limit

The “reserved for superuser” message is a useful early warning — it means you are within three of the cap, not at it, so you have seconds, not zero. If you see it, you are about to get the hard too many clients next.

Leaks: the slow climb to the wall

A sudden spike to the cap is a storm; a slow climb over hours is a leak. The usual culprits:

Leak source What happens Confirm Fix
idle in transaction app opens a txn, never commits/rolls back; the session holds a slot and locks SELECT state,count(*) FROM pg_stat_activity GROUP BY 1 fix the app; set idle_in_transaction_session_timeout
No pooling / pool-per-instance every app instance opens its own N connections; scale-out multiplies them client_addr counts in pg_stat_activity central pool (RDS Proxy)
Pool max too high pool config × instances > max_connections pool config vs cap size pools to the cap, or use RDS Proxy
Lambda without Proxy each concurrent execution = a new connection; a spike opens thousands DatabaseConnections tracks concurrency RDS Proxy in front of Lambda
Zombie sessions after crash TCP half-open sessions linger until tcp_keepalives reap them state + state_change age tune keepalives; pg_terminate_backend
-- Postgres: who is holding slots, and are they idle-in-transaction?
SELECT state, count(*), max(now() - state_change) AS oldest
FROM pg_stat_activity WHERE datname = 'orders' GROUP BY state ORDER BY 2 DESC;
-- Reap a specific leaked session:
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction' AND now() - state_change > interval '10 min';

Connection storms on failover

The other way to hit the cap is not a leak but a stampede: at a failover, every client’s connection drops at once, and every client tries to reconnect in the same second. Without a pool absorbing it, the reconnect burst can slam the new writer’s slots the instant it comes up — turning a 30-second failover into a minutes-long outage.

T (approx) Event Connection state
T+0 s failover triggered; old writer drops sessions all app connections error at once
T+5–15 s DNS re-points the endpoint to the new writer clients that re-resolve find the new IP
T+15–30 s every client reconnects simultaneously burst toward max_connections
T+30 s new writer accepts; slots fill in a spike cap hit if pools are large × many instances
T+60 s steady state (if it survived the spike) connections settle

The math that decides whether you survive the spike is just multiplication — and it is why “scale the app” backfires:

Fleet Pool max / instance Total attempted vs db.r6g.large cap (~1,795)
6 instances 20 120 fine
20 instances 20 400 fine
40 instances (autoscaled) 20 800 fine until a leak eats headroom
40 instances 50 2,000 over the cap — storm = outage

Both problems have the same structural answer: an RDS Proxy that holds client connections through the failover and multiplexes them onto a bounded set of real sessions, so the reconnect burst never reaches the engine.

RDS Proxy — the real fix above a few hundred connections

The correct answer to “too many connections” is almost never “raise the cap.” It is to put a managed connection pool between the app and the database so thousands of client connections multiplex onto a small number of real database sessions. RDS Proxy does exactly this, and it also holds connections through a failover so the app doesn’t storm the new writer.

Approach Where the pool lives Handles failover storm Multiplexing Best for
No pool nowhere; connection per request no no never, past a toy
Client-side pool (HikariCP, pgbouncer sidecar) in each app instance no (each reconnects) within one instance steady, few app instances
RDS Proxy AWS-managed, between app & DB yes (holds + queues) yes (across all clients) Lambda, spiky, many instances, IAM auth

The one gotcha that surprises people: pinning. If a session sets state the proxy can’t safely share — a session variable, a prepared statement, a temp table, an advisory lock — the proxy pins that client to its backend connection for the session’s life, and multiplexing stops helping. Watch the DatabaseConnectionsCurrentlySessionPinned CloudWatch metric and the proxy logs; high pinning means you are paying for a proxy that isn’t pooling. Minimise per-session state to keep connections poolable.

# Create a proxy (needs a Secrets Manager secret with the DB creds + an IAM role):
aws rds create-db-proxy --db-proxy-name orders-proxy --engine-family POSTGRESQL \
  --auth '[{"AuthScheme":"SECRETS","SecretArn":"arn:aws:secretsmanager:...:secret:orders-XXXX","IAMAuth":"REQUIRED"}]' \
  --role-arn arn:aws:iam::111122223333:role/rds-proxy-role \
  --vpc-subnet-ids subnet-a subnet-b --require-tls
# Then point the app at the PROXY endpoint instead of the DB endpoint:
aws rds describe-db-proxies --db-proxy-name orders-proxy \
  --query 'DBProxies[0].Endpoint'
resource "aws_db_proxy" "orders" {
  name                   = "orders-proxy"
  engine_family          = "POSTGRESQL"
  role_arn               = aws_iam_role.proxy.arn
  vpc_subnet_ids         = [aws_subnet.a.id, aws_subnet.b.id]
  require_tls            = true
  auth {
    auth_scheme = "SECRETS"
    secret_arn  = aws_secretsmanager_secret.db.arn
    iam_auth    = "REQUIRED"
  }
}
resource "aws_db_proxy_default_target_group" "orders" {
  db_proxy_name = aws_db_proxy.orders.name
  connection_pool_config {
    max_connections_percent      = 100
    max_idle_connections_percent = 50
  }
}

Deep-dive on pooling, IAM auth, and serverless patterns lives in the RDS Proxy material; here the point is the diagnosis: too many connections under scale or from Lambda means “add a pool,” not “add a rule.”

Gate 3 — Auth and TLS: the socket opened, the login didn’t

If the client got a real database error (not a timeout), gates 1 and 2 already passed — the packet reached the engine and there was a free slot. Now it is purely credentials and transport.

Password auth and Secrets Manager rotation

Failure Error Confirm Fix
Wrong master password FATAL: password authentication failed for user "app" test the exact secret value manually correct the secret; modify-db-instance --master-user-password if truly lost
Rotation mid-flight intermittent auth failures for ~seconds during rotation Secrets Manager rotation history; correlate timing app must re-fetch on failure; use the AWSCURRENT stage; retry once
App cached an old secret persistent failures after a rotation app’s cached credential vs current secret fetch per connection or invalidate cache on auth error
Wrong username/db FATAL: database "orders" does not exist \l / SHOW DATABASES correct the connection string

The rotation trap is worth understanding: Secrets Manager rotation for RDS sets a new password on the DB and updates the secret’s AWSCURRENT version, but there is a brief window where a client holding the previous password fails. The cure is application discipline — fetch the secret at connect time (or on an auth-failure retry), not once at boot — plus using the managed rotation Lambda, which is designed to keep both the old and new password valid across the switch for exactly this reason.

IAM database authentication — the 15-minute token

IAM auth replaces the password with a short-lived token you generate from your AWS credentials. It is excellent for eliminating stored passwords, and it has one failure mode that catches everyone: the token is valid for only 15 minutes.

Requirement Detail If violated
Token freshness valid 15 minutes from generation long-lived worker reuses a stale token → auth fails
Generated per connection aws rds generate-db-auth-token … (SigV4-signed) reusing across a pool past 15 min fails
TLS required IAM auth only works over SSL/TLS plaintext attempt rejected
Postgres role user must be GRANTed rds_iam PAM authentication failed
MySQL user created IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS' access denied
IAM policy rds-db:connect on the dbuser resource ARN not authorized
Rate limit new IAM-auth connections are throttled (hundreds/sec, class-dependent) storms of new connections throttled

The classic incident: a background worker generates a token at startup, opens a pooled connection, and 20 minutes later the pool tries to open a new connection with the token it cached at boot — which expired 5 minutes ago — and the new connection fails while the old ones keep working, producing a maddening “some connections work, some don’t.” The fix is to generate the token immediately before each new connection, never cache it past a few minutes.

# Generate a fresh token (this is the "password"); it dies in 15 minutes.
TOKEN=$(aws rds generate-db-auth-token \
  --hostname orders.abc123.ap-south-1.rds.amazonaws.com \
  --port 5432 --region ap-south-1 --username app_iam)
# Connect — TLS is mandatory for IAM auth:
PGPASSWORD="$TOKEN" psql "host=orders.abc123.ap-south-1.rds.amazonaws.com \
  port=5432 user=app_iam dbname=orders sslmode=verify-full \
  sslrootcert=global-bundle.pem"
-- Postgres: the DB user must carry the rds_iam role, or auth fails at pg_hba.
CREATE USER app_iam;
GRANT rds_iam TO app_iam;

The four pieces that must all line up for IAM auth to work — miss any one and you get an auth failure that reads like a bad password:

Piece What it is Example
IAM policy action permission to connect rds-db:connect
IAM resource ARN scopes to a DB user arn:aws:rds-db:ap-south-1:1111…:dbuser:db-ABC123/app_iam
Resource ID the DB’s resource id (not the identifier) describe-db-instances … DbiResourceId
DB-side grant Postgres role / MySQL plugin GRANT rds_iam / AWSAuthenticationPlugin

pg_hba, and the TLS enforcement error

RDS does not let you edit pg_hba.conf directly; it is generated from your users and parameters. The line you feel most is the SSL one: when rds.force_ssl=1, a plaintext client is rejected with a pg_hba error that literally ends in SSL off — which reads like an auth bug but is a transport bug.

Error Real cause Fix
FATAL: no pg_hba.conf entry for host "1.2.3.4", user "app", database "orders", SSL off rds.force_ssl=1; client connected without TLS add sslmode=require (or better, verify-full)
FATAL: no pg_hba.conf entry … SSL on (rare) a genuine host/user/db mismatch check user exists, DB name, source
MySQL ERROR 3159 … --require_secure_transport=ON TLS required; client sent plaintext connect with --ssl-mode=REQUIRED
PAM authentication failed IAM-auth user missing rds_iam / bad token grant rds_iam; regenerate token

TLS: the sslmode ladder and the CA bundle

Two questions govern TLS: is it required (server side), and does the client verify the server (client side)? Getting the first without the second is encryption without authentication — still vulnerable to a man-in-the-middle.

Server enforcement Parameter Engine
Force TLS on rds.force_ssl = 1 RDS/Aurora PostgreSQL
Force TLS on require_secure_transport = ON RDS/Aurora MySQL
Postgres sslmode Encrypts Verifies cert Verifies hostname Use
disable no no no never in prod
allow / prefer maybe no no weak; the driver default
require yes no no encrypted but MITM-able
verify-ca yes yes no verifies the CA chain
verify-full yes yes yes production default

The client must trust the RDS certificate authority. Download the current global CA bundle and point the client at it; the old per-region rds-ca-2019 bundle expired and the modern bundle (rds-ca-rsa2048-g1 and friends) is packaged in global-bundle.pem:

# One bundle covers all regions and rotations:
curl -o global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
# Verify-full: encrypt + check the chain + check the hostname.
psql "host=orders.abc123.ap-south-1.rds.amazonaws.com dbname=orders user=app \
  sslmode=verify-full sslrootcert=global-bundle.pem"

MySQL uses --ssl-mode (with --ssl-ca=global-bundle.pem for the verify levels):

MySQL --ssl-mode Encrypts Verifies cert Verifies hostname Use
DISABLED no no no never in prod
PREFERRED if available no no weak; the client default
REQUIRED yes no no encrypted, MITM-able
VERIFY_CA yes yes no verifies the chain
VERIFY_IDENTITY yes yes yes production default

A frequent post-force_ssl incident is that clients using require/REQUIRED keep working (they already encrypt) while any client on disable/DISABLED drops instantly — so the enablement looks “half-broken” when it is actually doing exactly what it should.

State traps: storage-full, read-only, and pending-reboot

Two “config that looks fine but isn’t” failures round out the catalogue.

Storage-full → the read-only wall

When an instance runs out of storage it enters status storage-full and can stop accepting writes; PostgreSQL surfaces attempts to write as errors, and in the worst case the instance becomes unavailable until you add space.

Symptom Confirm Fix
Writes fail, reads may work; status storage-full describe-db-instances … DBInstanceStatus; FreeStorageSpace metric = 0 extend allocated storage (modify-db-instance --allocated-storage)
Recurs weekly growth rate vs headroom enable storage autoscaling (--max-allocated-storage)
cannot execute INSERT in a read-only transaction check endpoint + SHOW default_transaction_read_only you’re on a reader or a read-only session — use the writer

That last error is a two-headed trap: it fires both when the storage is full and — far more often — when you are simply connected to a read replica / reader endpoint or default_transaction_read_only=on. Same words, different gate. Check which endpoint before you touch storage.

aws rds modify-db-instance --db-instance-identifier orders \
  --allocated-storage 100 --max-allocated-storage 500 --apply-immediately  # + autoscale ceiling
aws cloudwatch get-metric-statistics --namespace AWS/RDS --metric-name FreeStorageSpace \
  --dimensions Name=DBInstanceIdentifier,Value=orders --start-time 2026-07-14T00:00:00Z \
  --end-time 2026-07-14T12:00:00Z --period 3600 --statistics Minimum

Parameter changes that sit pending-reboot

Not every parameter applies live. Static parameters (like max_connections, shared_buffers, rds.force_ssl) have ApplyMethod = pending-reboot and take effect only after a reboot; dynamic ones apply immediately. The trap: you “raised max_connections,” nothing changed, and you assume the change failed — it is simply waiting for a reboot.

Check Command Reads
Is the param static or dynamic? describe-db-parameters … ApplyType static vs dynamic
Did it apply or is it pending? describe-db-parameters … ApplyMethod pending-reboot vs immediate
Is a reboot pending on the instance? describe-db-instances … PendingModifiedValues shows queued changes
aws rds describe-db-parameters --db-parameter-group-name orders-pg16 \
  --query "Parameters[?ParameterName=='max_connections'].{v:ParameterValue,type:ApplyType,apply:ApplyMethod}"
# Apply a pending-reboot change:
aws rds reboot-db-instance --db-instance-identifier orders

Client-side timeout knobs

Not every “timeout” is the network. The client driver has its own set of timeouts, and a too-low connect_timeout can fail a connection that the network would have completed a second later — masking (or mimicking) a gate-1 problem. Know the knobs so you can tell “the driver gave up early” from “the packet was truly dropped.”

Knob Layer What it bounds Default (typical) When it bites
connect_timeout client time to complete the TCP + startup 0 (OS, ~75s) / driver-set too low → timeout expired on a slow path
statement_timeout server/session max runtime of one query 0 (off) long query killed mid-flight
idle_in_transaction_session_timeout server max idle-in-transaction before kill 0 (off) set it to reap leaks
tcp_keepalives_idle OS/session when keepalives start on an idle socket OS default half-open sessions linger without it
lock_timeout server/session max wait for a lock 0 (off) a blocked query waits forever
JDBC socketTimeout client max wait on a socket read 0 (infinite) a hung read blocks a pool thread forever
JDBC loginTimeout client max for the whole login 0 slow gate-1/gate-3 path fails fast

The diagnostic value: if raising connect_timeout makes the failure go away, your real problem is a slow gate-1 path (a marginal route, DNS latency), not a hard block — fix the path, don’t just widen the timeout. And always set socketTimeout/statement_timeout so a single stuck query can’t pin a pool connection forever and march you toward gate 2.

Architecture at a glance

The diagram renders the whole playbook as the actual connection path: a client and its pool open a socket that must cross the security group (badge 1) and the NACL / route / public-access layer (badge 2), resolve and reach the right RDS endpoint — writer, not a stale or reader one (badge 3) — then land on the engine, where it either finds a free slot or hits max_connections (badge 4) and must satisfy auth + TLS (badge 5); anything that looks connected-but-broken (storage-full, read-only, pending-reboot) is the state box (badge 6). Read it left→right and top-down through the numbers — that is the order the gates reject you, and the order you check them.

Connection path for troubleshooting AWS RDS and Aurora timeouts and too-many-connections errors: a client app and connection pool, then the database security group and the NACL/route/public-access layer, then endpoint DNS resolving to the RDS or Aurora writer versus reader endpoint, then the database engine showing max_connections capacity and the auth-plus-TLS login, ending in a state box for storage-full, read-only and pending-reboot versus a successful open session, with six numbered badges marking the security-group block, the NACL/route/public-access block, the wrong or stale endpoint, max_connections exhaustion, the auth/TLS refusal, and the state traps.

The single habit the diagram encodes: let the error string pick the gate. A timeout stops you at badges 1–3, too many connections at badge 4, an auth/SSL error at badge 5, a read-only/write error at badge 6 — and you never debug a gate the error already told you it sailed through.

Real-world scenario

Company: Zenmart, a mid-size retailer in ap-south-1 running a Django checkout on ECS Fargate against an Aurora PostgreSQL cluster (db.r6g.large writer + one reader), fronted by no pool, connecting straight to the cluster writer endpoint.

The incident. During a flash sale, checkout latency exploded and logs filled with FATAL: sorry, too many clients already. The on-call engineer’s first instinct — the wrong one — was gate 1: “the network must be flapping,” and they opened the security group and the NACL. Both were fine (of course they were; the error was a database error, which means the packet had already crossed the SG). Twenty minutes gone. Someone else guessed gate 3 and rotated the master password “to be safe,” which did nothing but risk a second outage. The real problem was gate 2 and it was arithmetic: Fargate had autoscaled from 6 tasks to 40 under the sale load, each task’s Django pool opened up to 20 connections, and 40 × 20 = 800 attempted sessions against a db.r6g.large whose usable cap was ~1,795 — except a batch of idle in transaction sessions from a slow analytics query had already parked 600 slots, so the effective ceiling arrived far sooner.

The systematic pass. A senior engineer ignored the network entirely — the error was too many clients, which is gate 2 by definition — and pulled the two gate-2 signals: CloudWatch DatabaseConnections was pinned flat at the cap, and SELECT state,count(*) FROM pg_stat_activity GROUP BY 1 showed 612 idle in transaction. That immediately split the problem in two: a leak (the idle-in-transaction sessions) and a fan-out (pool-per-task × 40 tasks). They terminated the leaked sessions with pg_terminate_backend, which bought immediate headroom, and set idle_in_transaction_session_timeout = '5min' so the leak could never park slots indefinitely again.

The fix and the lesson. The permanent fix was an RDS Proxy in front of the cluster: 800 client connections now multiplex onto ~200 real sessions, and — the bonus nobody had asked for — the next planned failover no longer produced a reconnect storm, because the proxy held the client connections and drained them onto the new writer. Total time once they stopped debugging the wrong gate: the leak was cleared in 6 minutes; the proxy shipped the next day. The postmortem action items were pure playbook discipline: read the error string and pick the gate before opening a console; put a pool in front of any database an autoscaling fleet talks to; and alarm on DatabaseConnections > 80% of max_connections so capacity is a warning, not an outage. The password they had rotated in a panic was noted as a near-miss — “ruling out” gate 3 by changing credentials during a gate-2 incident is how one outage becomes two.

Advantages and disadvantages

The “advantage” here is the method — categorise by error string, confirm with the gate’s own tooling, fix once — versus the ad-hoc alternative most teams default to.

Systematic gate method (this approach) Ad-hoc “try things”
Error string → gate → two confirming commands Guess a cause, change a thing, wait, repeat
Never debugs a gate the error already passed Opens the security group for an auth error
Non-destructive confirmation (nc, dig, CloudWatch) “Rules out” gate 3 by rotating a live password
Capacity fix is structural (RDS Proxy), not a bigger cap Raises max_connections until the instance OOMs
Minutes to localise; teachable across the team Hours; frequently fixes the wrong gate
Prevents regressions with Terraform + alarms Lives in one engineer’s memory

The disadvantages are real. nc and Reachability Analyzer prove config reachability but not that the engine is healthy — a paused instance or a storage-full DB can pass gate 1 and still refuse work. CloudWatch DatabaseConnections has a one-minute granularity, so a sub-minute storm can hit the cap between data points; pg_stat_activity is ground truth but needs a working connection (during a full max_connections outage you may need the reserved superuser slot to even look). RDS Proxy adds a hop, a cost, and the pinning gotcha, so it is not a free win for a low-connection workload. The mature setup uses all the signals — error string first, then nc/dig for reachability, CloudWatch + pg_stat_activity for capacity, and secret/token/sslmode checks for auth — and never leans on only one.

Hands-on lab

You will stand up a small Postgres RDS instance, then deliberately break each gate: remove the security-group ingress and watch gate 1 time out (confirm with nc and Reachability Analyzer), exhaust the connection pool to trigger gate 2’s too many clients, and enforce TLS to see gate 3 reject a plaintext client — fixing each in aws CLI and Terraform, then tearing it all down. Free-tier-friendly on db.t3.micro; meterable items are flagged. Region: ap-south-1.

1. Stand up the instance and a client

# Assume an existing VPC with a private app subnet and an app instance in sg-app.
DBSG=$(aws ec2 create-security-group --group-name lab-db --description "lab db" \
  --vpc-id vpc-0abc --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $DBSG \
  --protocol tcp --port 5432 --source-group sg-app       # allow the APP sg on 5432
aws rds create-db-subnet-group --db-subnet-group-name lab-subnets \
  --db-subnet-group-description lab --subnet-ids subnet-a subnet-b
aws rds create-db-instance --db-instance-identifier lab-pg \
  --engine postgres --engine-version 16.3 --db-instance-class db.t3.micro \
  --allocated-storage 20 --master-username app --manage-master-user-password \
  --db-subnet-group-name lab-subnets --vpc-security-group-ids $DBSG \
  --no-publicly-accessible --backup-retention-period 0
aws rds wait db-instance-available --db-instance-identifier lab-pg
EP=$(aws rds describe-db-instances --db-instance-identifier lab-pg \
  --query 'DBInstances[0].Endpoint.Address' --output text)

--manage-master-user-password lets RDS store the master password in Secrets Manager for you — no plaintext password in your shell history. From the app instance, confirm the baseline:

nc -vz $EP 5432        # expected: "Connection to <ep> 5432 port [tcp/postgresql] succeeded!"

Expected: succeeded. Gate 1 is open. Any break you now introduce is unambiguous.

2. Break gate 1 — remove the SG ingress

aws ec2 revoke-security-group-ingress --group-id $DBSG \
  --protocol tcp --port 5432 --source-group sg-app     # the packet now has nowhere to land
# From the app instance:
nc -vz $EP 5432        # expected NOW: hangs, then "Connection timed out"

Note what you don’t see: no error explaining why, just a timeout. This is the moment engineers reset passwords. You will not — a timeout is gate 1, and credentials are downstream of a packet that never arrived.

3. Localise gate 1 with Reachability Analyzer

ENI=$(aws rds describe-db-instances --db-instance-identifier lab-pg \
  --query 'DBInstances[0].Endpoint.Address' --output text)   # resolve to the DB ENI via dig if needed
PID=$(aws ec2 create-network-insights-path --source i-app --destination i-dbeni \
  --protocol tcp --destination-port 5432 \
  --query NetworkInsightsPath.NetworkInsightsPathId --output text)
AID=$(aws ec2 start-network-insights-analysis --network-insights-path-id $PID \
  --query NetworkInsightsAnalysis.NetworkInsightsAnalysisId --output text)
sleep 20
aws ec2 describe-network-insights-analyses --network-insights-analysis-ids $AID \
  --query 'NetworkInsightsAnalyses[0].{Reachable:NetworkPathFound,Why:Explanations[].ExplanationCode}'

Expected:

{ "Reachable": false, "Why": [ "ENI_SG_RULES_MISMATCH" ] }

The tool names the security group, not the password, in one call (~₹8 / US $0.10). Fix it and re-confirm:

aws ec2 authorize-security-group-ingress --group-id $DBSG \
  --protocol tcp --port 5432 --source-group sg-app
nc -vz $EP 5432        # succeeded again

4. Break gate 2 — exhaust the connection pool

From the app host, open more sessions than the tiny instance allows (a db.t3.micro gives ~112, minus reserved). This loop grabs and holds connections:

for i in $(seq 1 130); do
  PGPASSWORD=$(aws secretsmanager get-secret-value --secret-id rds!db-... \
    --query SecretString --output text | python3 -c 'import sys,json;print(json.load(sys.stdin)["password"])')
  psql "host=$EP dbname=postgres user=app sslmode=require" \
    -c 'SELECT pg_sleep(120)' >/dev/null 2>&1 &
done
psql "host=$EP dbname=postgres user=app sslmode=require" -c 'SELECT 1'
# expected once the cap is hit:  FATAL:  sorry, too many clients already

Confirm it is genuinely capacity (gate 2) and not anything upstream — the engine answered, so gates 1 and 3 are fine:

aws cloudwatch get-metric-statistics --namespace AWS/RDS --metric-name DatabaseConnections \
  --dimensions Name=DBInstanceIdentifier,Value=lab-pg --start-time $(date -u -v-10M +%FT%TZ) \
  --end-time $(date -u +%FT%TZ) --period 60 --statistics Maximum

DatabaseConnections sits pinned at the cap. Kill the loop (kill $(jobs -p)) to release the slots — the real fix in production is RDS Proxy so thousands of clients multiplex onto a few sessions rather than each holding one:

resource "aws_db_proxy" "lab" {
  name           = "lab-proxy"
  engine_family  = "POSTGRESQL"
  role_arn       = aws_iam_role.proxy.arn
  vpc_subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
  require_tls    = true
  auth { auth_scheme = "SECRETS"; secret_arn = aws_secretsmanager_secret.db.arn }
}

5. Break and fix gate 3 — enforce TLS

Create a parameter group with rds.force_ssl=1, attach it, reboot to apply the static parameter, then watch a plaintext client fail:

aws rds create-db-parameter-group --db-parameter-group-name lab-force-ssl \
  --db-parameter-group-family postgres16 --description "force ssl"
aws rds modify-db-parameter-group --db-parameter-group-name lab-force-ssl \
  --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=pending-reboot"
aws rds modify-db-instance --db-instance-identifier lab-pg \
  --db-parameter-group-name lab-force-ssl --apply-immediately
aws rds reboot-db-instance --db-instance-identifier lab-pg     # static param needs a reboot
aws rds wait db-instance-available --db-instance-identifier lab-pg
psql "host=$EP dbname=postgres user=app sslmode=disable" -c 'SELECT 1'
# expected: FATAL: no pg_hba.conf entry for host "10.0.1.10", user "app", database "postgres", SSL off
curl -so global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
psql "host=$EP dbname=postgres user=app sslmode=verify-full sslrootcert=global-bundle.pem" -c 'SELECT 1'
# expected:  ?column? = 1   (encrypted, cert + hostname verified)

6. Teardown (do this — RDS meters by the hour)

aws rds delete-db-instance --db-instance-identifier lab-pg --skip-final-snapshot \
  --delete-automated-backups
aws rds wait db-instance-deleted --db-instance-identifier lab-pg
aws rds delete-db-parameter-group --db-parameter-group-name lab-force-ssl
aws rds delete-db-subnet-group --db-subnet-group-name lab-subnets
aws ec2 delete-security-group --group-id $DBSG
aws ec2 delete-network-insights-analysis --network-insights-analysis-id $AID
aws ec2 delete-network-insights-path --network-insights-path-id $PID

⚠️ Costs: the db.t3.micro instance meters per hour (free-tier eligible for the first 750 hours/month on a new account); Reachability Analyzer is ~₹8 per analysis; Secrets Manager stores the managed master password at ~₹33/secret/month. Delete promptly.

Common mistakes & troubleshooting

This is the section you keep open during an incident. Read the error, find its row, note the gate, run the confirm, apply the fix — and skip every gate the error already passed.

The master symptom → category → confirm → fix table

# Symptom (exact error) Category Root cause Confirm Fix
1 psql: … Connection timed out Reachability DB SG doesn’t allow the app SG nc -vz ep 5432; describe-security-groups authorize ingress from the app SG on 5432/3306
2 Connection timed out from home, works in VPC Reachability PubliclyAccessible=false or private subnet describe-db-instances PubliclyAccessible; dig outside enable public access + IGW route, or use a bastion
3 Connection timed out, SG looks right Reachability NACL missing outbound ephemeral; custom NACL describe-network-acls; Flow Logs pair add outbound tcp 1024-65535; fix rule order
4 Connection timed out after a network change Reachability app SG egress dropped the DB port describe-security-groups on the app SG allow egress TCP 5432/3306 to the DB
5 could not translate host name / NXDOMAIN Reachability wrong/typo’d endpoint; VPC DNS off dig ep; enableDnsSupport copy the exact endpoint; enable VPC DNS
6 Connection refused (instant) Reachability wrong port, or instance rebooting/replaced describe-db-instances Endpoint.Port, status use the right port; wait for available
7 cannot execute INSERT … read-only transaction Endpoint/State writing to the reader endpoint or a replica describe-db-cluster-endpoints; SHOW transaction_read_only use the cluster writer endpoint
8 timeouts to a dead IP right after failover Reachability driver cached DNS past the endpoint flip dig ep vs the IP the driver uses set driver DNS TTL ~5s; reconnect on error
9 FATAL: sorry, too many clients already Capacity max_connections exhausted CloudWatch DatabaseConnections vs cap RDS Proxy; kill leaks; right-size class
10 remaining connection slots are reserved … Capacity within superuser_reserved_connections of cap DatabaseConnections near cap free slots now; add a pool
11 ERROR 1040 (HY000): Too many connections Capacity MySQL max_connections hit SHOW STATUS LIKE 'Threads_connected' RDS Proxy; tune pool; larger class
12 slow climb to the cap over hours Capacity idle in transaction leak pg_stat_activity state column fix app; idle_in_transaction_session_timeout
13 mass connection spike at every failover Capacity reconnect storm, no pooling DatabaseConnections spike at events RDS Proxy (holds through failover)
14 FATAL: password authentication failed Auth wrong / mid-rotation / cached secret test the secret value manually re-fetch secret per connect; fix rotation
15 IAM auth: “some connections fail” Auth token older than 15 minutes token age; rds_iam grant regenerate token per new connection
16 PAM authentication failed Auth IAM user lacks rds_iam role \du for the role GRANT rds_iam TO user; + rds-db:connect IAM
17 no pg_hba.conf entry … SSL off Auth/TLS rds.force_ssl=1, client sent plaintext describe-db-parameters rds.force_ssl connect with sslmode=require/verify-full
18 ERROR 3159 … require_secure_transport=ON Auth/TLS MySQL TLS enforced; plaintext client SHOW VARIABLES LIKE 'require_secure_transport' --ssl-mode=REQUIRED + CA bundle
19 SSL error: certificate verify failed TLS missing/old CA bundle; wrong sslmode check sslrootcert; bundle date use global-bundle.pem; verify-full
20 new max_connections “does nothing” State static param stuck pending-reboot describe-db-parameters ApplyMethod reboot the instance to apply
21 writes fail, status storage-full State disk full FreeStorageSpace=0; DBInstanceStatus extend storage; enable autoscaling
22 timeout expired fast, before TCP done Client connect_timeout too low for the path driver connect_timeout value raise connect_timeout; fix the real gate-1 cause

The reachability vs capacity vs auth decode table

If you see… It’s this gate Do this first Do NOT
A silent Connection timed out Reachability nc -vz ep port; then SG → NACL → route reset the password
Instant Connection refused Reachability (edge) check the port and instance status assume it’s the security group
Name won’t resolve Reachability (DNS) dig ep; copy the exact endpoint retype from memory
too many clients / Too many connections Capacity DatabaseConnections vs max_connections change any SG/NACL rule
Slow climb then the cap Capacity (leak) pg_stat_activity for idle in transaction just raise max_connections
password authentication failed Auth re-fetch the secret; test manually touch the network
IAM auth intermittent Auth (token) check token age vs 15 min cache the token in a pool
SSL off / require_secure_transport Auth/TLS add sslmode/--ssl-mode + CA bundle disable force_ssl in a panic
read-only transaction Endpoint/State check writer vs reader; storage assume the DB is down
Param change ignored State ApplyMethod; reboot if static re-apply repeatedly

Error-string reference (Postgres & MySQL)

Error string Engine Gate One-line meaning & fix
could not connect to server: Connection timed out PG Reachability packet dropped — SG/NACL/route; nc -vz, then fix the rule
connection to server … failed: timeout expired PG Reachability/Client connect_timeout fired — real cause is usually gate 1
Connection refused PG/MySQL Reachability reached a host, nothing on that port — wrong port/rebooting
could not translate host name … Name or service not known PG Reachability DNS — wrong endpoint or VPC DNS off
FATAL: sorry, too many clients already PG Capacity max_connections full — pool/RDS Proxy
FATAL: remaining connection slots are reserved … PG Capacity within reserved slots of the cap — free/pool now
ERROR 1040 (HY000): Too many connections MySQL Capacity max_connections full — pool/RDS Proxy
FATAL: password authentication failed for user "x" PG Auth bad/rotated password — re-fetch secret
ERROR 1045 (28000): Access denied for user 'x' MySQL Auth bad password/host grant — check user + secret
FATAL: no pg_hba.conf entry for host "…", … SSL off PG Auth/TLS force_ssl on, client plaintext — add sslmode
ERROR 3159 (HY000): … require_secure_transport=ON MySQL Auth/TLS TLS enforced — --ssl-mode=REQUIRED
SSL error: certificate verify failed PG TLS CA bundle missing/old — use global-bundle.pem
PAM authentication failed PG Auth IAM auth — grant rds_iam, fresh token
cannot execute INSERT in a read-only transaction PG Endpoint/State reader endpoint or storage-full — use writer

Reachability Analyzer explanation codes for an RDS path

Explanation code Means Fix
ENI_SG_RULES_MISMATCH DB SG has no matching ingress for the source/port authorize the app SG on the DB port
NO_ROUTE_TO_DESTINATION no route from the source subnet to the DB subnet add/repair the route; check the association
SUBNET_ACL_RESTRICTION a NACL rule blocks the path fix the NACL ALLOW + ephemeral egress
SECURITY_GROUP_EGRESS_MISMATCH app SG has no egress to the DB allow egress on 5432/3306
NO_PATH / UNASSOCIATED_COMPONENT ENI not attached / DB not yet ready wait for available; verify the target

The three nastiest, in prose

The security-group chain. The most common RDS outage and the most mis-debugged, because the DB security group’s source looks superficially reasonable while being exactly wrong. The reflex is to allow “the app,” so someone adds the app’s public-facing IP, or the NAT gateway’s EIP, or the office CIDR — and every one of those fails for the same reason: an app in the VPC connecting to an RDS endpoint in the same VPC keeps its private source IP over the local route and never presents the NAT EIP or any public address. So the SG that “allows the app” allows an address the app never uses, and the connection times out silently. The only robust rule is a security-group reference: DB SG allows sg-app, by identity, so it survives IP changes, scale-out, and instance replacement. Confirm the block deterministically with nc -vz endpoint 5432 (times out) and Reachability Analyzer (ENI_SG_RULES_MISMATCH) rather than eyeballing rules — and remember to check the app’s egress too, since a locked-down app SG can drop the DB port on the way out, producing the identical timeout one layer earlier.

max_connections exhaustion and RDS Proxy. This one is nasty because the instinctive fixes make it worse. When FATAL: sorry, too many clients already appears under load, the tempting moves are to scale the app (more instances, each with its own pool, multiplying total connections), raise max_connections (each Postgres backend costs several MB of RAM, so a big number OOMs a small instance), or restart the database (a mass reconnect storm refills the cap instantly). The correct diagnosis is arithmetic and a leak check: DatabaseConnections pinned at the formula-derived cap is capacity, and SELECT state,count(*) FROM pg_stat_activity reveals whether a chunk of those slots are idle in transaction (a leak) versus genuinely busy (true demand). The structural fix is a pool that multiplexesRDS Proxy turns thousands of client connections into a few hundred real sessions and, as a bonus, holds connections through a failover so you don’t storm the new writer. Set idle_in_transaction_session_timeout so a leak can never park slots forever, and alarm at 80% of the cap so capacity is a warning, not a 2 a.m. page.

IAM-auth token expiry. IAM database authentication is a security win — no stored passwords — with one failure mode that produces a genuinely confusing symptom: partial failure. The auth token from generate-db-auth-token is valid for exactly 15 minutes, must be freshly SigV4-signed for the specific host/port/user, and only works over TLS with the DB user granted rds_iam (Postgres) or the AWSAuthenticationPlugin (MySQL). The trap is caching: a worker generates a token at startup, opens a pool, and everything works — until the pool needs to open a new connection 20 minutes later using the token it cached at boot, which expired 5 minutes ago. Now some connections (the old ones) work and new ones fail, which looks like a flaky database rather than an expired credential. The cure is to generate the token immediately before each new connection and never treat it like a long-lived password; if you also see throttling under a connection spike, remember IAM-auth caps the rate of new connections, which is another reason to pool with RDS Proxy (which can do the IAM auth for you).

Best practices

Security notes

Troubleshooting connectivity and hardening it pull in opposite directions, and the seam is where mistakes happen. First, do not debug by widening: opening the DB security group to 0.0.0.0/0 or setting PubliclyAccessible=true “to rule out the network” turns a connectivity ticket into an internet-exposed database — use nc and Reachability Analyzer, which change nothing, and if you must widen, scope to a /32 and revert in the same session. Second, prefer IAM database authentication or Secrets Manager over static passwords: IAM auth eliminates stored credentials entirely (short-lived tokens, TLS-only), and managed rotation keeps a plaintext password out of your config — just handle the 15-minute token lifetime and the rotation window in the app. Third, make TLS verify-full, not merely require: require encrypts but does not authenticate the server, leaving you open to a man-in-the-middle; verify-full with the RDS CA bundle proves you are talking to the real endpoint. Fourth, least-privilege the diagnostic and proxy access: the RDS Proxy IAM role should read only its specific Secrets Manager secret, and the human role that runs describe-*, Reachability Analyzer, and reads Flow Logs should be a scoped read/analyse role, not admin. Fifth, treat a genuine AccessDenied/password authentication failed as a control working — confirm intent before “fixing” it, because the fix for a real auth failure is correct credentials, not a weaker policy. Finally, keep the database private (PubliclyAccessible=false, private subnets, SG-referenced access) as the default and reach it through a bastion, VPN, or SSM port-forward; public accessibility is a lab convenience, not a posture.

Cost & sizing

The diagnostic tooling is cheap relative to an hour of downtime, but the fixes have real, recurring costs worth sizing. Rough figures (USD list; INR ≈ ×83):

Item What drives it Rough cost Notes
RDS instance class × hours db.t3.micro ~$0.017/hr (₹1.4) free-tier: 750 hrs/mo new accounts
RDS Proxy vCPU of the target × hours ~$0.015 per vCPU-hour e.g. ~$0.03/hr for a 2-vCPU target
Secrets Manager per secret + API calls ~$0.40/secret/mo (₹33) + calls managed master password uses one
Reachability Analyzer per analysis ~$0.10 (₹8) run freely during an incident
VPC Flow Logs → CloudWatch GB ingested + stored ~$0.50/GB sample or send to S3 for high volume
Storage (gp3) GB-month + IOPS/throughput above baseline ~$0.115/GB-mo autoscaling grows it; it doesn’t shrink
Multi-AZ doubles instance + storage ~2× the single-AZ instance the price of automatic failover
Cross-AZ data (reader in another AZ) GB across AZs ~$0.01/GB each way reader endpoint traffic can add up

Sizing guidance: max_connections follows the instance class, so size the class to your usable connection need (RAM ÷ ~9.5 MB on Postgres, minus reserved) rather than raising the parameter — a bigger number on a small instance buys OOM, not throughput. Above a few hundred concurrent connections, an RDS Proxy is almost always cheaper than the larger instance you would otherwise need, because it lets a small database serve a large, spiky client fleet. Turn on storage autoscaling with a sane ceiling so you never pay for a storage-full outage, and remember gp3 storage only ever grows — plan the ceiling, since shrinking means a dump-and-reload.

Interview & exam questions

Q1. A client reports Connection timed out to RDS. What is your first move, and what do you explicitly NOT do? First move: confirm gate 1 with nc -vz endpoint 5432 from the app subnet and, if it times out, check the DB security group (does it allow the app SG?), then NACL/route. What you do not do is reset the password or check credentials — a timeout means the packet never reached the login, so auth is irrelevant. (SAA-C03/SOA-C02)

Q2. Why must the DB security group reference the app’s security group rather than the NAT gateway’s EIP? An app in the VPC connecting to an RDS endpoint in the same VPC routes over the local route and keeps its private source IP; it never presents the NAT EIP (that only applies to internet-bound traffic). So an SG allowing the NAT EIP allows an address the app never uses, and the connection times out. An SG reference matches the app by identity regardless of IP. (SAA-C03)

Q3. How is max_connections determined on RDS PostgreSQL, and why can’t you just set it to 5000 on a small instance? It defaults to LEAST(DBInstanceClassMemory/9531392, 5000) — a function of instance memory. Each Postgres connection is a backend process costing several MB, so raising the cap on a small instance exhausts RAM and triggers OOM/swap well before you reach the number. The scalable fix is RDS Proxy, not a bigger cap. (SOA-C02/DVA-C02)

Q4. FATAL: sorry, too many clients already under load. Walk the diagnosis and the fix. It is gate 2 (capacity), so ignore the network. Confirm with CloudWatch DatabaseConnections pinned at the cap and pg_stat_activity to check for idle in transaction leaks. Fix: terminate leaked sessions and set idle_in_transaction_session_timeout; structurally, front the DB with RDS Proxy so clients multiplex onto fewer real sessions. (DVA-C02)

Q5. Explain the IAM database authentication token lifetime and its classic failure. The token from generate-db-auth-token is valid 15 minutes, must be SigV4-signed per host/port/user, works only over TLS, and needs the rds_iam role (Postgres). The classic failure: a worker caches the token at startup, then opens a new pooled connection 20 minutes later with the expired token — old connections work, new ones fail, looking like a flaky DB. Fix: generate the token per new connection. (SCS-C02/DVA-C02)

Q6. A client gets no pg_hba.conf entry for host "…", user "app", database "orders", SSL off. What changed and how do you fix it? Someone set rds.force_ssl=1 (a static parameter, applied on reboot), so plaintext connections are rejected by the generated pg_hba. The client is sending no TLS. Fix: connect with sslmode=require (encryption) or verify-full (encryption + cert + hostname) using the RDS CA bundle. (SCS-C02)

Q7. What is the difference between sslmode=require and sslmode=verify-full? require encrypts the connection but does not verify the server certificate, so it is vulnerable to a man-in-the-middle. verify-full encrypts and verifies both the certificate chain (against the CA bundle) and that the hostname matches — the production default. (SCS-C02)

Q8. cannot execute INSERT in a read-only transaction — give two distinct causes. Most often you connected to a reader endpoint or a read replica’s instance endpoint (read-only by design); use the cluster writer endpoint. Less often, the instance is in storage-full state (or default_transaction_read_only=on), blocking writes until you add storage. Same error, different gate — check the endpoint first. (SAA-C03/SOA-C02)

Q9. After a Multi-AZ failover, some clients keep timing out against a dead IP. Why, and what is the fix? The RDS endpoint DNS flipped to the standby, but the client (often a JVM) cached the old IP past the endpoint’s short TTL and keeps dialling the former primary. Fix: lower the driver DNS TTL (e.g. networkaddress.cache.ttl=5) and reconnect on error so clients follow the flip within seconds. (ANS-C01/SOA-C02)

Q10. Why does RDS Proxy help with failovers, not just connection counts? Beyond multiplexing many client connections onto few database sessions, the proxy holds and queues client connections during a failover and drains them onto the new writer, preventing the reconnect storm that would otherwise slam the cap the instant the new writer comes up. (DVA-C02)

Q11. You raised max_connections in the parameter group but the cap is unchanged. Why? max_connections is a static parameter with ApplyMethod=pending-reboot; it takes effect only after you reboot the instance. Confirm with describe-db-parameters (ApplyType=static, ApplyMethod=pending-reboot) and reboot to apply. (SOA-C02)

Q12. A connection fails intermittently right after a Secrets Manager rotation. Diagnose. Rotation set a new password and advanced AWSCURRENT; clients holding the previous password fail in the brief switch window, or an app that cached the secret at boot keeps using the old value. Fix: fetch the secret at connect time (or re-fetch on an auth-failure retry) and rely on the managed rotation Lambda that keeps both passwords valid across the switch. (SCS-C02/DVA-C02)

Quick check

  1. A service logs Connection timed out to RDS. Should you check the password? Why or why not?
  2. Your DB security group allows the NAT gateway’s EIP, yet the in-VPC app times out. Why?
  3. FATAL: sorry, too many clients already — which gate, and what are the two confirming signals?
  4. Your app caches an IAM auth token at startup; 20 minutes in, new connections fail while old ones work. What happened?
  5. You set rds.force_ssl=1 but nothing changed for existing plaintext clients until later. What did you forget?

Answers

  1. No. A timeout is gate 1 (reachability) — the packet never reached the login prompt, so credentials are irrelevant. Confirm with nc -vz endpoint 5432 and check the DB security group and NACL/route, not the password.
  2. Because an in-VPC app reaching an RDS endpoint in the same VPC routes over the local route and keeps its private source IP — it never presents the NAT EIP (that only applies to internet-bound traffic). Reference the app’s security group instead.
  3. Gate 2 (capacity). Confirm with CloudWatch DatabaseConnections pinned at max_connections, and SELECT state,count(*) FROM pg_stat_activity to see whether the slots are genuinely busy or leaked idle in transaction.
  4. The IAM auth token expired — it is valid only 15 minutes. Old connections opened with a then-valid token keep working; new connections use the cached, now-expired token and fail. Generate the token per new connection.
  5. rds.force_ssl is a static parameter — it applies only after a reboot (ApplyMethod=pending-reboot). Until you reboot, the running instance keeps the old behaviour.

Glossary

Term Definition
Gate (1/2/3) The three ordered checkpoints a connection passes: reachability, capacity, auth/TLS — the error string tells you which one stopped you
Security-group reference An SG rule whose source is another SG (by identity) rather than a CIDR/IP; survives IP changes and is the correct DB-SG pattern
PubliclyAccessible The RDS flag that (with a public subnet + IGW route + SG) lets the endpoint resolve to a public IP from outside the VPC
Writer / reader endpoint Aurora cluster DNS names: the writer follows failover and accepts writes; the reader is load-balanced and read-only
max_connections The hard cap on concurrent sessions, defaulting to a formula of DBInstanceClassMemory — tied to the instance class
DBInstanceClassMemory Instance RAM minus RDS-reserved memory, in bytes, used in engine parameter formulas
RDS Proxy A managed connection pool that multiplexes many client connections onto few DB sessions and holds them through failover
Connection pinning When RDS Proxy ties a client to one backend connection (due to session state) and can no longer multiplex it
idle in transaction A Postgres session that opened a transaction and neither committed nor rolled back, holding a slot (and locks) — a common leak
IAM database authentication Auth using a short-lived (15-minute) SigV4 token instead of a password; TLS-only; needs rds_iam / AWSAuthenticationPlugin
rds.force_ssl / require_secure_transport The Postgres / MySQL parameters that reject non-TLS connections at the server
sslmode ladder Postgres TLS levels from disablerequire (encrypt) → verify-full (encrypt + verify cert + hostname)
CA bundle (global-bundle.pem) The Amazon RDS certificate authority chain a client uses to verify the server certificate
Storage-full An instance status where the disk is exhausted; writes fail until storage is extended
Pending-reboot A parameter change (static) that is queued and applies only after the instance reboots

Next steps

AWSRDSAuroraRDS Proxymax_connectionsSecurity GroupsIAM AuthTLSTroubleshooting
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