All articles
DevOps & CloudOctober 13, 2026 6 min read

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.

The Vercel Preview Deployment That DDoSed Our Staging Database

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:

  1. No session-level features. Prepared statements, SET LOCAL, advisory locks, and LISTEN/NOTIFY either break or behave surprisingly. We had to rip out one pg_advisory_lock call that was being used for a half-baked rate limiter.
  2. Prisma users specifically need ?pgbouncer=true on the connection string and should pre-generate the Prisma client with the correct binary target. We lost about half a day to a cryptic prepared statement "s0" already exists error 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 DatabaseConnections at 70% of max_connections, routing to the on-call Slack channel (not PagerDuty — staging doesn't wake people up).
  • A pgBouncer SHOW POOLS scrape into our Grafana, specifically watching cl_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.

#Vercel#Postgres#DevOps#Postmortem#Serverless

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project