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
sudoprivileges. - 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:2spreads cached files over two levels of subdirectories.keys_zone=APPCACHE:50mis a shared memory zone for the keys. One megabyte holds about 8,000 keys, so 50 MB covers roughly 400,000 cached responses.inactive=60mremoves entries that nobody requested for 60 minutes, whether or not they have expired.max_size=2gcaps disk usage; Nginx evicts the least recently used entries when it is reached.use_temp_path=offwrites responses directly into the cache directory instead of copying them from a temporary directory.- The
cachelog 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.1with an emptyConnectionheader lets Nginx reuse connections to the backend (keepalive 16).proxy_paramsis a file shipped with Ubuntu's Nginx that forwards theHost,X-Real-IP,X-Forwarded-ForandX-Forwarded-Protoheaders.proxy_cache_keydefines 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_validapplies only when the backend does not send its ownCache-Control,ExpiresorX-Accel-Expiresheaders. When it does, Nginx follows the backend.- Only
GETandHEADrequests are cached;POST,PUTandDELETEalways go to the backend. proxy_cache_bypassandproxy_no_cacheskip the cache for any request with anAuthorizationheader or a cookie namedsession. Replacesessionwith the name of your application's login cookie, so that personalized responses are never stored or shared.X-Cache-Statusexposes the result so you can verify it.
NoteBy default Nginx does not cache responses that contain
Set-Cookie,Cache-Control: private,no-cacheorno-store, orVary: *. This protects you from storing per-user responses. Do not override it withproxy_ignore_headersunless you are sure the response is identical for every visitor.
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_staleserves an expired copy when the backend fails or returns a 5xx status, and, withupdating, while a fresh copy is being fetched.proxy_cache_background_updatefetches that fresh copy in the background, so no visitor waits for the backend when an entry expires.proxy_cache_locklets only one request populate a missing entry; others wait up toproxy_cache_lock_timeoutand 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.
