Server-side caching stores work the server has already done, a rendered page or the result of a slow query, so the next request can skip it. Nginx, Varnish, Redis and Memcached are the four tools you will meet most often on Linux, but they solve different problems: the first two cache whole HTTP responses in front of your application, the last two are key-value stores your code uses to cache objects. This guide compares them on Ubuntu 24.04 with a minimal working setup for each, shows how to check that the cache is actually hit, and ends with a recommendation for common scenarios.
Prerequisites
To try the examples you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx installed (
sudo apt install nginx). - A web application listening on
127.0.0.1:3000to act as the backend. Any framework works; the examples only proxy HTTP to it.
The package versions in Ubuntu 24.04 are Nginx 1.24, Varnish 7.1, Redis 7.0 and Memcached 1.6.
Comparison at a glance
| Nginx proxy/FastCGI cache | Varnish | Redis | Memcached | |
|---|---|---|---|---|
| What it caches | Full HTTP responses | Full HTTP responses | Any value your code stores | Any value your code stores |
| Where it sits | Inside the web server you already run | Separate reverse proxy in front of the web server | Next to the application, called from code | Next to the application, called from code |
| Storage | Disk, keys in shared memory | Memory (malloc) or file | Memory, optional persistence | Memory only |
| Invalidation | TTL, delete files; purge needs a third-party module | TTL, bans and purges built in | Per key, TTL, SCAN by pattern | Per key, TTL |
| Configuration language | Nginx directives | VCL (full programming logic) | Commands from your application | Commands from your application |
| Data structures | n/a | n/a | Strings, hashes, lists, sets, sorted sets, streams | Strings only |
| Replication and clustering | No | No (each node has its own cache) | Replication, Sentinel, Cluster | Client-side sharding only |
| Typical use | Anonymous page views on a single server | High-traffic sites with complex caching and purge rules | Query results, sessions, rate limits, queues | Simple object and session cache |
The key distinction: HTTP caches (Nginx, Varnish) require no application changes but only work for responses that are the same for many users. Object caches (Redis, Memcached) need code changes but can cache parts of personalized pages too.
Nginx proxy cache
If Nginx already serves your site, its built-in cache is the simplest way to cache full pages. For PHP-FPM backends use fastcgi_cache; for any HTTP backend (Node.js, Python, Go, Ruby) use proxy_cache, shown here.
Define the cache zone in the http context:
sudo nano /etc/nginx/conf.d/proxy-cache.conf
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=APP:20m max_size=1g inactive=60m use_temp_path=off;
Then use it in your site's server block:
sudo nano /etc/nginx/sites-available/your_domain
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_cache APP;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 301 5m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Do not cache or serve cached pages for logged-in users
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
add_header X-Cache-Status $upstream_cache_status always;
}
Replace session in $cookie_session with the name of your application's login cookie. Nginx never caches responses that carry Set-Cookie or Cache-Control: private, no-cache or no-store, so the application stays in control.
Create the directory, reload and test:
sudo mkdir -p /var/cache/nginx/app
sudo nginx -t && sudo systemctl reload nginx
curl -sI http://your_domain/ | grep -i x-cache-status
curl -sI http://your_domain/ | grep -i x-cache-status
x-cache-status: MISS
x-cache-status: HIT
Strengths: no new service, stale-while-revalidate behaviour with use_stale and background_update, cache survives restarts because it lives on disk.
Limits: the open source version cannot purge a single URL without the third-party ngx_cache_purge module, and caching logic beyond cookies and headers gets awkward quickly.
Varnish
Varnish is a dedicated HTTP cache. Its configuration language, VCL, lets you normalise URLs, strip tracking parameters, decide per request what to cache and purge content by pattern. It does not terminate TLS, so in production it usually sits between an HTTPS terminator (Nginx, HAProxy or a CDN) and the application.
Install it:
sudo apt install varnish
The Ubuntu package listens on port 6081 by default. Replace the default VCL with one that points to the application and adds a hit/miss header:
sudo nano /etc/varnish/default.vcl
vcl 4.1;
backend default {
.host = "127.0.0.1";
.port = "3000";
}
sub vcl_recv {
# Only GET and HEAD are cacheable
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Never cache authenticated requests
if (req.http.Authorization || req.http.Cookie ~ "session=") {
return (pass);
}
# Drop marketing parameters so they do not create separate cache entries
if (req.url ~ "[?&](utm_[a-z]+|gclid|fbclid)=") {
set req.url = regsuball(req.url, "(utm_[a-z]+|gclid|fbclid)=[^&]*&?", "");
set req.url = regsub(req.url, "[?&]+$", "");
}
# Anonymous requests: remove remaining cookies so the page can be cached
unset req.http.Cookie;
}
sub vcl_backend_response {
set beresp.ttl = 5m;
# Keep serving stale content for up to 1 hour if the backend is down
set beresp.grace = 1h;
if (beresp.status >= 500) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
}
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
}
Restart Varnish and request the same URL twice:
sudo systemctl restart varnish
curl -sI http://127.0.0.1:6081/ | grep -iE '^(x-cache|age):'
curl -sI http://127.0.0.1:6081/ | grep -iE '^(x-cache|age):'
x-cache: MISS
age: 0
x-cache: HIT
age: 3
Invalidate content with a ban. This example removes every cached URL that starts with /blog/:
sudo varnishadm 'ban req.url ~ ^/blog/'
Watch the hit and miss counters:
varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss
MAIN.cache_hit 1842 0.51 Cache hits
MAIN.cache_miss 211 0.06 Cache misses
To serve real traffic, point your TLS-terminating Nginx proxy_pass at http://127.0.0.1:6081 instead of the application.
Strengths: fastest full-page cache, rich request logic, instant purges and bans, grace mode. Limits: another service to run, no TLS, memory-only cache is lost on restart, VCL has a learning curve.
Redis
Redis is an in-memory data store. As a cache it holds whatever your code puts in it: the result of an expensive query, a rendered fragment, a session. It also covers adjacent needs such as rate-limit counters and job queues, which is why many stacks already run it.
Install it:
sudo apt install redis-server
For a pure cache, cap memory and let Redis evict the least recently used keys when it is full. Open the configuration:
sudo nano /etc/redis/redis.conf
Set these directives (they exist commented out in the file):
maxmemory 256mb
maxmemory-policy allkeys-lru
If Redis is used only as a cache, you can also disable snapshots by setting save "". Keep persistence if the same instance stores sessions or queues. Restart and verify:
sudo systemctl restart redis-server
redis-cli config get maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
The standard pattern is cache-aside: read from the cache, and on a miss load from the database and store the result with a TTL. In Python with the redis package:
import json
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def get_product(product_id, db):
key = f"product:{product_id}"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
row = db.fetch_one("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
r.set(key, json.dumps(row), ex=600) # expire after 10 minutes
return row
def update_product(product_id, fields, db):
db.update_product(product_id, fields)
r.delete(f"product:{product_id}") # invalidate after writing
Check what is cached and how long it will live:
redis-cli --scan --pattern 'product:*' | head
redis-cli ttl product:42
product:42
product:17
(integer) 587
Use --scan (or SCAN in code) rather than KEYS to find keys by pattern: KEYS blocks the server while it walks the whole keyspace.
Measure the hit ratio from the server statistics:
redis-cli info stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
keyspace_hits:98231
keyspace_misses:4120
evicted_keys:0
Strengths: rich data types, per-key TTLs, replication and clustering, useful beyond caching. Limits: requires code changes, memory is the limit, one main thread per instance handles commands.
Memcached
Memcached is a plain, multi-threaded in-memory key-value cache. It has no persistence, no replication and only string values, which keeps it simple and very efficient for small objects.
Install it:
sudo apt install memcached
The configuration file /etc/memcached.conf already binds to 127.0.0.1 and allocates 64 MB. Raise the memory limit by changing the -m line:
sudo nano /etc/memcached.conf
-m 256
-l 127.0.0.1
Restart and check the statistics:
sudo systemctl restart memcached
printf 'stats\r\nquit\r\n' | nc 127.0.0.1 11211 | grep -E 'limit_maxbytes|get_hits|get_misses|evictions'
STAT get_hits 0
STAT get_misses 0
STAT limit_maxbytes 268435456
STAT evictions 0
Applications use it through a client library with the same cache-aside logic shown for Redis. A common use is PHP sessions: install php-memcached and set session.save_handler = memcached and session.save_path = "127.0.0.1:11211" in the PHP-FPM php.ini.
Strengths: very low overhead, scales across CPU cores, nothing to tune. Limits: values lost on restart, no data structures, no built-in replication; for most new projects Redis covers the same need and more.
Cache invalidation and TTL strategy
Choosing a tool is the easy part; deciding how long data may be stale is what makes a cache correct. Three patterns cover most cases:
- TTL expiry only: data expires after a fixed time. Simple and safe when a few minutes of staleness is acceptable (listings, public pages, API responses).
- Delete on write: the code that changes data deletes the related cache keys, as in
update_product()above. The next read repopulates the cache. - Versioned keys or URLs: include a version or content hash in the key or file name (
app.3f9c2.css,product:42:v7). Old entries simply stop being requested and expire.
Reasonable starting TTLs:
| Content | TTL | Invalidation |
|---|---|---|
| Static assets with a hash in the file name | 1 year | New file name on each deploy |
| Public HTML pages | 5 to 15 minutes | Ban or purge on publish |
| Read-heavy API responses | 1 to 5 minutes | TTL expiry |
| Database query results | 1 to 60 minutes | Delete on write |
| Per-user data | Do not use a shared HTTP cache | Per-user keys in Redis |
Always pair a TTL with stale serving (proxy_cache_use_stale in Nginx, beresp.grace in Varnish) so an expired entry does not send a burst of requests to the backend at once.
Which one should you choose
- A single server with a PHP or Node.js site and mostly anonymous traffic: start with the Nginx FastCGI or proxy cache. It needs no extra service and handles most sites.
- High traffic, many URLs and editors who need changes live immediately: put Varnish behind your TLS terminator for its bans, grace mode and VCL logic.
- Logged-in users, APIs and personalised pages: cache objects in Redis from your application code. Use it for sessions and rate limits too.
- An existing application that already talks to Memcached: keep it. For new projects, Redis is usually the better default.
- Large sites: layer them. Browser cache for static assets, Nginx or Varnish for public pages, Redis for data behind personalised pages.
Troubleshooting
Nginx always shows MISS or BYPASS. The backend is probably sending Set-Cookie or Cache-Control: private on every response, or the bypass cookie is present. Inspect the headers with curl -sI http://127.0.0.1:3000/.
Varnish never returns HIT. Varnish passes requests that still carry cookies and does not cache responses with Set-Cookie. Run sudo varnishlog -g request -q 'ReqURL eq "/"' and look at the VCL_call and TTL lines to see why an object was not stored.
Redis evicted_keys keeps growing. The working set does not fit in maxmemory. Raise the limit, lower TTLs, or cache smaller values.
Conclusion
Nginx and Varnish cache complete HTTP responses without touching application code, while Redis and Memcached cache data your application chooses to store. For most sites the best path is an Nginx page cache first, then Redis for the parts of the application that are personalised. From here, add stale serving to every layer, track hit ratios with the commands above, and set TTLs per content type rather than one value for everything.
