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
sudoprivileges. - 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_domainwith a server block in/etc/nginx/sites-available/your_domain. Replaceyour_domainwith 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:2spreads cached files over two levels of subdirectories so no single directory gets too large.keys_zone=PHPCACHE:100mcreates a shared memory zone namedPHPCACHE. One megabyte holds about 8,000 keys, so 100 MB is enough for roughly 800,000 cached pages.inactive=60mdeletes entries nobody has requested for 60 minutes, even if they are still valid.max_size=1gcaps disk usage; when it is reached Nginx removes the least recently used entries.fastcgi_cache_keydefines what makes two requests "the same". Unlike proxy caching, FastCGI caching has no default key, so this line is required. Including$schemeand$hostkeeps 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_validsets how long each status code stays cached when PHP does not send its ownCache-ControlorExpiresheaders.fastcgi_cache_bypassandfastcgi_no_cacheread the$skip_cachevariable you will fill in Step 3. The first skips reading from the cache, the second skips writing to it.fastcgi_cache_use_stalewithfastcgi_cache_background_updateserves the expired copy while one request refreshes it in the background, and keeps the site up if PHP-FPM returns errors.fastcgi_cache_lockmakes only one request go to PHP-FPM when many visitors ask for the same uncached page at once.- The
X-FastCGI-Cacheheader exposes the cache result so you can verify it.
NoteNginx never caches a response that contains a
Set-Cookieheader orCache-Control: private,no-cacheorno-store. PHP sends these when the code callssession_start(), which is usually the right behavior. If a page is never cached, check its headers first.
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:
| Value | Meaning |
|---|---|
MISS | Not in the cache; PHP generated the response and it was stored |
HIT | Served from the cache |
BYPASS | A bypass rule matched; PHP generated the response |
EXPIRED | The entry had expired; PHP generated a fresh one |
STALE | An expired entry was served because PHP-FPM failed |
UPDATING | An 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.
