How to Configure Nginx as a Caching Proxy

How to Configure Nginx as a Caching Proxy

At some point, almost every backend hits the same wall: the database or application logic behind a popular endpoint just can’t keep churning out identical responses fast enough for the traffic hitting it. Before reaching for a dedicated caching layer like Varnish or a CDN, it’s worth knowing that Nginx already has a genuinely capable HTTP caching engine built in. I’ve used Nginx’s proxy_cache to take real production load off backends more times than I can count, and it’s often the single highest-leverage change you can make to a slow site with mostly-repeated traffic patterns.

This guide covers setting up Nginx as a caching reverse proxy in front of an existing backend — could be a Node app, a WordPress site, an API, anything speaking HTTP.

How Nginx Caching Works, Conceptually

When proxy_cache is enabled, Nginx sits between the client and your backend. On the first request for a given URL, Nginx forwards it to the backend, gets the response, stores a copy on disk (or in a configured cache zone), and serves that copy to the client. On subsequent matching requests, Nginx serves straight from its local cache — never touching the backend at all — until the cached entry expires or is explicitly purged.

This is different from browser caching (which happens on the client) and different from application-level caching (like Redis-based caching inside your app code) — it’s a caching layer that sits entirely at the proxy, transparent to both the client and the backend.

Requirements

  • Nginx installed and already configured as a reverse proxy to some backend (see the Ruby/Flask/Node articles in this series for backend-specific setup)
  • Enough disk space allocated for the cache (SSD strongly preferred over spinning disk for cache performance)
  • A backend that sends reasonable caching-related headers (Cache-Control, Expires) — though Nginx can override these if the backend doesn’t cooperate

Step 1: Define a Cache Zone

Cache configuration starts at the http block level, outside any server block:

http {
    proxy_cache_path /var/cache/nginx/proxy_cache
                      levels=1:2
                      keys_zone=main_cache:10m
                      max_size=1g
                      inactive=60m
                      use_temp_path=off;

    # ... rest of your http block
}

Breaking down each part of proxy_cache_path:

  • /var/cache/nginx/proxy_cache — the directory on disk where cached response bodies are actually stored.
  • levels=1:2 — organizes cached files into a two-level subdirectory hierarchy based on a hash of the cache key. This avoids having tens of thousands of files dumped into one flat directory, which gets slow on most filesystems.
  • keys_zone=main_cache:10m — allocates a named shared memory zone (main_cache) for storing cache keys and metadata (not the response bodies themselves — those live on disk). 10MB of metadata is enough to track roughly 80,000 cached entries, since each entry’s metadata takes about 128 bytes.
  • max_size=1g — caps the total disk space the cache is allowed to consume. Once this limit is hit, Nginx evicts the least recently used entries.
  • inactive=60m — an entry is removed if it hasn’t been requested (not just aged past its freshness) in 60 minutes, regardless of whether it’s technically still “fresh” by cache-control standards.
  • use_temp_path=off — writes cache files directly to the final cache directory instead of a separate temp path before moving them, which is both simpler and faster on modern filesystems.

Create the cache directory with correct ownership:

sudo mkdir -p /var/cache/nginx/proxy_cache
sudo chown -R www-data:www-data /var/cache/nginx

Step 2: Enable Caching in Your Server Block

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://backend_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        proxy_cache main_cache;
        proxy_cache_valid 200 302 10m;
        proxy_cache_valid 404 1m;

        proxy_cache_key "$scheme$request_method$host$request_uri";

        add_header X-Cache-Status $upstream_cache_status;
    }
}

Key directives explained:

  • proxy_cache main_cache; — activates caching using the zone defined earlier.
  • proxy_cache_valid — sets how long to cache responses by status code. Here, successful (200) and redirect (302) responses cache for 10 minutes; 404s cache briefly too (1 minute) — caching “not found” responses, even briefly, protects your backend from repeated requests hammering a broken or missing URL.
  • proxy_cache_key — defines exactly what makes two requests “the same” for caching purposes. The default already includes scheme, method, host, and URI — I’m making it explicit here since customizing this (for example, adding a cookie or header into the key) is one of the most common real-world tweaks.
  • add_header X-Cache-Status $upstream_cache_status; — this is invaluable for debugging. It adds a response header showing whether a given request was a cache HIT, MISS, EXPIRED, BYPASS, STALE, or UPDATING.

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Step 3: Verify Cache Behavior

curl -I https://example.com/some-page

Look at the X-Cache-Status header. The first request should show MISS (nothing cached yet). Run the same request again immediately:

curl -I https://example.com/some-page

This time you should see HIT — Nginx served it from cache without touching the backend at all.

Step 4: Handle Dynamic and Sensitive Content Correctly

Not everything should be cached, and getting this wrong is far more dangerous than getting it merely suboptimal — caching one user’s personalized or authenticated response and serving it to a different user is a real security and privacy bug I’ve seen happen in production.

Skip caching for anything involving cookies, authentication, or user-specific data:

location / {
    proxy_pass http://backend_upstream;

    proxy_cache main_cache;
    proxy_cache_valid 200 10m;

    # Don't cache if there's a session cookie present in the request
    proxy_cache_bypass $cookie_sessionid;
    proxy_no_cache $cookie_sessionid;

    # Never cache POST requests, admin areas, or the cart/checkout flow
    proxy_cache_methods GET HEAD;
}

location /admin/ {
    proxy_pass http://backend_upstream;
    # No caching directives here at all - simply omitted from this block
}

location /cart {
    proxy_pass http://backend_upstream;
    proxy_no_cache 1;
    proxy_cache_bypass 1;
}
  • proxy_cache_bypass — if this variable evaluates to non-empty/non-zero, Nginx skips reading from cache for this request (still may write to cache, though, depending on proxy_no_cache).
  • proxy_no_cache — if this evaluates to non-empty/non-zero, Nginx won’t store this response in the cache at all.
  • Setting both to the same condition (like the presence of a session cookie) is the standard pattern for “logged-in users always get a fresh, uncached response.”

Step 5: Set Up Cache Purging (Optional but Useful)

Sometimes content changes and you can’t wait for the cache to expire naturally — a CMS publish event, for example. Nginx’s open-source version doesn’t include purge-by-URL out of the box, but there’s a common workaround using a special location block:

location ~ /purge(/.*) {
    allow 127.0.0.1;
    deny all;
    proxy_cache_purge main_cache "$scheme$request_method$host$1";
}

Note: proxy_cache_purge is actually part of Nginx Plus (the commercial version) natively, or available via a third-party module (ngx_cache_purge) on open-source Nginx. If you’re on open-source Nginx without that module, the simplest reliable purge method is just deleting the specific cached file from disk directly, or clearing the whole cache directory:

sudo rm -rf /var/cache/nginx/proxy_cache/*
sudo systemctl reload nginx

This is blunt (clears everything, not one URL), but it’s dependable and requires no extra modules.

Testing Your Setup Thoroughly

Beyond the basic HIT/MISS check, test these scenarios explicitly:

# Confirm logged-in-like requests bypass cache
curl -I -H "Cookie: sessionid=abc123" https://example.com/dashboard

# Confirm cache expires correctly after proxy_cache_valid duration
curl -I https://example.com/some-page
sleep 605  # just past a 10-minute cache_valid
curl -I https://example.com/some-page  # should show MISS or EXPIRED again

Also load test to see the actual backend load reduction:

# Before enabling cache, note backend request rate
# After enabling cache, compare - most repeat traffic should never reach the backend

Troubleshooting Common Issues

Cache never shows HIT — Check the Cache-Control and Set-Cookie headers your backend is sending; Nginx respects Cache-Control: no-cache, private, or the presence of Set-Cookie by default unless you explicitly override with proxy_ignore_headers:

proxy_ignore_headers Cache-Control Set-Cookie;
proxy_cache_valid 200 10m;

Use this override carefully — only when you’re certain the backend’s headers are wrong or overly conservative for your actual use case, not as a default habit.

Stale content served after a known change — Your inactive or proxy_cache_valid window is longer than expected, or a purge mechanism isn’t wired up. Consider shorter cache windows for frequently changing content, or implement the purge location block above.

Different users seeing each other’s data — This is the serious one. It means something user-specific (a cart, a dashboard, personalized content) got cached and served to a different user. Immediately add proxy_no_cache/proxy_cache_bypass rules for the affected paths and clear the cache directory. Audit every location block that touches authenticated or personalized routes.

Disk fills up — Check max_size is actually set and reasonable relative to your disk. Nginx’s eviction happens on a schedule, not instantly, so a sudden traffic spike with lots of unique URLs can temporarily overshoot before cleanup catches up — leave real headroom, don’t set max_size right up against your available disk space.

Cache warms slowly after a restart — Expected behavior; Nginx’s cache is disk-based and does persist across reloads (nginx -s reload), but a full restart or moving to a new server starts cold. Consider a cache-warming script that pre-fetches your most popular URLs after deployment if this matters for your traffic pattern.

Security Considerations

  • Never cache authenticated or personalized responses without very deliberate, explicit key differentiation (and even then, think hard about whether it’s worth the risk).
  • Restrict purge endpoints to internal IPs only, as shown with allow 127.0.0.1; deny all; — an exposed purge endpoint is a denial-of-service vector (repeatedly purging forces every request back to the backend).
  • Set X-Cache-Status visibility carefully — it’s great for your own debugging, but consider stripping it in production if you don’t want to reveal caching implementation details to external users; a simple more_clear_headers X-Cache-Status; (via headers-more module) or just removing the add_header line before final deployment handles this.
  • Validate proxy_cache_key doesn’t accidentally ignore something important, like a query string parameter that actually changes the response — under-keying (too little distinguishing info) causes wrong-content bugs, while over-keying (too much) tanks your hit rate.

Performance Tips

  • Use proxy_cache_use_stale to serve slightly stale content instead of an error when the backend is slow or down — a major resilience win:
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
  • Enable proxy_cache_lock to prevent a “thundering herd” where many simultaneous requests for the same uncached URL all hit the backend at once; instead, one request populates the cache while others wait briefly:
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
  • Split cache zones by content type if you have wildly different caching needs across your site (e.g., a short-lived zone for API responses, a long-lived zone for static-ish content), rather than forcing one proxy_cache_valid policy to fit everything.
  • Monitor hit ratio, not just raw traffic — a low hit ratio despite caching being “enabled” usually points at an overly specific cache key or overly short proxy_cache_valid window.
  • Place the cache on fast storage. For high-traffic sites, an NVMe-backed cache directory meaningfully outperforms spinning disk, and even outperforms network-attached storage in most cases.

Real-World Use Cases

  • A WordPress blog getting hammered by a traffic spike from a viral post — enabling proxy_cache in front of PHP-FPM cut database load by well over 90% for that period, since the vast majority of requests were identical GETs for the same post.
  • A public JSON API with expensive database aggregation queries behind certain read-only endpoints — caching those specific responses for a few minutes dramatically reduced database CPU usage without meaningfully harming data freshness for the use case.
  • An e-commerce product catalog, where category and product pages were cached aggressively while cart, checkout, and account pages were explicitly excluded via dedicated location blocks — striking the balance between performance and correctness that caching always requires.

Best Practices Recap

  • Define cache zones with sane max_size and inactive limits based on real disk capacity.
  • Always add X-Cache-Status during setup and testing (and consider stripping it before final production deployment).
  • Explicitly exclude authenticated, personalized, and mutating (POST/PUT/DELETE) routes from caching.
  • Use proxy_cache_lock to avoid thundering-herd backend load on cache misses.
  • Use proxy_cache_use_stale for resilience during backend outages or slowness.
  • Build a purge strategy (module-based or manual) before you need it in an emergency, not during one.
  • Watch your hit ratio over time, not just whether caching is “on.”

Done right, Nginx caching is one of the best performance-to-effort ratios available in web infrastructure — a relatively small, well-understood config addition that can take a struggling backend and make it comfortably handle traffic it previously couldn’t.

Total
2
Shares

Leave a Reply

Previous Post
How to Set Up Nginx for a Node.js API

How to Set Up Nginx for a Node.js API

Next Post
How to Redirect Non-WWW to WWW URLs in Nginx

How to Redirect Non-WWW to WWW URLs in Nginx

Related Posts