Every request to a PHP application normally goes through PHP-FPM, which runs the code and queries the database even when the resulting page is identical for every visitor. Nginx FastCGI cache stores those generated pages on disk and serves repeat requests directly, so PHP-FPM only works when the content actually changes. In this tutorial you will enable FastCGI caching for a PHP site on Ubuntu 24.04, exclude requests that must never be cached, verify the cache with response headers and learn how to purge it.

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 and PHP-FPM installed (sudo apt install nginx php8.3-fpm). Ubuntu 24.04 ships PHP 8.3, whose socket is /run/php/php8.3-fpm.sock.
  • A site served from /var/www/your_domain with a server block in /etc/nginx/sites-available/your_domain. Replace your_domain with your own domain throughout the guide.
  • A few hundred MB of free disk space for the cache.

Step 1 - Defining the cache zone

The cache zone tells Nginx where to store cached responses and how much memory to use for the index of keys. It must be declared in the http context. On Ubuntu, every file in /etc/nginx/conf.d/ is included inside that context, so create a dedicated file there:

sudo nano /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=PHPCACHE:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

What each parameter does:

  • levels=1:2 spreads cached files over two levels of subdirectories so no single directory gets too large.
  • keys_zone=PHPCACHE:100m creates a shared memory zone named PHPCACHE. One megabyte holds about 8,000 keys, so 100 MB is enough for roughly 800,000 cached pages.
  • inactive=60m deletes entries nobody has requested for 60 minutes, even if they are still valid.
  • max_size=1g caps disk usage; when it is reached Nginx removes the least recently used entries.
  • fastcgi_cache_key defines what makes two requests "the same". Unlike proxy caching, FastCGI caching has no default key, so this line is required. Including $scheme and $host keeps HTTP and HTTPS versions, and different sites on the same server, apart.

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

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

Step 2 - Enabling the cache in the server block

Now tell the PHP location of your site to use the zone. Open the server block:

sudo nano /etc/nginx/sites-available/your_domain

A complete server block with caching enabled looks like this:

server {
    listen 80;
    listen [::]:80;
    server_name your_domain www.your_domain;
    root /var/www/your_domain;
    index index.php index.html;

    set $skip_cache 0;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;

        fastcgi_cache PHPCACHE;
        fastcgi_cache_valid 200 301 302 10m;
        fastcgi_cache_valid 404 1m;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;

        fastcgi_cache_use_stale error timeout updating http_500 http_503;
        fastcgi_cache_background_update on;
        fastcgi_cache_lock on;

        add_header X-FastCGI-Cache $upstream_cache_status always;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}

The caching directives work together:

  • fastcgi_cache_valid sets how long each status code stays cached when PHP does not send its own Cache-Control or Expires headers.
  • fastcgi_cache_bypass and fastcgi_no_cache read the $skip_cache variable you will fill in Step 3. The first skips reading from the cache, the second skips writing to it.
  • fastcgi_cache_use_stale with fastcgi_cache_background_update serves the expired copy while one request refreshes it in the background, and keeps the site up if PHP-FPM returns errors.
  • fastcgi_cache_lock makes only one request go to PHP-FPM when many visitors ask for the same uncached page at once.
  • The X-FastCGI-Cache header exposes the cache result so you can verify it.

Test and reload:

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

Step 3 - Excluding requests that must not be cached

Serving a cached page to the wrong visitor is worse than not caching at all: a logged-in user could see another user's name, or a cart could show someone else's items. Add bypass rules to the server block, right after set $skip_cache 0;:

    if ($request_method = POST) {
        set $skip_cache 1;
    }

    if ($query_string != "") {
        set $skip_cache 1;
    }

    if ($request_uri ~* "/wp-admin/|/wp-json/|/xmlrpc.php|wp-.*\.php|/feed/|sitemap(_index)?\.xml") {
        set $skip_cache 1;
    }

    if ($request_uri ~* "/cart/|/checkout/|/my-account/") {
        set $skip_cache 1;
    }

    if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart|PHPSESSID") {
        set $skip_cache 1;
    }

The rules skip the cache for form submissions, URLs with query strings, the WordPress admin and API, WooCommerce cart and account pages, and any visitor who has a login, comment or session cookie. They are written for WordPress and WooCommerce; for another application, keep the POST, query string and session cookie rules and replace the paths and cookie names with the ones your application uses for logged-in users.

Using if with only set inside, at server level, is safe; the well-known problems with if in Nginx apply to other directives inside location blocks.

Test and reload again:

sudo nginx -t && sudo systemctl reload nginx

Step 4 - Verifying that the cache works

Create a small test page that prints the current time, which makes it obvious whether a response came from PHP or from the cache:

echo '<?php echo date("H:i:s"), "\n";' | sudo tee /var/www/your_domain/cache-test.php

Request it twice, a few seconds apart, and print the cache header and the body:

curl -s -D - http://your_domain/cache-test.php | grep -Ei 'x-fastcgi-cache|^[0-9]{2}:'
sleep 3
curl -s -D - http://your_domain/cache-test.php | grep -Ei 'x-fastcgi-cache|^[0-9]{2}:'
X-FastCGI-Cache: MISS
10:42:15
X-FastCGI-Cache: HIT
10:42:15

The second response is a HIT and shows the same time: PHP did not run. Now confirm a bypass rule by adding a query string:

curl -s -D - "http://your_domain/cache-test.php?nocache=1" | grep -i x-fastcgi-cache
X-FastCGI-Cache: BYPASS

You can also see the cached file on disk:

sudo find /var/cache/nginx/fastcgi -type f | head

The possible values of $upstream_cache_status are:

ValueMeaning
MISSNot in the cache; PHP generated the response and it was stored
HITServed from the cache
BYPASSA bypass rule matched; PHP generated the response
EXPIREDThe entry had expired; PHP generated a fresh one
STALEAn expired entry was served because PHP-FPM failed
UPDATINGAn expired entry was served while another request refreshed it

Remove the test page when you are done:

sudo rm /var/www/your_domain/cache-test.php

Step 5 - Purging the cache

When you publish or edit content, visitors keep seeing the old version until the entry expires. For a 10 minute validity that is often acceptable. When it is not, delete the cached entry.

To empty the whole cache, delete the files. Nginx notices missing files and fetches fresh copies:

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

To purge a single URL, compute its file name. The name is the MD5 hash of the cache key, and with levels=1:2 the file lives in a directory named after the last character of the hash, inside one named after the two characters before it. For https://your_domain/blog/hello-world/ the key is the scheme, method, host and URI concatenated:

KEY='httpsGETyour_domain/blog/hello-world/'
HASH=$(printf '%s' "$KEY" | md5sum | cut -d' ' -f1)
sudo rm -v "/var/cache/nginx/fastcgi/${HASH: -1}/${HASH: -3:2}/${HASH}"
removed '/var/cache/nginx/fastcgi/4/e1/2b7d0e3c9a1f5c8d6e4b0a9f7c3d1e14'

If rm reports that the file does not exist, the page was not cached or the key is different (for example http instead of https, or a missing trailing slash). Each cached file starts with a KEY: line that you can inspect with sudo grep -a -m1 '^KEY:' /path/to/file.

For WordPress, plugins such as Nginx Helper can delete cache files automatically when a post is updated; point the plugin at /var/cache/nginx/fastcgi and make sure the PHP-FPM user can write to it.

Troubleshooting

The header always shows MISS. Check the response headers from PHP with curl -sI http://your_domain/. A Set-Cookie header or Cache-Control: no-cache prevents caching. Also confirm that /var/cache/nginx/fastcgi is owned by www-data and look for permission errors with sudo tail /var/log/nginx/error.log.

The header does not appear at all. add_header in a location replaces every add_header inherited from the server level, and the header is only added in the PHP location. Request a URL that is handled by PHP, not a static file.

Logged-in users see cached pages. A cookie name is missing from the bypass rules. Log in, list your cookies in the browser developer tools and add the session cookie name to the $http_cookie regular expression.

The cache grows too large. Lower max_size or inactive in /etc/nginx/conf.d/fastcgi-cache.conf and reload. Check the current size with sudo du -sh /var/cache/nginx/fastcgi.

Conclusion

Your PHP site now serves anonymous traffic from Nginx's cache, with PHP-FPM only handling logged-in users, form submissions and content that has expired. As next steps, secure the site with Let's Encrypt using sudo certbot --nginx, log $upstream_cache_status in the access log to measure your hit ratio, and adjust fastcgi_cache_valid to how often your content changes.