
A proxy cache is judged not by its speed but by what it refuses to share: a public response served twice without calling the origin, and a private response that never leaves its owner. This lesson is aimed at someone who already publishes an application behind Nginx and wants to reduce its load without exposing one user's data to another. It teaches proxy_cache, the cache key, the Cache-Control and Vary headers, the directives that keep a response out of the cache, compression, then the isolation proof that concludes the work.
What you will learn
The lesson goes from declaring the cache to proving that it does not leak.
- Declare a cache with
proxy_cache_pathandproxy_cache, and read its real status. - Let the origin decide eligibility with
Cache-Control, instead of contradicting it. - Build a cache key and separate the variants of a single URL with
Vary. - Exclude authenticated responses from the cache with
proxy_no_cacheandproxy_cache_bypass. - Prove that no private response passes from one account to another.
- Compress text responses and measure the gain before and after.
Prerequisites
The lesson assumes you know how to publish an application and read the headers of a response.
- Publishing an application with
proxy_pass: Publish an application with Nginx. - Reading a request, a response, their headers and content negotiation: Read HTTP before configuring a proxy.
- Validating and reloading a configuration: Install Nginx.
- A Debian 13 machine with
sudo, running Nginx 1.31.8 installed from the publisher's repository.
What is a proxy cache, and what does it have to prove?
A proxy cache stores the response of an origin to return it to the next request without calling the origin: it gains latency, and it takes the risk of handing someone what was meant for another. The origin is the application server behind Nginx. A MISS is a request that the cache cannot serve and that therefore goes to the origin; a HIT is a response returned from the cache, without the origin having produced anything.
The gain is real, but it justifies nothing on its own. A cache that speeds up the home page and serves a visitor the record of another account is a security failure, not an optimization. The lesson therefore sets two requirements that are verified together. First, public and eligible responses are really served from the cache. Second, no private response passes from one account to another, and the variants of the same resource (the language, for example) do not get mixed.
Removing the cache satisfies the second requirement and betrays the first: it is a way to delete the problem, not to solve it. The whole method that follows rests on this idea, that an isolation proof must always come with its counter-check: the public page is still served from the cache.
How do you declare a cache with proxy_cache_path and proxy_cache?
A cache is declared in two steps: proxy_cache_path reserves the disk space and the memory zone, then proxy_cache enables it on a location. The first directive is set in the http context, outside any server. It gives a directory, a key zone (keys_zone, an in-memory index shared between workers) and bounds on size and duration.
# http contextproxy_cache_path /var/cache/nginx/boutique levels=1:2 keys_zone=boutique:10m max_size=200m inactive=30m;
server { listen 80; server_name boutique.lab.test;
location / { proxy_cache boutique; add_header X-Cache-Status $upstream_cache_status always; proxy_pass http://127.0.0.1:9100; proxy_set_header Host $host; }}Each parameter has a role. levels=1:2 spreads the files across subdirectories to avoid a giant directory. keys_zone=boutique:10m names the zone that proxy_cache boutique; references and grants it 10 MB. max_size bounds the disk, and inactive removes an entry not requested during this duration, whatever its freshness. Provide the directory and its permissions for the workers' user, nginx with the publisher's package: that account must be able to write to /var/cache/nginx/boutique, otherwise Nginx cannot fill its own cache.
The X-Cache-Status header makes the cache observable. Its value comes from the $upstream_cache_status variable, which Nginx fills in itself with MISS, HIT, EXPIRED, STALE, UPDATING, REVALIDATED or BYPASS depending on what happened. EXPIRED designates a stale entry reloaded from the origin, STALE a stale entry served for lack of better when the origin does not answer, UPDATING a stale entry served while another request refreshes it, REVALIDATED a stale entry that the origin confirmed, and BYPASS a response taken from the origin because the cache was deliberately bypassed. The always parameter makes the header appear on error responses too.
Who decides what goes into the cache, Nginx or the origin?
The origin knows its responses and says so with Cache-Control; the cache's role is to apply this contract, not to replace it with a uniform duration. By default, Nginx respects the origin's headers: a Cache-Control: max-age=600 sets the freshness duration, a private, no-cache or no-store prevents storing the response, and a response carrying Set-Cookie is not cached. The proxy_cache_valid directive only comes in as a fallback, for responses that carry no duration. An entry whose duration has elapsed is called stale: revalidation then consists of asking the origin whether the response has changed, instead of demanding the whole body from it.
location / { proxy_cache boutique;
# Fallback: only for responses that say nothing proxy_cache_valid 200 1m;
# A stale entry is revalidated with the ETag rather than regenerated proxy_cache_revalidate on;
proxy_pass http://127.0.0.1:9100;}This is where the most serious defect of the lesson is at play: proxy_ignore_headers Cache-Control Expires Set-Cookie;. This directive tells Nginx not to read these headers. The contract that the origin announces on each response is then thrown away, and two things happen at once. Private responses become eligible, since their private, no-store is no longer read. And a uniform duration, proxy_cache_valid 200 10m; for example, applies to everything, including a stock level that changes every second.
A second door leads to the same result, and it is less well known: a valid X-Accel-Expires header in the origin's response short-circuits the Cache-Control test. Nginx reads it first, and a positive value sets the storage duration without private or no-store ever being consulted. An application that emits this header to drive the proxy's cache therefore cancels, without knowing it, the protection its own Cache-Control announces. The two conditions are checked together: no proxy_ignore_headers on Cache-Control, and no X-Accel-Expires emitted by an application that also serves private responses.
A resource that changes often remains eligible, though: the origin announces Cache-Control: public, max-age=5 and Nginx keeps it five seconds, not ten minutes. With proxy_cache_revalidate on;, a stale entry is revalidated by a conditional request (If-None-Match with the ETag); if the origin answers 304 Not Modified, it does not regenerate the body. The two policies, short duration and revalidation, keep freshness without giving up the cache. What Nginx must never do is decide in place of the origin to gain cache.
How does the cache key separate variants?
The cache key is the string that designates an entry: two requests that produce the same key receive the same stored response, and the first to arrive wins. By default, Nginx builds it with $scheme$proxy_host$request_uri, that is, the scheme, the origin host and the full URI with its query string. proxy_cache_key lets you redefine it, and it is a sure way to go wrong: a key that is too poor mixes responses, a key that is too rich scatters the cache.
A key reduced to $uri ignores three things: the query string, so /catalogue?page=1 and /catalogue?page=2 are confused; the requested language; and the session. Two users who request the same URI then receive the first stored response, whoever its recipient was.
To separate the representations of a single URL (a product page in French and in English), the intended mechanism is the Vary header. The origin announces Vary: Accept-Language to say that its response depends on the requested language, and Nginx, which honors Vary since version 1.7.7, then stores each variant separately under the same key, without touching the key.
Adding $http_accept_language to the key would give a similar result, with a defect: each distinct value of the header (fr, fr-FR,fr;q=0.9, en-US,en;q=0.8…) would create an entry, and the cache would scatter into variants that differ only in their wording. Vary lets the origin, which knows what its response depends on, decide. It must, however, announce it: a forgotten Vary remains an application defect.
The same mechanism governs compression: an origin that compresses according to Accept-Encoding must announce Vary: Accept-Encoding, otherwise a compressed response may be served to a client that cannot read it.
How do you measure a HIT without trusting the header?
A HIT is measured at the origin, by what it produced, and never by what Nginx declares. X-Cache-Status is a declaration by the proxy, not a measurement, and it is the proxy that you are checking. A hand-written add_header X-Cache-Status "HIT" always; would display HIT on all responses, including those that have just crossed the origin, and a check that trusted this header would conclude green on a nonexistent cache. The header only has value if it reuses $upstream_cache_status, and it proves nothing.
The reliable measurement is to have the origin count what it produces. A test application increments a counter for each response body it generates, and exposes it on the machine's loopback, where Nginx has no hold. Two requests in a row on the same public resource then give an unambiguous result: the counter goes up by one at the first, and does not move at the second. A 304 does not count as a generation, so that a revalidation policy passes the measurement just like a short duration.
One pitfall remains: a measurement inherits the cache of the previous one. Each measurement must therefore start from a cold entry, that is, from a resource absent from the cache. The remedy is to add to the path a unique token per measurement (/catalogue/accueil/3f9a1c0d2b7e), which the origin ignores and which designates the same resource. Each token produces a new key, whatever key you have chosen, and the measurement concludes on a state that it set up itself.
How do you keep a private response from passing from one account to another?
Three safeguards add up: never ignore the origin's Cache-Control, explicitly keep authenticated requests out of the cache, and prove the result with markers that you choose. The first is already in place if you have not written proxy_ignore_headers Cache-Control. The other two are safety nets that you set yourself, because an origin that forgets its private one day must not be enough to expose data.
Look at the diagram from top to bottom: the alpha account receives its response, which carries its own marker. In the first case, the cache stores it under a key that does not see the session, and the beta account that requests the same URL receives alpha's marker. In the second case, nothing is stored and beta receives its own.
The directives that keep a request out of the cache are two in number, and they do not do the same thing. proxy_no_cache decides not to store the response, and proxy_cache_bypass decides not to serve from the cache. Both are declared, because a single one leaves a hole: without proxy_cache_bypass, an entry already stored is served; without proxy_no_cache, one account's response is stored for those that follow.
location /compte/ { proxy_cache boutique;
# No cache read if the request carries a session or an authorization proxy_cache_bypass $cookie_session $http_authorization;
# No cache write for those same requests proxy_no_cache $cookie_session $http_authorization;
proxy_pass http://127.0.0.1:9100; proxy_set_header Host $host;}These directives accept variables: if at least one of them is non-empty and different from 0, the condition is true. The location /compte/ only serves private responses: any request carrying a session or an authorization is kept out of the cache there, and the cache is kept elsewhere. Note the $cookie_session variable: it designates the cookie named session and not "any cookie". Keeping out of the cache any request that carries a cookie, including that of an audience measurement tool, would make the public page's cache disappear, which is the counter-check failed. Cookies, Vary and Authorization therefore do not boil down to a generic rule: you name what actually identifies a user.
How do you prove isolation and freshness?
An isolation proof draws a random marker per account, reads that marker at the origin, then verifies that it appears only at its owner's. A fixed body would prove nothing, since it would be the same on both sides; a random marker makes a leak indisputable. The sequence has four findings, in this order.
- The alpha account receives its response, with its marker: the private route works for its holder.
- The beta account does not receive alpha's marker, and receives its own.
- An anonymous request on the same route is refused (
401in the lab) and receives no account's marker. - The counter-check: the public page is still served from the cache, which is observed as for the HIT, two requests in a row and an origin counter that does not move at the second. Without it, "disabling the cache everywhere" would pass the first three findings.
Freshness is proved the same way. For a volatile resource, you change the state at the origin, wait longer than the announced duration (seven seconds for a max-age=5), then require the response to carry the new value. A symmetric test requires that two consecutive requests do not cause two bodies to be produced: a duration of zero would keep freshness by giving up the cache, a duration of ten minutes would keep the cache by serving a dead state. Both sides count.
Can you purge the cache with open source Nginx?
No: the proxy_cache_purge directive belongs to NGINX Plus, the commercial version; open source Nginx has no built-in purge. The ways to invalidate an entry with the lab's package are workarounds: a proxy_cache_bypass triggered by a request header that forces a reload from the origin, a freshness duration short enough that the question does not arise, or deleting the files of the cache directory.
The choice of duration is therefore the real lever. A resource that must change quickly receives a short max-age from the origin, with proxy_cache_revalidate on;, rather than a long duration accompanied by a purge that you do not know how to do cleanly. A stable resource can keep a long duration, since changing it goes through a new deployment anyway.
How do you compress responses and measure the gain?
gzip on; compresses text responses on the fly when the client announces Accept-Encoding: gzip, and the gain is measured by comparing the bytes received with and without compression. The negotiation is simple: the client declares the encodings it can read, the server picks one and says so in Content-Encoding. By default, Nginx only compresses text/html; other types are declared with gzip_types.
http { gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1024; gzip_vary on;}gzip_min_length avoids compressing a response so small that compression would cost more than it brings; its default value is 20 bytes. Nginx only judges this threshold on the Content-Length header: a response that arrives in segments, with no announced length, is therefore not filtered by this threshold and is compressed even if it is tiny. gzip_vary on; adds Vary: Accept-Encoding, which tells intermediate caches that two representations of the same URL exist. Do not compress already compressed formats (JPEG images, archives): the gain is zero and the processor works for nothing.
To measure, request the same resource with and without the encoding, and compare the transferred size.
curl -s -o /dev/null -w '%{size_download}\n' http://boutique.lab.test/catalogue/liste.jsoncurl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: gzip' http://boutique.lab.test/catalogue/liste.jsonThe two numbers are bytes actually received: the second must be noticeably smaller for text content, and equal to the first for a resource whose type is not in gzip_types. The path in the example is fictional; measure on a resource that exists on your side. Do not keep an "average gain" found online, it depends entirely on the content.
A security caveat calls for not compressing blindly. When a single response contains both a secret (a token, a session identifier) and text that the attacker can have reflected by the page, the size of the compressed response can tell the attacker something about the secret, even if the connection is encrypted. This is an established class of risk, described in RFC 7457, section 2.6: it explains why pages that mix secrets and user-controlled input are not compressed. The lesson does not detail the mechanism; keep the principle, and only compress responses where this mix does not exist. Compression on the Traefik side follows the same logic, described in Traefik middlewares.
Practice
The lab sets up a catalog behind a cache that is already in place, and badly tuned: the origin announces its contract on each response, and the configuration throws it away. You apply that contract. The public home page must really be served from the cache, the language variants must not get mixed without the French variant ceasing to be cached, an account must never receive another's marker, and a stock level modified at the origin must appear in under seven seconds.
The validation never reads X-Cache-Status: it counts the body generations at the origin, on the VM's loopback, and starts from a cold entry for each measurement. The content contract that lists the four resources and their rules is deposited on the machine, and it is the contract that decides, not your preference.
Key points
- A cache has two requirements that are proved together: eligible public responses are really served from the cache, and no private response passes from one account to another.
proxy_cache_pathreserves the space,proxy_cacheenables it.$upstream_cache_statusgives the status that Nginx observes, but it is not a measurement.- A HIT is measured at the origin, by counting what it produces, with a unique token per measurement to start from a cold entry.
proxy_ignore_headers Cache-Controlthrows away the origin's contract: it makes the private eligible and freezes the volatile. Let the origin decide, and reserveproxy_cache_validfor silent responses.- A second door leads to the same leak: a valid
X-Accel-Expiresfrom the origin short-circuits theCache-Controltest, soprivateandno-storeare never consulted. Check both conditions together. - The key designates the entry: a key reduced to
$uriignores language, session and query string.Varyseparates representations without touching the key. proxy_no_cacheprevents storing,proxy_cache_bypassprevents serving: declare both, on what actually identifies a user (the named session cookie,Authorization), not on any cookie.- An isolation proof requires a random marker per account, an anonymous request, and the counter-check: the public page stays cached.
- Purge is an NGINX Plus feature. With open source Nginx, it is the duration announced by the origin that sets freshness.
gzip on;andgzip_typescompress text; measure withcurl -w '%{size_download}', and do not compress a response that mixes a secret and an input that the user controls.
Knowledge check
Check that you have grasped the essentials of this guide. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Observe Nginx: logs, stub_status and upstream
A new full quiz with random questions
Work again only on the questions you missed