Database Connection Timeout in Production But Not Locally
Your app works locally but times out in production. The database is fine. It is firewall rules, VPC routing, or connection pooling. Diagnosis and fixes here.
- Database timeouts in production but not locally are caused by firewall rules, network topology differences, connection pool exhaustion, or database overload, not code bugs.
- Use
nc -zv,telnet, andpsqlto diagnose: test network reachability first, then connection acceptance, then pool stats. - Connection pooling (PgBouncer, framework pools) is essential in production: it reuses connections and prevents exhaustion of the database's hard limit (Render 100-200, Heroku 20-180).
- Upgrade the database tier only if connections are genuinely maxed out; first optimize pooling configuration, check for connection leaks, and optimize slow queries.
- Managed platforms like Ship handle firewall, routing, and pooling automatically, trading lower raw cost for predictable pricing and built-in observability.
Your app runs fine locally. Deploy it to production. Immediate connection timeout. The database code is identical, so why only in production? It is almost always one of four things: your firewall is blocking the connection, your network topology differs between environments, you are creating too many connections at once, or the database itself is overwhelmed. This guide walks through each one with the exact tests and fixes.
Why Does This Happen: The Difference Between Local and Production
Local development is a protected bubble. Your laptop connects directly to the database on the same network or through a tunnel. There is no firewall filtering traffic, no security groups, no network address translation (NAT) changing your app server's IP, and no 50 concurrent users hammering the connection pool.
Production exposes all of these constraints. A connection timeout means the database did not accept your connection within the timeout window, usually 5-30 seconds depending on your driver. The connection request either never reached the database (network problem), was rejected (firewall or auth problem), or was queued so long that the client gave up waiting (pool exhaustion or database overload).
The key insight: local and production are not the same environment. Stop assuming they are. Start testing the assumptions between them.
Diagnose Which Cause You Have: Before You Fix Anything
Guessing costs you time. Test in order.
Step 1: Network reachability. Can the app server reach the database server's IP and port at all? Use these commands from your app server or a test pod:
nc -zv your-database-host.com 5432
telnet your-database-host.com 5432
psql -h your-database-host.com -U postgres --timeout=5 -c "SELECT 1"
If these hang or refuse, the network is blocked. Go to Fix #1 (firewall) or Fix #2 (routing).
If they connect, the network is open. Move to Step 2.
Step 2: Database is accepting connections. Connect directly to the database (not through your app). Use a simple client like psql, mysql, or mongo from your app server. If that works, the database is alive. If it hangs, the database is overloaded or the connection limit is hit. Go to Fix #4 (scaling).
Step 3: Connection pool stats. Enable debug logging on your connection pooler or database driver. Log the number of active, idle, and pending connections. In production, if you see pending connections that never close (waiting for a slot), you have pool exhaustion. Go to Fix #3 (pooling).
The output looks different depending on your framework:
- Django: Check
django.db.connections['default'].queries_logand the pool size inDATABASES['default']['CONN_MAX_AGE']. - Rails: Use
ActiveRecord::Base.connection_pool.statto see size, connections, and waiting threads. - Node.js (pg): Check the
maxparameter in the pool config and watchpool.idleCountandpool.waitingCount. - Go (pq): Use
db.Stats()to read open connections and waiting count.
Step 4: Is it intermittent or constant? If timeouts spike during traffic spikes but recover, it is pool exhaustion or a temporary network blip. If it is constant, it is a configuration issue (firewall, routing, pooling defaults) that exists whether traffic is high or low.
Fix #1: Firewall and Security Group Rules
This is the single most common culprit. Your firewall is open locally because there is no firewall. In production, there is one.
On AWS: Check the security group attached to your database. The database's inbound rules must allow traffic from the app server's security group (or IP) on the database port. PostgreSQL is 5432, MySQL is 3306, MongoDB is 27017. Common mistake: you add a rule allowing your laptop's IP, then deploy to AWS, and the app server's IP is not in the list.
Click the database in AWS console, go to Security Groups, then Inbound Rules. You should see:
Type: PostgreSQL (or Custom TCP)
Port: 5432
Source: sg-xxxxx (the app server's security group, or 0.0.0.0/0 if it is public)
If it is missing, add it. Do not add 0.0.0.0/0 unless the database is truly public (bad idea).
On Azure: NSGs (Network Security Groups) work the same way. The database's NSG must have an inbound rule for the app server's subnet or IP on the database port.
On a bare VPS: Check the host firewall. On Linux:
sudo iptables -L
sudo ufw status
If you see a rule blocking port 5432 inbound, remove it or allow it for the app server's IP.
Test it works: Run nc -zv your-db-host.com 5432 from the app server. If it connects, the firewall is open.
Fix #2: VPC Routing and Network Topology
Some databases do not live on the public internet. Heroku Postgres, Render, Railway, and managed cloud databases often run on private subnets inside a VPC. Your app cannot reach them via the public internet; it must use an internal URL.
The problem: You grab the public URL from the console, put it in your connection string, and it works locally (because your laptop is in the same VPC or has tunnel access) but fails in production (because your app server cannot see the public internet or is on a different subnet).
The solution: Use the internal URL, not the public one. Render calls it "Internal Database URL". Heroku Postgres provides a connection URL in the environment variable DATABASE_URL that is already internal. Railway does the same.
If your hosting provider offers both, use the internal one. If you do not know which one you have, check:
echo $DATABASE_URL
If it contains a private IP (10.x.x.x, 172.x.x.x, 192.x.x.x) or a DNS name like prod-db-internal.railway.internal, you are using internal routing. If it is a public IP or public hostname, you are exposed to the internet, which is slower and less secure.
NAT Gateway issues: If your app is on a private subnet and must reach a public database, the traffic goes through a NAT Gateway. The database sees the NAT Gateway's IP, not your app server's IP. If you have an IP allow-list on the database, add the NAT Gateway's IP, not the app server's IP.
Check your NAT Gateway's public IP in AWS console under VPC > NAT Gateways, copy the elastic IP, and add that to the database's allow-list (if one exists).
Fix #3: Connection Pooling: The Production Essential
Every HTTP request to your app might open a new database connection. Your app gets 10 concurrent requests. That is 10 database connections. Scale to 100 concurrent users, and you are trying to open 100 connections per request burst.
Databases have hard limits:
- Render 0-8GB tier: 100 connections max
- Render 8-16GB tier: 200 connections max
- Heroku Postgres Standard-0: 20 connections
- Heroku Postgres Standard-2: 180 connections
- AWS RDS db.t3.micro: 60-70 connections
Your app cannot exceed these. If you try, the database rejects new connections, and your app times out waiting for a slot.
Local development does not trigger this because you have one user (you), not a hundred.
The fix: Use a connection pooler. PgBouncer is the gold standard for PostgreSQL. It sits between your app and the database, reuses connections, and limits the total number of connections to the database.
PgBouncer configuration is simple. Add a pgbouncer.ini file:
[databases]
my_db = host=actual-database.com port=5432 user=postgres
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
timeout = 600
Then point your app's connection string to PgBouncer (localhost:6432) instead of the database. PgBouncer opens 25 connections to the database and reuses them for all clients. Your app can open 1000 connections to PgBouncer without hitting the database limit.
Pooling mode matters:
- Transaction mode: Connections are returned to the pool after each transaction. Safe for most apps, slight latency.
- Session mode: Connections stay open for the entire session. Faster, but requires careful cleanup (no prepared statements held between requests).
Use transaction mode unless you know better.
Framework-specific pooling: Most modern frameworks have built-in pooling:
- Django: Configure
CONN_MAX_AGEin settings.py. Start with 600 seconds (persistent connections) and tune based on database limits. - Rails: ActiveRecord pools by default (5 connections). Increase
pool: 25indatabase.ymlfor production. - Node.js: The
pglibrary defaults to 10 connections. Setmax: 20in the pool config. - Go (sqlc): Use
database/sqlwithdb.SetMaxOpenConns(25)anddb.SetMaxIdleConns(5).
Fix #4: When to Scale the Database vs. Optimize Connection Handling
If you have connection pooling configured correctly but still timeout:
-
Count active connections. Log the actual number of connections in use during peak traffic. If you are at the database's hard limit (100 for Render 8GB, 20 for Heroku Standard-0), you need to upgrade.
-
Look for connection leaks. Connections opened but never closed. This is usually a bug in your app: a database call inside a loop that does not close the connection. Use monitoring tools (New Relic, DataDog) to profile connection lifecycle.
-
Check query performance. Slow queries hold connections longer. If a query takes 30 seconds, that connection is unavailable for 30 seconds. Optimize the slow query or add a read replica.
-
Upgrade the database. If everything is optimized and you genuinely need more connections, upgrade the instance size. Render 8GB tier to 16GB adds 100 more connections. It costs more, but it solves the problem if connections are the bottleneck.
Be honest about which one it is. Do not throw money at a pooling problem.
Managed Hosting: The Off-Ramp
All of this--firewall rules, VPC routing, connection pooling, networking defaults--is handled automatically by managed platforms like Ship, Heroku, Render, and Railway. You deploy your app, they manage the network, and your database URL is pre-configured as an environment variable.
You do not configure security groups. You do not choose between internal and external URLs. You do not run PgBouncer. It is already there.
The trade-off: managed platforms cost more than a bare Hetzner VPS plus Coolify. Hetzner plus DIY wins on raw price. Ship and managed platforms win on predictability. You get one flat monthly bill, no surprise overage charges, and support if something breaks.
If you are building on Ship, you also get observability built in: connection pool stats, database metrics, and logs in one place. When timeouts happen, you can see exactly why. Deploy a test app on Ship and see it yourself.
Frequently Asked Questions
Why does psql work locally but my app times out?
Your app uses a connection string with a timeout (usually 5-30 seconds). The psql command is interactive and does not have that timeout, so it waits longer. Also, psql is a single connection; your app might be opening many. Test with: psql -h your-db -U postgres --timeout=5 -c "SELECT 1" to mimic your app's timeout behavior.
What is the difference between a connection timeout and a read timeout?
Connection timeout: the app cannot establish a TCP connection to the database within the timeout window (network, firewall, routing issue). Read timeout: the connection is established, but the database does not return data within the timeout window (slow query, database overload). Fix #1-3 solve connection timeouts. Fix #4 and query optimization solve read timeouts.
How do I know if I have a connection pool configured?
Check your framework's documentation and search for the pool configuration keyword. Django: CONN_MAX_AGE. Rails: pool: N in database.yml. Node.js: max: N in the pg pool config. If you see these, you have pooling. If not, your connections are likely unbounded, which will timeout under load.
What is PgBouncer and do I need it?
PgBouncer is a lightweight connection pooler for PostgreSQL. You do not need it if your framework's built-in pooling is sufficient (usually it is for apps under 100 concurrent users). Use PgBouncer if: your database's connection limit is very low (e.g., 20 for Heroku Standard-0), you have many app servers (each maintaining its own pool), or your framework does not have pooling. Managed platforms like Ship usually include pooling, so you do not need to run PgBouncer yourself.
Can I have unlimited database connections?
No. Every database has a hard limit based on available memory. PostgreSQL reserves about 6MB per connection. A 4GB database can safely handle around 500 connections; beyond that, memory pressure causes crashes. The solution is pooling (reuse fewer connections) or upgrading the instance size (more memory, more connections). Render's pricing scales with connection limits: bigger tiers, more connections.
The Bottom Line
Database timeouts in production that do not happen locally are almost always network, firewall, pooling, or scaling issues, not bugs in your code. Test network reachability first, then check pooling, then look at database limits. If you are running this yourself, expect to spend an hour or two debugging. If you deploy on a managed platform, the debugging is done for you. Managed platforms handle the network, routing, firewall, and pooling defaults. Choose based on your time value and how much you want to own the infrastructure yourself.