Skip to content
Let’s plan the right software for your processes. Call us for a demo or a quote: +90 546 737 48 29

TR EN DE

Caching and compression in Nginx

Two questions decide how fast a web page feels: how many times is the same content generated again, and how many bytes travel over the network? Caching answers the first: a copy of content that has been prepared once is stored and served to later requests without being generated again. Compression answers the second: the response is made smaller before it is sent, and the browser unpacks it.

Nginx does both with a handful of directives. This guide first explains what each one is for, then how to configure it and, most importantly, what must not be cached. The examples are general-purpose; choose values to suit your own site and test the configuration after every change.

In brief

  • Browser caching stops returning visitors from downloading the same files again.
  • gzip shrinks text-based responses; it is not applied to images or archives.
  • proxy_cache stores the backend server's response and serves it directly to later requests.
  • Logged-in and personalised pages do not belong in a shared cache.

Caution

A badly configured server cache can show one user's page to another user. Do not enable proxy_cache or fastcgi_cache until you are sure that pages involving a login, a basket or personal data are kept out of the cache.

On this page

Three tools: browser cache, compression, server cache

  1. Which cache lives where?

    "The cache" is not a single thing; three separate copies can be kept at different points along the path of a request:

    • Browser cache: On the visitor's own device. Through response headers the server says "you may keep this file for this long", and the browser does not ask for the file again during that time.
    • Server cache (proxy_cache / fastcgi_cache): On the Nginx server's disk. Acting as a reverse proxy, Nginx stores the response it received from the backend server and answers later requests for the same address without contacting the backend at all. It is shared by all visitors.
    • Intermediate caches: Services such as a CDN look at the same headers and keep their own copies. See the CDN guide for details.

    Compression, on the other hand, keeps no copy; it makes the response smaller while it travels over the network. The three do not replace one another; they are used together.

    Diagram: the browser cache sits on the visitor's device, compression works on the network, and the server cache sits between Nginx and the backend server : Enlarge
  2. Browser caching for static files

    CSS, JavaScript, image and font files rarely change. The expires directive adds the Expires and Cache-Control: max-age headers to the response; add_header Cache-Control supplies additional values such as public or immutable.

    What determines the lifetime is whether the file's address changes:

    • Versioned file names (e.g. app.3f9a1c.css): because the name changes whenever the content does, a very long lifetime (a year, for example) and immutable are safe. immutable tells the browser "there is no need to revalidate this file until it expires".
    • Files with a fixed name (e.g. style.css): with a long lifetime, visitors see updates late. Choose a shorter lifetime or add a version to the file name.
    • HTML pages: usually given no-cache; the browser keeps a copy but checks with the server before each use.
    Nginx
    # Versioned files (e.g. /assets/app.3f9a1c.css): the name changes whenever the content does
    location ^~ /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    # Other static files: the name is fixed, so use a shorter lifetime
    location ~* \.(?:css|js|jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {
        expires 30d;
        add_header Cache-Control "public";
    }
    
    # HTML pages: a copy is kept, but the server is asked before each use
    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/ =404;
    }

    Two details deserve attention. The first block uses ^~; without it, a request for /assets/app.css would match the regular-expression location below and receive the shorter lifetime. Secondly, add_header directives are inherited from the level above only if there is no add_header at all on the current level; as soon as you write a single add_header inside a location, security headers defined at server level disappear there and have to be repeated.

  3. Compression with gzip

    In its request the browser announces, with the Accept-Encoding: gzip header, that it accepts compressed responses; Nginx compresses the response and sends it with Content-Encoding: gzip. Text-based content such as HTML, CSS, JavaScript, JSON and SVG becomes noticeably smaller.

    Nginx
    # Inside the http block: applies to all sites
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_vary on;
    gzip_proxied any;
    
    # text/html is always compressed; do not list it here
    gzip_types
        text/plain
        text/css
        text/xml
        application/json
        application/javascript
        application/xml
        application/rss+xml
        image/svg+xml;
    • gzip on; switches compression on. On its own it compresses text/html responses only.
    • gzip_types lists additional content types. text/html is always compressed and must not be listed (if it is, Nginx issues a duplicate type warning).
    • gzip_min_length leaves responses shorter than this number of bytes uncompressed (default 20). As very small responses gain nothing, a higher threshold is usually chosen.
    • gzip_comp_level ranges from 1 to 9 (default 1). Higher levels cost more CPU while the gain steadily diminishes; a middle value is enough for most sites.
    • gzip_vary on; adds Vary: Accept-Encoding to the response, which stops intermediate caches from mixing up compressed and uncompressed copies.
    • gzip_proxied: by default Nginx does not compress responses to requests that arrive through another proxy (those carrying a Via header). If a CDN or another proxy sits in front of your site, you need this directive.

    Formats such as JPEG, PNG, WebP, video, ZIP and PDF are already compressed; adding them to the list only wastes CPU.

    Brotli is an alternative compression method to gzip. It is not part of the standard Nginx package and requires a separate module to be installed. Writing brotli on; without the module causes a configuration error. Even if you use Brotli, leave gzip switched on; clients that do not support Brotli will receive gzip.

    Diagram: the browser sends Accept-Encoding: gzip, Nginx compresses the text-based response and returns it with Content-Encoding: gzip; images and archives are not compressed : Enlarge
  4. Server caching with proxy_cache

    When Nginx runs as a reverse proxy in front of a backend server (see reverse proxy setup), it can store responses on disk. The first request for an address goes to the backend and the response is stored (a MISS); later requests are answered straight from the cache until the entry expires (a HIT).

    Nginx
    # Cache area: directory, key zone (10 MB), disk limit, removal of unused entries
    proxy_cache_path /var/cache/nginx/site levels=1:2 keys_zone=site_cache:10m
                     max_size=1g inactive=60m use_temp_path=off;
    
    # 1 if a session cookie is present, otherwise 0 (change the cookie name to match your application)
    map $cookie_PHPSESSID $skip_cache {
        default 1;
        ""      0;
    }
    
    server {
        listen 80;
        server_name example.com;
    
        # Pages that look the same to everyone: cached
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
    
            proxy_cache site_cache;
            proxy_cache_key $scheme$host$request_uri;
            proxy_cache_valid 200 301 10m;
            proxy_cache_valid 404 1m;
    
            # Request with a session: do not answer from the cache and do not store the response
            proxy_cache_bypass $skip_cache;
            proxy_no_cache $skip_cache;
    
            # Serve an expired copy if the backend cannot respond
            proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
            proxy_cache_lock on;
    
            add_header X-Cache-Status $upstream_cache_status;
        }
    
        # Admin panel, basket and account pages: no cache
        location ~ ^/(admin|basket|account)(/|$) {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
    • proxy_cache_path may only appear at http level: the directory for the files, the shared memory zone that holds the keys (keys_zone=name:size), the upper limit on disk (max_size) and the removal of entries that nobody has requested for this long (inactive).
    • proxy_cache enables the zone for that location.
    • proxy_cache_key decides when two requests count as "the same". If the content also varies by something else (a language cookie, for instance), that value must be part of the key; otherwise a page generated in one language is served to someone who asked for another.
    • proxy_cache_valid is the storage time per status code. If the backend sends a Cache-Control or Expires header, that takes precedence.
    • proxy_cache_bypass stops the request from being answered from the cache, and proxy_no_cache stops the response from being written to it. The condition applies when the given value is neither empty nor "0". In the example, map sets the variable to 1 for requests carrying a session cookie; change the cookie name to match your application.
    • proxy_cache_use_stale allows an expired copy to be served when the backend returns an error or does not respond; the site stays up during short outages.
    • add_header X-Cache-Status $upstream_cache_status; writes the cache status into every response.

    By default Nginx caches GET and HEAD requests only, and it does not store responses that contain Set-Cookie or whose Cache-Control header includes private, no-cache or no-store. Switching this behaviour off with proxy_ignore_headers is the most common cause of personal content leaking; if you are not sure what you are doing, do not use it.

    Flow chart: a request arrives; if the cache holds a valid copy it is served directly (HIT); otherwise the request goes to the backend server and the response is stored and returned (MISS) : Enlarge
  5. What should not be cached?

    The server cache is shared by all visitors. The rule is simple: if the response depends on who is asking, it does not go into a shared cache.

    • Logged-in pages: my account, my orders, profile, personal prices or content.
    • Basket and checkout steps.
    • The admin panel and login pages.
    • Responses containing Set-Cookie: if cached, one user's session cookie is sent to someone else.
    • Form submissions (POST) and pages containing single-use tokens (CSRF tokens, for example).
    • API responses that vary with the authorisation header.

    The safest arrangement is to enable the cache only for addresses you have chosen explicitly (content that looks the same to everyone, such as the home page and product or article pages) and to leave admin and account addresses uncached in a separate location. For general precautions about sessions, see the guide on login and session security.

    Comparison: pages that look the same to everyone and static files are cached; logged-in pages, the basket, the admin panel and responses containing Set-Cookie are not : Enlarge

Checking that it works: X-Cache-Status

After testing and reloading the configuration, look at the response headers. Request the same address twice: you should see MISS in the first response and HIT in the second.

Bash
# Test the configuration and reload if there are no problems
sudo nginx -t && sudo systemctl reload nginx

# Static file headers: Cache-Control and Expires should appear
curl -s -o /dev/null -D - https://example.com/assets/app.3f9a1c.css | grep -i -E "cache-control|expires"

# Compression: Content-Encoding: gzip and Vary: Accept-Encoding should appear
curl -s -o /dev/null -D - -H "Accept-Encoding: gzip" https://example.com/ | grep -i -E "content-encoding|vary"

# Server cache: request the same address twice (first MISS, then HIT)
curl -s -o /dev/null -D - https://example.com/ | grep -i x-cache-status
curl -s -o /dev/null -D - https://example.com/ | grep -i x-cache-status
ValueMeaning
MISSThere was no copy in the cache; the response came from the backend (and was stored if eligible).
HITThe response was served straight from the cache; the backend was not contacted.
BYPASSThe proxy_cache_bypass condition was met; the cache was skipped.
EXPIREDThe copy had expired; the response was fetched from the backend again.
STALEThe backend could not respond; an expired copy was served.
UPDATINGThe old copy was served while the entry was being refreshed.
REVALIDATEDThe backend confirmed that the expired copy is still valid.

If you see MISS every time, look at the backend's response: it may be sending Set-Cookie or Cache-Control: no-store. Starting a session in PHP, for example, adds headers that prevent caching under the default settings. If you would rather not show the header to everyone on the live site, you can remove it once testing is finished.

If you use PHP-FPM: fastcgi_cache

When Nginx passes PHP not to another web server but directly to PHP-FPM (fastcgi_pass), the same logic is set up with the fastcgi_cache family: fastcgi_cache_path, fastcgi_cache, fastcgi_cache_valid, fastcgi_cache_bypass, fastcgi_no_cache. One important difference is that fastcgi_cache_key has no default value; it must be written explicitly.

Nginx
fastcgi_cache_path /var/cache/nginx/php levels=1:2 keys_zone=php_cache:10m
                   max_size=512m inactive=60m;

map $cookie_PHPSESSID $skip_cache {
    default 1;
    ""      0;
}

server {
    listen 80;
    server_name example.com;
    root /var/www/example.com/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php-fpm.sock;   # the socket path varies by distribution

        fastcgi_cache php_cache;
        fastcgi_cache_key $scheme$request_method$host$request_uri;   # has no default
        fastcgi_cache_valid 200 5m;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;

        add_header X-Cache-Status $upstream_cache_status;
    }
}

For the PHP-FPM connection itself, see the guide on Nginx and PHP-FPM.

Clearing the cache

If the content has changed but the cache still serves the old copy, there are several options:

  • Keep lifetimes short: For pages that change often, a proxy_cache_valid of a few minutes is usually enough, and no clearing is needed.
  • Delete the files: Deleting the files in the cache directory empties the whole cache; Nginx fetches responses from the backend again on the next requests. Target only the directory you defined with proxy_cache_path.
  • Change the key: Adding a version value to proxy_cache_key and changing it invalidates all old entries at once; the old files are removed automatically when the inactive period ends.
  • Refresh a single address: If you tie the proxy_cache_bypass condition to a signal that only you can send (a request header accepted only from your own IP address, for instance), that request receives a fresh response from the backend and the entry is renewed.
Bash
# Empty the entire server cache.
# The path must be the directory you defined with proxy_cache_path (or fastcgi_cache_path); do not use any other directory.
sudo find /var/cache/nginx/site -type f -delete

To the best of our knowledge, a ready-made "purge" feature that removes individual addresses is not built into the open-source version of Nginx; it is provided by the commercial version or by third-party modules. Check the documentation of your own package to see what your installation includes.

The browser cache, however, cannot be cleared from the server; it lives on the visitor's device. That is why versioned file names are the only reliable approach for long-lived static files.

Common mistakes

  • Giving a one-year lifetime to CSS and JavaScript files whose names never change; updates do not reach visitors.
  • Adding text/html or image types to the gzip_types list.
  • Enabling proxy_cache for the whole site without checking for the session cookie.
  • Copying the line proxy_ignore_headers Set-Cookie Cache-Control; without knowing what it does.
  • Not adding the relevant value to the cache key when content varies by language, device or currency.
  • Writing an add_header inside a location and not noticing that the security headers from the level above have vanished (see security headers).
  • Putting the cache directory somewhere the user Nginx runs as cannot write to.

Checklist

  • Static files get a lifetime through expires; long lifetimes only for versioned file names.
  • gzip is on; gzip_types contains text-based types only, with gzip_vary on.
  • The server cache is enabled only for addresses that look the same to everyone.
  • Requests with a session cookie are excluded with proxy_cache_bypass and proxy_no_cache.
  • The admin panel, basket and account pages are uncached.
  • MISS and HIT have been observed with X-Cache-Status; BYPASS appears while logged in.
  • How to clear the cache is written down and has been tried.
  • nginx -t has been run after every change.

Frequently asked questions

Why do changes to the site not show up straight away once caching is on?

Because the browser or the server is serving an old copy that has not yet expired. You can clear the server cache or shorten its lifetime; for the browser cache, the lasting solution is to use versioned file names for static files.

Should I use gzip or Brotli?

gzip ships with Nginx and works in every browser; switch it on first. Brotli requires a separate module. If you are able to install the module you can use both together; the browser picks the one it supports.

I keep seeing MISS. Why?

The most common reasons: the backend sends Set-Cookie or Cache-Control: no-store or private, the request carries a session cookie, proxy_cache_valid has not been defined, or the address contains a query parameter that changes with every request.

What is the difference between proxy_cache and fastcgi_cache?

The logic is the same; the difference is where Nginx gets the response from. If the backend is a server that speaks HTTP (proxy_pass), use proxy_cache; if it is PHP-FPM directly (fastcgi_pass), use fastcgi_cache.

Do I still need the Nginx cache if I use a CDN?

The two complement each other. A CDN serves content from locations close to the visitor, while the Nginx cache reduces the load on the application for the requests the CDN passes on to your server. If your cache headers are correct, both follow the same rules.

BYK Yazılım Support Team
This guide is written and regularly reviewed by the BYK Yazılım support team. Last updated: 4 October 2026.

Related guides

Let us talk about your website infrastructure

BYK Yazılım builds corporate websites. Write to us with any questions about your site.

Contact us Our corporate website service