The Vercel Preview Deployment That DDoSed Our Staging Database
A single PR spun up 40 preview deployments overnight, each opening a connection pool to our staging Postgres. By morning, staging was dead. Here's what went wrong and the three-layer fix we landed on.

A junior engineer opened a draft PR on a Friday afternoon, force-pushed thirty-eight times to fix a flaky Playwright test, and went home. By 7am Saturday, our staging Postgres was refusing connections and three dependent teams were blocked. Nothing was malicious, nothing was misconfigured in the obvious sense — Vercel just did exactly what we asked it to.
This is the breakdown of what happened, why the default setup almost guarantees it on any team past five engineers, and the layered fix we eventually settled on.
What actually happened
Our Next.js app on Vercel is wired up so every push to any branch produces a preview deployment. Each preview talks to a shared staging Postgres (RDS, db.t4g.medium, max_connections = 200). The app uses the standard pg pool with a default pool size of 10 per serverless function instance.
That Friday PR triggered 38 deployments. Each deployment is its own isolated set of serverless functions. Add in the four other PRs that got pushed that evening, plus the main staging branch, and we had roughly 43 live preview URLs. A warm-up cron and a couple of synthetic monitors made sure none of them stayed cold.
Do the math: 43 deployments × ~5 warm function instances × 10 connections per pool = 2,150 potential connections against a database that caps at 200. Postgres doesn't gracefully degrade — once max_connections is hit, new clients get FATAL: sorry, too many clients already and the app surfaces 500s.
The part that embarrassed us: we'd been running like this for eight months. We'd just never pushed enough concurrent previews to tip it over.
Why the dashboard didn't warn us
RDS Performance Insights showed connection count climbing slowly across the week, not spiking. CloudWatch alarms were set on CPU and storage, not DatabaseConnections. Vercel's dashboard happily listed all 43 previews as healthy because the functions themselves were returning fast — the ones that did get a connection. The failing requests were just logged as 500s and lost in the noise of a staging environment nobody watches on weekends.
The first instinct was wrong
Monday morning, the obvious fix was "just raise max_connections". We bumped it to 500 and called it a day. That held for about a week before memory pressure on the instance started causing query planner slowdowns — each Postgres connection costs roughly 7–10 MB of RAM in our workload, and we were now reserving ~4 GB just for idle connection overhead on a 4 GB instance.
The second instinct was "disable previews for draft PRs". Vercel supports this via the Ignored Build Step. We shipped it:
# vercel.json or Project Settings → Git → Ignored Build Step
if git log -1 --pretty=%B | grep -q "\[skip-preview\]"; then
echo "🛑 Skipping preview build"
exit 0
else
exit 1
fi
This helped maybe 20%. Engineers forgot the tag, draft PRs still got pushed often, and some teams genuinely needed preview URLs on drafts for design review. We needed a structural fix, not a social one.
The three-layer fix that actually worked
We landed on a combination of connection pooling at the edge of the database, per-preview database branching, and smarter pool sizing in the app. Any one of these alone would have helped. Together they turned the problem from "existential" into "not something we think about".
Layer 1: pgBouncer in transaction mode
We put pgBouncer in front of Postgres with transaction-level pooling. This is the single biggest lever. Instead of each function holding a connection, pgBouncer multiplexes thousands of client connections onto a small pool of real Postgres connections.
Our pgBouncer config, roughly:
[databases]
app = host=staging-db.internal port=5432 dbname=app
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 60
Two things to be aware of with transaction mode:
- No session-level features. Prepared statements,
SET LOCAL, advisory locks, andLISTEN/NOTIFYeither break or behave surprisingly. We had to rip out onepg_advisory_lockcall that was being used for a half-baked rate limiter. - Prisma users specifically need
?pgbouncer=trueon the connection string and should pre-generate the Prisma client with the correct binary target. We lost about half a day to a crypticprepared statement "s0" already existserror before finding this.
For us, pgBouncer runs as a small ECS Fargate service — 0.25 vCPU, 512 MB RAM — and handles the whole staging environment without breaking a sweat.
Layer 2: per-preview database branches
For the production path we'd already been evaluating Neon, mostly for its copy-on-write branching. The incident pushed us to actually ship it for staging first. Every Vercel preview now gets its own ephemeral Postgres branch, created in a GitHub Action on PR open and destroyed on PR close.
The wiring looks roughly like this:
# .github/workflows/preview-db.yml
on:
pull_request:
types: [opened, reopened, closed]
jobs:
branch:
runs-on: ubuntu-latest
steps:
- uses: neondatabase/create-branch-action@v5
if: github.event.action != 'closed'
id: branch
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}
- name: Push DB URL to Vercel
if: github.event.action != 'closed'
run: |
vercel env add DATABASE_URL preview \
--git-branch=${{ github.head_ref }} \
--token=${{ secrets.VERCEL_TOKEN }} \
<<< "${{ steps.branch.outputs.db_url }}"
Branch creation takes a couple of seconds in our experience, and because branches share storage via copy-on-write, we're not paying for 40 full copies of a 12 GB staging dataset. Each PR gets a realistic, isolated database it can migrate, seed, or trash without affecting anyone else.
The tradeoff: Neon's compute auto-suspends and takes a second or two to resume on first query. For a preview environment that's fine. For production we're more careful.
Layer 3: honest pool sizing in the app
Even with pgBouncer, the app's own connection pool matters. On serverless, a pool size of 10 per instance is nonsense — each instance usually handles one request at a time and gets recycled frequently. We dropped it to 1 for preview and staging, and to 3 for production (where we have longer-lived warm instances and some parallel queries per request).
import { Pool } from 'pg';
const isServerless = !!process.env.VERCEL;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: isServerless ? 1 : 10,
idleTimeoutMillis: isServerless ? 10_000 : 30_000,
connectionTimeoutMillis: 5_000,
});
If you're using Prisma, the equivalent knob is connection_limit in the connection string. Default is num_cpus * 2 + 1, which is almost always wrong for serverless.
What we monitor now
The monitoring gap was arguably worse than the config gap. We added:
- A CloudWatch alarm on
DatabaseConnectionsat 70% ofmax_connections, routing to the on-call Slack channel (not PagerDuty — staging doesn't wake people up). - A pgBouncer
SHOW POOLSscrape into our Grafana, specifically watchingcl_waiting(clients blocked waiting for a server connection). Any sustained non-zero value means we're under-provisioned. - A Vercel deployment count metric. If more than 50 preview deployments are live, something is probably wrong — a stuck webhook, a bot gone feral, or a human in a bad mood.
We also started tagging preview deployments by PR author in Sentry, which made it trivial to spot that a single bot account was responsible for 60% of the connection churn one week.
The uncomfortable lesson
Vercel's preview model is one of the genuinely great developer experiences in the industry. But "every push gets a full environment" assumes your downstream dependencies — databases, queues, third-party APIs with rate limits — can scale the same way. They can't, by default. The platform makes it easy to multiply your frontend by 40 and silently keeps the same single Postgres underneath.
If you're on Vercel plus a traditional Postgres and you have more than a handful of active engineers, you already have this problem. You just haven't tipped it over yet.
Where we'd start
If we were doing this again from scratch on a new project, the order would be: pick a database that supports cheap branching from day one, put a connection pooler in front of it before the first production deploy, and set a DatabaseConnections alarm the same day you provision the instance. The branching is the real win — it removes a whole class of "my migration broke staging" conversations and makes the connection problem mostly moot. Everything else is cleanup after the fact.
If you want a hand auditing a Vercel-plus-Postgres setup before it bites you, our DevOps and cloud team does this kind of review regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

AWS NAT Gateway Costs Ate Our Margins: How We Cut Them 80% Without Rewriting the VPC
NAT Gateway data processing charges quietly became the second-largest line item on our AWS bill. Here's the audit, the fix, and the tradeoffs we hit along the way.

The Vercel-to-AWS Egress Bill Nobody Warned Us About
A Next.js app on Vercel talking to an AWS Postgres and S3 backend looked clean on paper. Then the data transfer line item showed up. Here's what we learned about egress, regions, and quiet architectural debt.

GCP Cloud Run Min-Instances vs Startup CPU Boost: Which One Actually Fixes Cold Starts
We ran a Node and a Java service on Cloud Run through both knobs — min-instances and Startup CPU Boost — to see which one actually earns its keep. The answer depends on your runtime, your traffic shape, and how honest you are about idle cost.
