When Nginx works as a reverse proxy, every request normally travels to the backend application, even when thousands of visitors ask for the same page or API response. The proxy_cache module stores those responses on disk and answers repeat requests itself, which cuts backend load and response times, and can keep serving content while the backend is down. In this tutorial you will put a cache in front of a backend on Ubuntu 24.04, protect it from requests that must not be cached, make it serve stale content during outages, refresh entries on demand and measure the hit ratio.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with sudo privileges.
  • Nginx installed with sudo apt install nginx.
  • A backend application listening on 127.0.0.1:3000. If you do not have one yet, Step 1 starts a small test backend.
  • A domain name pointing to the server, referred to as your_domain.

Step 1 - Starting a test backend

To see clearly what the cache does, it helps to have a backend whose requests you can watch. If your application already runs on 127.0.0.1:3000, skip to Step 2.

Create a directory with a test page:

sudo mkdir -p /var/www/backend
echo '<h1>Hello from the backend</h1>' | sudo tee /var/www/backend/index.html

Run Python's built-in HTTP server as a temporary systemd service, listening only on localhost and running as an unprivileged dynamic user:

sudo systemd-run --unit=demo-backend -p DynamicUser=yes /usr/bin/python3 -m http.server 3000 --bind 127.0.0.1 --directory /var/www/backend

Check that it responds:

curl -s http://127.0.0.1:3000/
<h1>Hello from the backend</h1>

Every request that reaches this backend is logged in the journal, which you will use to confirm cache hits.

Step 2 - Defining the cache zone

The proxy_cache_path directive must be in the http context. Ubuntu includes every file in /etc/nginx/conf.d/ inside that context, so create a dedicated file:

sudo nano /etc/nginx/conf.d/proxy-cache.conf
proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=APPCACHE:50m inactive=60m max_size=2g use_temp_path=off;

log_format cache '$upstream_cache_status $remote_addr [$time_local] "$request" $status $request_time';

The parameters:

  • levels=1:2 spreads cached files over two levels of subdirectories.
  • keys_zone=APPCACHE:50m is a shared memory zone for the keys. One megabyte holds about 8,000 keys, so 50 MB covers roughly 400,000 cached responses.
  • inactive=60m removes entries that nobody requested for 60 minutes, whether or not they have expired.
  • max_size=2g caps disk usage; Nginx evicts the least recently used entries when it is reached.
  • use_temp_path=off writes responses directly into the cache directory instead of copying them from a temporary directory.
  • The cache log format puts the cache result first on every line, which you will use in Step 6.

Create the directory and give it to the user Nginx workers run as:

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

Step 3 - Enabling the cache in the server block

Create a server block for the site:

sudo nano /etc/nginx/sites-available/your_domain
upstream app_backend {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    access_log /var/log/nginx/your_domain.cache.log cache;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        include proxy_params;

        proxy_cache APPCACHE;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        proxy_cache_valid 200 301 302 5m;
        proxy_cache_valid 404 1m;

        proxy_cache_bypass $http_authorization $cookie_session;
        proxy_no_cache $http_authorization $cookie_session;

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

How this works:

  • proxy_http_version 1.1 with an empty Connection header lets Nginx reuse connections to the backend (keepalive 16). proxy_params is a file shipped with Ubuntu's Nginx that forwards the Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers.
  • proxy_cache_key defines what makes two requests the same. This is also the default value, written out so it is clear what the purge commands in Step 5 rely on.
  • proxy_cache_valid applies only when the backend does not send its own Cache-Control, Expires or X-Accel-Expires headers. When it does, Nginx follows the backend.
  • Only GET and HEAD requests are cached; POST, PUT and DELETE always go to the backend.
  • proxy_cache_bypass and proxy_no_cache skip the cache for any request with an Authorization header or a cookie named session. Replace session with the name of your application's login cookie, so that personalized responses are never stored or shared.
  • X-Cache-Status exposes the result so you can verify it.

Enable the site, test and reload:

sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Request the page twice and look at the cache header:

curl -s -o /dev/null -D - http://your_domain/ | grep -i x-cache-status
curl -s -o /dev/null -D - http://your_domain/ | grep -i x-cache-status
X-Cache-Status: MISS
X-Cache-Status: HIT

If you use the test backend, its journal shows a single request even though you made two:

sudo journalctl -u demo-backend --no-pager | grep 'GET /'
Sep 25 10:21:43 server python3[4121]: 127.0.0.1 - - [25/Sep/2026 10:21:43] "GET / HTTP/1.1" 200 -

A request with an Authorization header skips the cache:

curl -s -o /dev/null -D - -H 'Authorization: Bearer test' http://your_domain/ | grep -i x-cache-status
X-Cache-Status: BYPASS

Step 4 - Serving stale content and preventing stampedes

Two more behaviors make the cache much more useful in production. First, if the backend crashes or times out, Nginx can keep serving the last good copy instead of returning errors. Second, when a popular entry expires, Nginx can send one request to the backend instead of letting every waiting client hit it at once.

Add these directives inside the location / block, below proxy_cache_valid 404 1m;:

        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
  • proxy_cache_use_stale serves an expired copy when the backend fails or returns a 5xx status, and, with updating, while a fresh copy is being fetched.
  • proxy_cache_background_update fetches that fresh copy in the background, so no visitor waits for the backend when an entry expires.
  • proxy_cache_lock lets only one request populate a missing entry; others wait up to proxy_cache_lock_timeout and are then sent to the backend.

Stale copies can only be served while they are still on disk, which is controlled by inactive=60m in Step 2. Raise it if you want a longer safety window.

Reload Nginx and test an outage with the demo backend. Wait at least five minutes after the last request so the cached copy expires, then stop the backend and request the page:

sudo nginx -t && sudo systemctl reload nginx
sudo systemctl stop demo-backend
curl -s -D - http://your_domain/ | grep -iE 'x-cache-status|^HTTP|Hello'
HTTP/1.1 200 OK
X-Cache-Status: STALE
<h1>Hello from the backend</h1>

Visitors still get the page, served from the stale copy, while the backend is down. Stopping the transient unit also removes it; run the systemd-run command from Step 1 again to bring it back.

Step 5 - Refreshing and purging cached content

The open source version of Nginx has no built-in purge command, but you can combine two techniques.

To force a refresh of a single URL, allow trusted clients to bypass the cache with a special header. A bypassed request goes to the backend and its response replaces the cached copy. Add a geo and a map block to /etc/nginx/conf.d/proxy-cache.conf:

sudo nano /etc/nginx/conf.d/proxy-cache.conf
geo $cache_refresh_allowed {
    default     0;
    127.0.0.1   1;
    ::1         1;
}

map "$cache_refresh_allowed:$http_x_cache_refresh" $cache_refresh {
    default  0;
    "1:1"    1;
}

Then add $cache_refresh to the existing proxy_cache_bypass line in the server block, but not to proxy_no_cache, so the fresh response is still stored:

        proxy_cache_bypass $http_authorization $cookie_session $cache_refresh;

Reload Nginx, then refresh a page from the server itself:

sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -D - -H 'X-Cache-Refresh: 1' -H 'Host: your_domain' http://127.0.0.1/ | grep -i x-cache-status
curl -s -o /dev/null -D - http://your_domain/ | grep -i x-cache-status
X-Cache-Status: BYPASS
X-Cache-Status: HIT

The same header sent from any other address is ignored, so visitors cannot use it to flood your backend. You can call this from a deployment script for the pages that changed.

To empty the whole cache, for example after a deployment that changes every page, delete the cached files:

sudo find /var/cache/nginx/proxy -type f -delete

Nginx treats missing files as misses and fetches fresh copies on the next requests.

Step 6 - Measuring the hit ratio

The cache log format writes the cache result as the first field of every line in /var/log/nginx/your_domain.cache.log. Count the results:

sudo awk '{print $1}' /var/log/nginx/your_domain.cache.log | sort | uniq -c | sort -rn
  18342 HIT
   2210 MISS
    604 BYPASS
    311 EXPIRED
     27 STALE

Calculate the hit ratio over all requests that went through the cache:

sudo awk '$1 != "-" {total++} $1 == "HIT" || $1 == "STALE" || $1 == "UPDATING" {hits++} END {if (total) printf "Hit ratio: %.1f%% (%d of %d)\n", 100*hits/total, hits, total}' /var/log/nginx/your_domain.cache.log
Hit ratio: 85.5% (18369 of 21494)

A low ratio with many BYPASS entries means your bypass conditions match too often; many MISS or EXPIRED entries mean the validity times are too short for your traffic. Check the disk usage with sudo du -sh /var/cache/nginx/proxy.

Troubleshooting

Every request shows MISS. Look at the backend's response headers with curl -sI http://127.0.0.1:3000/. A Set-Cookie header or Cache-Control: private, no-cache or no-store prevents caching. Also check sudo tail /var/log/nginx/error.log for permission errors on /var/cache/nginx/proxy.

The X-Cache-Status header is missing. add_header directives are not inherited into a block that defines its own add_header. Make sure the header is added in the same location that uses proxy_cache.

Logged-in users see other users' content. A personalized response was cached. Add the application's session cookie or header to both proxy_cache_bypass and proxy_no_cache, then empty the cache as shown in Step 5.

502 Bad Gateway with no stale copy. Stale content is only available for URLs that were cached before and are still on disk within the inactive period. Check the backend with curl -I http://127.0.0.1:3000/.

Conclusion

Nginx now caches your backend's responses, skips personalized requests, keeps the site available from stale copies during outages and lets you refresh content on demand. When you finish testing, stop the demo backend with sudo systemctl stop demo-backend. As next steps, enable HTTPS with sudo certbot --nginx -d your_domain, have your application send explicit Cache-Control headers so it controls each response's lifetime, and rotate the cache log with a logrotate rule.