pratik@linux:~$ cat ~/blog/vercel-like-caching-with-nginx.md
2026-04-07|[nginx, caching, performance, infrastructure, devops, backend]

How to Build Vercel-Like Caching With Nginx for Your Backend

Replicate Vercel's ISR and edge caching model using Nginx — stale-while-revalidate, on-demand purging via CMS webhooks, and optional Varnish for sub-millisecond responses. No CDN vendor lock-in required.

We run 500+ client projects at GeekyAnts. A lot of them are CMS-driven marketing sites, docs portals, product pages — the kind of stuff where 98% of visitors see the exact same HTML. And yet, every single request was hitting Node, which hit the database, which did the same query it did 400ms ago for the last visitor.

Vercel solves this with ISR and edge caching. But not everything runs on Vercel, and frankly, not everything should. So we started replicating the same caching model with Nginx sitting in front of our backends. No Redis. No CDN vendor lock-in. Just Nginx doing what it was built to do.

Here's the setup we landed on.


What We're Building

The architecture is simple. Nginx sits between the client and your backend, absorbing the vast majority of traffic before it ever reaches your application server.

  Vercel-Style Caching Architecture with Nginx
  ─────────────────────────────────────────────

  ┌──────────┐     ┌──────────────────────┐     ┌──────────────┐     ┌──────────┐
  │  Client  │────►│   Nginx Cache Layer  │────►│   Backend    │────►│ Database │
  │ (Browser)│◄────│   (Disk/RAM Cache)   │◄────│  (Node/Go/   │◄────│ (Postgres│
  └──────────┘     │                      │     │   Python)    │     │  /MySQL) │
                   └──────────┬───────────┘     └──────┬───────┘     └──────────┘
                              │                        │
                   95%+ requests                 5% cache misses
                   served from cache             hit the backend
                              │                        │
                              │                        ▲
                              │               ┌────────┴────────┐
                              │               │  CMS Webhook    │
                              │               │  POST /api/     │
                              │               │  revalidate     │
                              │               └────────┬────────┘
                              │                        │
                              ◄────── PURGE request ───┘
                              │
                     Next request rebuilds
                     the cache automatically

The whole point: Nginx absorbs 95%+ of traffic. Your database doesn't even know those requests happened. When content changes, a webhook blows away the cached version, and the next visitor triggers a rebuild. Everyone after that gets the fresh cached copy.

That's it. That's the Vercel model.


When This Makes Sense (and When It Doesn't)

I'll save you some time.

  When to Use Nginx Caching
  ─────────────────────────────────────────────────────────────────

  ✓ USE IT FOR                    ✗ DON'T USE IT FOR
  ─────────────────────────       ─────────────────────────────
  Public, read-heavy pages        Personalised pages
  (blogs, docs, catalogues)       (dashboards, user feeds)

  CMS-driven sites with           Real-time data
  editorial workflows             (live scores, stock tickers)

  Expensive SSR backends          Pages with millions of
  (DB joins, API calls > 50ms)    query parameter combos

  Teams wanting instant updates   Teams that can't debug
  without short TTLs              cache issues

Public, read-heavy pages. Blogs, docs, product catalogues, landing pages. If the HTML is the same for every visitor, cache it. Why are you rendering it twice?

CMS-driven sites with editorial workflows. You know exactly when content changes because your CMS tells you. A webhook fires, the cache gets purged, done.

Expensive backends. If your SSR involves database joins, third-party API calls, or anything that takes more than 50ms — stop doing that on every request. Cache the output.

Teams that want instant updates without short TTLs. The old approach was "set cache to 30 seconds and hope for the best." That's sloppy. Cache for 24 hours and purge when something actually changes. Way more predictable.

Now, don't use this for:

Personalised pages. Dashboards, account settings, feeds that are different per user. You can't cache those in a shared layer. You'll end up serving User A's data to User B, and that's a very bad afternoon.

Real-time data. Live scores, stock tickers, chat. A cache layer is just extra latency here.

Pages with a million query parameter combinations. If your cache key space is enormous, your hit rate drops to nothing and you're just burning disk.

Teams that can't debug cache issues. I mean this seriously. "Why is the old content still showing?" is one of the most annoying bugs to track down if you don't have X-Cache-Status headers and some basic logging in place. Get that right first.


Step 1: Base Nginx Cache Setup

In your nginx.conf, inside the http block:

nginx
proxy_cache_path /var/cache/nginx/app_cache
    levels=1:2
    keys_zone=app_cache:10m
    inactive=24h
    max_size=10g;

10 MB of shared memory for keys (handles ~80k entries), evicts anything untouched for 24 hours, caps total disk at 10 GB. Adjust to taste.

  Cache Path Configuration Breakdown
  ──────────────────────────────────

  /var/cache/nginx/app_cache
  ├── levels=1:2          Two-level directory hash
  │                       (avoids too many files in one dir)
  ├── keys_zone=10m       10 MB shared memory for cache keys
  │                       (~80,000 entries)
  ├── inactive=24h        Evict entries untouched for 24 hours
  └── max_size=10g        Cap total disk usage at 10 GB

Then in your server block:

nginx
server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_cache app_cache;
        proxy_cache_key "$scheme$host$request_uri";
        proxy_pass http://127.0.0.1:3000;

        proxy_cache_valid 200 1h;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

That's already doing something useful. Every 200 response gets cached for an hour. Hit curl -I yourdomain.com/blog/some-post and look for X-Cache-Status: HIT. If you see it, Nginx served that from disk. Your backend didn't wake up.


Step 2: Stale-While-Revalidate

This is the bit that makes it feel like Vercel. A user hits an expired page and still gets an instant response — Nginx serves the stale copy and fetches a fresh one from the backend in the background. The next user gets the updated version. Nobody waits.

  Stale-While-Revalidate Flow
  ───────────────────────────

  Time ──────────────────────────────────────────────────────►

  Cache State:  [  FRESH  ]  [STALE]  [    FRESH (updated)    ]
                             │     │
  User A hits ───────────────┘     │
  Gets stale response instantly    │
  (< 5ms)                         │
                                   │
  Background: Nginx fetches ───────┘
  fresh copy from backend
  (~200ms, invisible to User A)

  User B hits ─────────────────────────►
  Gets fresh cached response (< 5ms)


  WITHOUT stale-while-revalidate:
  ──────────────────────────────
  User A hits ──► waits 200ms ──► gets response
  User B hits ──► gets cached response
nginx
location / {
    proxy_cache app_cache;
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_key "$scheme$host$request_uri";

    proxy_cache_valid 200 1h;

    # Conditional requests — saves bandwidth on revalidation
    proxy_cache_revalidate on;

    # THE important line. "updating" = serve stale while fetching fresh.
    # The rest = serve stale if the backend is dead.
    proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;

    # Only one request goes to the backend during revalidation.
    # Everyone else gets the stale copy.
    proxy_cache_lock on;
    proxy_cache_lock_timeout 5s;

    add_header X-Cache-Status $upstream_cache_status always;
}

proxy_cache_lock is easy to overlook but matters a lot under load. Without it, if 200 users hit an expired page at the same time, all 200 requests go to the backend. With it, one goes through, 199 get the stale version. Your backend barely notices.

  Thundering Herd: With vs Without proxy_cache_lock
  ──────────────────────────────────────────────────

  WITHOUT cache lock:                WITH cache lock:

  200 users hit expired page         200 users hit expired page
          │                                   │
          ▼                                   ▼
  ┌─────────────────┐               ┌─────────────────┐
  │  200 requests   │               │   1 request to  │
  │  to backend     │               │   backend       │
  │  simultaneously │               │                 │
  └────────┬────────┘               │  199 users get  │
           │                        │  stale response │
           ▼                        │  instantly      │
  Backend overwhelmed               └────────┬────────┘
  Slow responses for ALL                     │
                                             ▼
                                    Backend handles 1 req
                                    Cache updated for all

The error timeout http_500... part is a bonus — if your backend goes down entirely, Nginx keeps serving the last known good response instead of throwing 502s at your users. We've had this save us during deploys more than once.


Step 3: Auth Bypass

You don't want to cache authenticated responses. If you do, you'll serve one user's personalised page to another user, and depending on what's on that page, that could be anything from embarrassing to lawsuit-worthy.

nginx
location / {
    proxy_cache app_cache;
    proxy_pass http://127.0.0.1:3000;

    set $no_cache 0;

    if ($http_authorization != "") {
        set $no_cache 1;
    }

    if ($cookie_session_id) {
        set $no_cache 1;
    }

    # Don't read from cache
    proxy_cache_bypass $no_cache;
    # Don't write to cache either
    proxy_no_cache $no_cache;

    add_header X-Cache-Status $upstream_cache_status always;
    add_header X-Cache-Bypass $no_cache always;
}

Two directives, and they do different things. proxy_cache_bypass says "don't serve from cache." proxy_no_cache says "don't store the response in cache." You want both. If you only set bypass, Nginx still writes the authenticated response into the cache, and the next anonymous user might get it.

  Cache Bypass vs No-Cache: Why You Need Both
  ────────────────────────────────────────────

  proxy_cache_bypass only:          Both directives:

  Auth request comes in             Auth request comes in
         │                                  │
         ▼                                  ▼
  Skip reading from cache ✓         Skip reading from cache ✓
         │                                  │
         ▼                                  ▼
  Backend returns response          Backend returns response
  with user-specific data           with user-specific data
         │                                  │
         ▼                                  ▼
  Response STORED in cache ✗        Response NOT stored ✓
         │
         ▼
  Next anonymous user gets
  cached auth response! ✗✗✗

Step 4: Per-Route Caching for CMS Pages

Not everything deserves the same TTL. Static assets with content hashes can be cached for a month. Blog posts for a day (purged on publish). API routes should never be cached.

  Per-Route Cache Strategy
  ────────────────────────

  Route Type         TTL        Stale Serving    Purge Method
  ─────────────      ─────      ─────────────    ────────────
  Static assets      30 days    Yes              Filename hash
  (js/css/images)                                (cache-bust)

  CMS pages          24 hours   Yes + lock       Webhook PURGE
  (/blog, /docs)

  Homepage           10 min     Yes              Webhook PURGE
  (/)

  API routes         NEVER      N/A              N/A
  (/api/*)
nginx
# Static assets — fingerprinted filenames, cache forever (well, 30 days)
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ico)$ {
    proxy_cache app_cache;
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_valid 200 30d;
    proxy_cache_use_stale updating;
    add_header X-Cache-Status $upstream_cache_status always;
}

# CMS pages — long TTL, webhook purges on content change
location ~* ^/(blog|docs|pages|products)/ {
    proxy_cache app_cache;
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_key "$scheme$host$request_uri";
    proxy_cache_valid 200 24h;
    proxy_cache_revalidate on;
    proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
    proxy_cache_lock on;
    add_header X-Cache-Status $upstream_cache_status always;
}

# API — pass through, never cache
location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_no_cache 1;
    proxy_cache_bypass 1;
}

# Homepage — shorter TTL since it often aggregates content
location = / {
    proxy_cache app_cache;
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_valid 200 10m;
    proxy_cache_use_stale updating;
    add_header X-Cache-Status $upstream_cache_status always;
}

The mental model: cache everything public, skip everything dynamic, purge when content changes.


Step 5: On-Demand Revalidation (The Webhook Part)

This is the piece that turns "dumb TTL caching" into "Vercel-style instant updates."

Your CMS publishes a blog post. It fires a webhook. Your backend receives it, sends a PURGE request to Nginx for the affected URLs, and optionally pre-warms them. The next real user gets a fresh, cached page.

  On-Demand Revalidation Flow
  ───────────────────────────

  ┌───────────┐    webhook     ┌──────────────┐
  │    CMS    │───────────────►│   Backend    │
  │ (publish) │  POST /api/    │  /api/       │
  └───────────┘  revalidate    │  revalidate  │
                               └──────┬───────┘
                                      │
                          ┌───────────┴───────────┐
                          │                       │
                    Step 1: PURGE           Step 2: Pre-warm
                          │                       │
                          ▼                       ▼
                   ┌─────────────┐         ┌─────────────┐
                   │    Nginx    │         │    Nginx    │
                   │ Delete from │         │ Fetch fresh │
                   │ cache       │         │ & cache it  │
                   └─────────────┘         └─────────────┘
                                                  │
                                                  ▼
                                           ┌─────────────┐
                                           │ Next visitor │
                                           │ gets fresh   │
                                           │ cached page  │
                                           └─────────────┘

Nginx Side

You need the ngx_cache_purge module. It's built into Nginx Plus. For open-source Nginx, you'll need to compile it in or use a distro that includes it.

nginx
map $request_method $purge_method {
    PURGE 1;
    default 0;
}

server {
    listen 80;
    server_name yourdomain.com;

    # Purge endpoint — locked to localhost
    location ~ /purge(/.*) {
        allow 127.0.0.1;
        deny all;
        proxy_cache_purge app_cache "$scheme$host$1";
    }

    location / {
        proxy_cache app_cache;
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_key "$scheme$host$request_uri";
        proxy_cache_valid 200 24h;
        proxy_cache_use_stale updating;
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Backend Webhook Handler (Node.js)

javascript
app.post('/api/revalidate', async (req, res) => {
  const { secret, paths } = req.body;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ error: 'Invalid secret' });
  }

  try {
    for (const path of paths) {
      // Tell Nginx to drop this cache entry
      await fetch(`http://127.0.0.1/purge${path}`, { method: 'PURGE' });
    }

    // Pre-warm: hit the pages so the cache is already populated
    // before a real user does
    for (const path of paths) {
      await fetch(`http://127.0.0.1${path}`);
    }

    res.json({ revalidated: true, paths });
  } catch (err) {
    console.error('Revalidation failed:', err);
    res.status(500).json({ error: 'Revalidation failed' });
  }
});

What Your CMS Webhook Sends

Point Contentful, Sanity, Strapi, WordPress — whatever you're using — at https://yourdomain.com/api/revalidate with a body like:

json
{
  "secret": "your-revalidation-secret",
  "paths": [
    "/blog/my-new-post",
    "/blog",
    "/"
  ]
}

Include the listing pages too (like /blog), not just the individual post URL. Otherwise the index page keeps showing old content until its TTL expires. People forget this one a lot.


Step 6: Full Config, All Together

nginx
proxy_cache_path /var/cache/nginx/app_cache
    levels=1:2
    keys_zone=app_cache:10m
    inactive=24h
    max_size=10g;

map $request_method $purge_method {
    PURGE 1;
    default 0;
}

server {
    listen 80;
    server_name yourdomain.com;

    # Purge endpoint (localhost only)
    location ~ /purge(/.*) {
        allow 127.0.0.1;
        deny all;
        proxy_cache_purge app_cache "$scheme$host$1";
    }

    # Auth bypass
    set $no_cache 0;
    if ($http_authorization != "") {
        set $no_cache 1;
    }
    if ($cookie_session_id) {
        set $no_cache 1;
    }

    # Static assets (30 days)
    location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ico)$ {
        proxy_cache app_cache;
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 200 30d;
        proxy_cache_use_stale updating;
        add_header X-Cache-Status $upstream_cache_status always;
    }

    # API routes (never cache)
    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_no_cache 1;
        proxy_cache_bypass 1;
    }

    # Everything else (24h, stale-while-revalidate, auth bypass)
    location / {
        proxy_cache app_cache;
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_key "$scheme$host$request_uri";
        proxy_cache_valid 200 24h;
        proxy_cache_revalidate on;
        proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;

        proxy_cache_bypass $no_cache;
        proxy_no_cache $no_cache;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Bonus: Varnish in Front of Nginx

Nginx caching works. But it's disk-based. Varnish keeps everything in RAM, and the difference is noticeable — sub-millisecond responses vs 5–10ms from Nginx disk reads.

  The Varnish + Nginx Stack
  ─────────────────────────

  ┌──────────┐     ┌───────────────────┐     ┌───────────────────┐     ┌──────────┐
  │  Client  │────►│     Varnish       │────►│      Nginx        │────►│ Backend  │
  │          │◄────│  RAM cache        │◄────│  SSL termination  │◄────│ (port    │
  └──────────┘     │  port 80          │     │  + disk fallback  │     │  3000)   │
                   │                   │     │  port 8080        │     └──────────┘
                   │  Response: < 1ms  │     │  Response: ~5ms   │
                   └───────────────────┘     └───────────────────┘

Nginx still does the things it's better at — SSL, gzip, static files. Varnish just handles the hot cache layer.

Varnish VCL

vcl
vcl 4.1;

backend default {
    .host = "127.0.0.1";
    .port = "8080";
}

sub vcl_recv {
    if (req.method == "PURGE") {
        if (req.http.X-Purge-Secret != "your-secret") {
            return (synth(403, "Forbidden"));
        }
        return (purge);
    }

    # Public pages: strip cookies, force caching
    if (req.url ~ "^/(blog|docs|pages|products)/") {
        unset req.http.Cookie;
        return (hash);
    }

    if (req.http.Authorization) {
        return (pass);
    }

    if (req.url ~ "^/api/") {
        return (pass);
    }
}

sub vcl_backend_response {
    if (bereq.url ~ "^/(blog|docs|pages|products)/") {
        set beresp.ttl = 24h;
        set beresp.grace = 1h;
        unset beresp.http.Set-Cookie;
    }

    if (bereq.url ~ "\.(js|css|png|jpg|jpeg|gif|svg|woff2|ico)$") {
        set beresp.ttl = 30d;
    }
}

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Varnish-Cache = "HIT";
    } else {
        set resp.http.X-Varnish-Cache = "MISS";
    }
}

Why Bother With Varnish When Nginx Already Caches?

Three reasons. First, speed — RAM vs disk is a real difference when you're serving thousands of requests per second. Second, VCL gives you way more control over caching logic than Nginx directives do. Edge-side includes, per-object grace periods, request coalescing — all built in. Third, Varnish's grace mode is smarter than proxy_cache_use_stale. It handles the thundering herd problem more gracefully and lets you set different grace windows per content type.

  Nginx vs Varnish: When to Use What
  ──────────────────────────────────

                        Nginx Cache          Varnish + Nginx
  ─────────────────     ─────────────        ───────────────
  Storage               Disk-based           RAM-based
  Response time         ~5-10ms              < 1ms
  Config language       Nginx directives     VCL (very flexible)
  SSL termination       Built-in             Needs Nginx/HAProxy
  Purge support         Module required      Built-in
  Grace/stale logic     Basic                Advanced
  Complexity            Low                  Medium
  Best for              < 500 rps            1000+ rps

Is it worth the added complexity? If you're doing less than a few hundred rps, probably not. Nginx alone is fine. If you're pushing thousands and every millisecond of TTFB matters, Varnish pays for itself.

Updated Webhook Handler for Both Layers

javascript
app.post('/api/revalidate', async (req, res) => {
  const { secret, paths } = req.body;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ error: 'Invalid secret' });
  }

  for (const path of paths) {
    await fetch(`http://127.0.0.1${path}`, {
      method: 'PURGE',
      headers: { 'X-Purge-Secret': process.env.VARNISH_PURGE_SECRET },
    });

    await fetch(`http://127.0.0.1:8080/purge${path}`, {
      method: 'PURGE',
    });
  }

  // Pre-warm through Varnish so both layers are populated
  for (const path of paths) {
    await fetch(`http://127.0.0.1${path}`);
  }

  res.json({ revalidated: true, paths });
});

What You End Up With

  Performance Results
  ───────────────────

  Scenario                              DB Hit?    Response Time
  ─────────────────────────────────     ────────   ─────────────
  Page cached and valid                 No         < 1ms (Varnish)
                                                   ~5ms (Nginx)

  Page expired, stale served            Once       < 1ms for user
  while refreshing in background        (bg)       ~200ms backend

  CMS publishes, webhook purges         Once on    ~200ms
  cache, next request rebuilds          next req   (that one req)

  Authenticated user                    Yes,       ~200ms
                                        every time

  Backend is completely down             No         < 1ms
                                                   (stale served)
  Response Time: Before vs After Nginx Caching
  ─────────────────────────────────────────────

  Response
  Time (ms)
  400 ┤ █
      │ █
  350 ┤ █
      │ █
  300 ┤ █                █
      │ █                █
  250 ┤ █                █
      │ █     █          █
  200 ┤ █     █          █          █
      │ █     █          █          █
  150 ┤ █     █          █          █
      │ █     █          █          █
  100 ┤ █     █          █          █
      │ █     █          █          █
   50 ┤ █     █          █          █
      │ █     █          █          █
   10 ┤ █  ░  █  ░       █  ░       █
    5 ┤ █  ░  █  ░       █  ░       █
    1 ┤ █  ░  █  ░  ░    █  ░  ░    █
      └─┴──┴──┴──┴──┴────┴──┴──┴───┴──
       Cached  Stale   Purge+    Auth
       page    SWR     rebuild   user

       █ Before (no cache)  ░ After (with cache)

Your database is quiet. Your pages are fast. When content changes, it's live within seconds. Four files: nginx.conf, a VCL file if you're using Varnish, a webhook endpoint in your backend, and a webhook config in your CMS.

No vendor lock-in. No per-request billing. Just HTTP caching doing what it was designed to do 20 years ago.

pratik@linux:~$ _

© 2026 Kumar Pratik. All rights reserved.