How to Fix Postgres Connection Pool Exhaustion on Fly.io

Fly.io’s free tier attracts thousands of indie developers and early-stage teams every month. But there’s a specific failure mode that keeps hitting newcomers hard: connection pool exhaustion on Postgres, often at the worst possible time — during a demo, a launch, or a production spike.
The error is blunt. too many connections or connection pool exhausted shows up in logs, your app stalls, and the Fly Managed Postgres instance simply stops accepting new clients. No graceful degradation. Just failure. This isn’t a niche edge case — it’s one of the most frequently reported issues in Fly.io’s community forums as of 2026, with the PgBouncer thread alone accumulating hundreds of replies across multiple Phoenix and Node.js stacks.
The fix exists. It’s well-documented in pieces, but scattered. This article pulls it together.
In brief: Fly.io’s free-tier Postgres instances cap out at 25 total connections by default, and most application frameworks open far more than that. The standard solution is PgBouncer in transaction-pooling mode, but deploying it correctly requires understanding a few non-obvious trade-offs.
- The free-tier Postgres VM (shared-cpu-1x, 256 MB RAM) sets
max_connections = 25by default based on available memory. - PgBouncer in transaction mode drops effective connection demand from N app threads to 5–10 server-side connections, resolving the exhaustion issue for most workloads.
- Certain PostgreSQL features — prepared statements, advisory locks,
SETcommands — break under transaction-mode pooling and require code-level workarounds.
Why the Free Tier Runs Out of Connections So Fast
Fly.io’s Managed Postgres runs on a standard PostgreSQL backend. Each max_connections value is calculated at startup based on available RAM. According to Fly.io’s own documentation, the shared-cpu-1x instance with 256 MB RAM defaults to max_connections = 25. That’s not a soft cap — PostgreSQL reserves roughly 400–600 bytes of shared memory per connection slot, so bumping this value on a 256 MB VM risks OOM kills.
The problem is that modern web frameworks don’t connect conservatively. A default Rails app with Puma spins up a connection pool of 5 per worker. Run 4 Puma workers across 2 app instances and you’ve already consumed 40 connections — 60% over the limit before a single real user hits the app. Node.js with pg or Prisma behaves similarly. Phoenix/Ecto defaults to a pool of 10 per node. Every fly deploy that spins up a rolling restart briefly doubles your connection count.
The root cause isn’t Fly.io being stingy. It’s a genuine constraint of small-memory Postgres combined with connection-per-thread application models. The industry-standard fix for this gap is a connection pooler — specifically PgBouncer.
What PgBouncer Actually Does (and Why Transaction Mode Matters)
PgBouncer sits between your app and Postgres. Instead of 40 app threads each holding an open Postgres connection, they all connect to PgBouncer, which maintains a much smaller server-side pool.
Three pooling modes exist, and choosing the wrong one is where most developers go wrong:
- Session mode: One server connection per client session. Effectively useless for connection reduction.
- Transaction mode: Server connection assigned only during a transaction, released immediately after
COMMIT/ROLLBACK. This is what you want. - Statement mode: One connection per SQL statement. Breaks multi-statement transactions entirely. Don’t use this.
Transaction mode can serve 40+ application clients through 5–8 server-side Postgres connections. That’s the math that solves free-tier exhaustion.
Fly.io ships PgBouncer bundled inside its Managed Postgres image, accessible on port 5432 while raw Postgres listens on 5433. As noted in the Fly community forum thread, many users don’t realize the default connection string already routes through PgBouncer — but often in session mode, which doesn’t help.
Fixing the Exhaustion: Step-by-Step Configuration
Step 1: Confirm Your Pooling Mode
Connect to your Postgres app machine directly:
fly ssh console -a <your-postgres-app-name>
Check /etc/pgbouncer/pgbouncer.ini for pool_mode. If it reads session, that’s your problem.
Step 2: Switch to Transaction Mode
Edit the config:
[pgbouncer]
pool_mode = transaction
max_client_conn = 100
default_pool_size = 10
server_pool_size = 5
Restart PgBouncer inside the VM:
supervisorctl restart pgbouncer
Step 3: Adjust Application Connection Strings
Point your app at port 5432 (PgBouncer), not 5433 (raw Postgres). Your DATABASE_URL secret in Fly should already reflect this if you used fly postgres attach.
Step 4: Handle Transaction-Mode Incompatibilities
This is where teams get surprised. Transaction-mode pooling breaks:
- Named prepared statements — Prisma and some ORMs use these by default. Disable with
?pgbouncer=truein Prisma’s connection URL, or setprepare: falsein node-postgres. SETcommands outside transactions — Some ORMs issueSET search_pathon connect. These don’t persist across pooled connections.- Advisory locks — Session-scoped locks don’t work because the session isn’t consistent.
Comparing Your Connection Pooling Options on Fly.io
| Approach | Connection Reduction | Setup Complexity | Free Tier Compatible | Breaks Prepared Statements |
|---|---|---|---|---|
| PgBouncer (session mode) | Low — none effectively | Low | No | No |
| PgBouncer (transaction mode) | High — 5–8x | Medium | Yes | Yes (needs config) |
| Application-level pooling only | Medium — 2–3x | Low | Marginal | No |
| External pooler (Supabase Pooler) | High | Medium-High | Depends on plan | Partial |
| Upgrade Fly Postgres VM size | N/A — more headroom | Very Low | No (paid) | No |
The upgrade path — bumping to a 1 GB VM — increases max_connections to roughly 100 and costs around $7–10/month on Fly. For serious production apps, this is often cleaner than wrestling with transaction-mode edge cases. But for free-tier workloads — side projects, demos, MVPs — transaction-mode PgBouncer is the right call.
Application-level pooling (setting pool: 2 in Ecto or Rails, for example) helps reduce pressure but doesn’t solve the problem when you have multiple app instances or serverless functions firing simultaneously.
What This Means for Teams Running on Fly’s Free Tier in 2026
For solo developers and indie hackers: Set default_pool_size = 5 in PgBouncer and cap your app’s internal pool at 2–3 per worker. That combination keeps you well under 25 connections even with rolling deploys. Test it before launch — not during.
For early-stage teams with 2–3 engineers: The free-tier Postgres is genuinely production-risky for anything with real traffic. Budget $7/month for the 1 GB VM upgrade. The engineering time spent debugging connection errors costs far more.
For Phoenix/Ecto users specifically: Ecto’s connection pool is configurable in config/runtime.exs. Setting pool_size: 2 per node and routing through PgBouncer in transaction mode handles most free-tier workloads cleanly. The Fly community thread shows this combination working reliably for apps under roughly 50 concurrent users.
Watch for one upcoming change: Fly.io has signaled interest in deeper PgBouncer integration with automatic mode selection based on VM memory tier. If that ships in 2026, manual INI editing may become unnecessary for new Postgres deployments.
The Bottom Line
The connection pool exhaustion problem isn’t mysterious — it’s a memory-constrained PostgreSQL default colliding with connection-hungry frameworks. The fix is transaction-mode PgBouncer with sensible pool sizes. Not glamorous. But it works.
Key Takeaways
- Free-tier Postgres caps at 25 connections due to 256 MB RAM constraints
- Transaction-mode PgBouncer reduces server-side connections by 5–8x
- Prepared statements and advisory locks require code-level fixes under transaction mode
- Upgrading to a 1 GB VM is the cleaner path for anything approaching real production traffic
Over the next several months, expect Fly.io to improve first-run developer experience around database pooling — the community volume on this issue is too high to ignore. Automatic pooler configuration based on VM size is a likely near-term improvement.
The immediate action: check your pgbouncer.ini today. If it says session, you’re one traffic spike away from this problem.
References
Photo by Microsoft Copilot on Unsplash


