Skip to content

Instantly share code, notes, and snippets.

@anthumchris
Last active September 25, 2022 19:04
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save anthumchris/a872c5b56d7a78f29b7c1c0881c4ef1b to your computer and use it in GitHub Desktop.
Save anthumchris/a872c5b56d7a78f29b7c1c0881c4ef1b to your computer and use it in GitHub Desktop.
Clear Nginx Cache

Clearing Nginx's HTTP Cache

I recently implemented Nginx HTTP content caching on our WordPress web servers to improve page load speeds and eliminate redundant, unneeded server-side page rendering. Caching the pages was relatively straightforward, but clearing the cache required a custom workaround.

Nginx comes in two versions: free and “Nginx Plus” at $2,500/year. The free version of Nginx does not offer the needed cache-clearing features of Nginx Plus, and I wasn’t comfortable paying $20,000 for 8 instances without trying to build my own solution.

Our Nginx servers run as an HTTP proxy for multiple PHP/MySQL-backed WordPress sites. The goal was to cache the dynamic PHP HTML responses in Nginx and serve the HTML pages from Nginx to avoid redundant, CPU-intensive PHP renders.

Site Cache Configuration

The example below shows how PHP response caching is configured for a site (other nginx configuration details are excluded for brevity). A cache named cachedemo-prod is defined to store cached HTML files in folder /var/cache/nginx/cachedemo-prod.

nginx.conf

fastcgi_cache_path /var/cache/nginx/cachedemo-prod levels=1:2 keys_zone=cachedemo-prod:10m max_size=100m inactive=1y use_temp_path=off;

server {
  ...

  # Configure cache 
  fastcgi_cache cachedemo-prod;
  fastcgi_cache_key $request_method$scheme$host$request_uri;

  # Disable cached responses if admins are logged in (admin session cookie exists)
  fastcgi_cache_bypass $cookie_wordpress_logged_in_abcdefg1234567890
                       $cookie_wordpress_sec_abcdefg1234567890;
                       
  # forward all *.php URIs to PHP service
  location ~ \.php$ {
    # Keep cached pages for 90 days
    fastcgi_cache_valid 200 90d;

    fastcgi_read_timeout 300;
    try_files $uri =404;
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/var/run/php5-fpm.sock;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
  }
  
  ...
}

Cache-Clearing Events

To achieve a balance between performance benefits and minimizing configuration overhead, I simplified matters by clearing the website's entire cache when:

  1. Admins Change Content in WordPress (content added, modified, removed)
  2. Developers Deploy New Releases (CSS, HTML, JavaScript, images, etc)

While changes could be small, one CSS change may affect a header layout, which subsequently affects all pages using that header. It seemed like a fail-safe and defensive strategy to prevent stale content by invalidating all pages.

Clearing a Site’s Cache

The cache is cleared by deleting all files/folders withing the specified cache folder. When one of the Cache-Clearing events occur, a trigger executes to initialize the cache-clearing script at /usr/share/nginx-clear-cache-init.sh:

#!/bin/bash
sudo /root/scripts/nginx-clear-cache.php "$@"

nginx-clear-cache.php is called as sudo because Nginx creates the files as root:root and I found no way of immediately changing this behavior in Nginx’s configuration files. The script requires sudo privileges to delete the cached files. It also requires 1 parameter indicating which cache key to clear. I decided to use a combination of HTTP hostname and explicit key name to be used for the same cache. The HTTP hostname allows more flexibility when WordPress triggers the script by dynamically passing the hostname, which indicates the environment (e.g. Prod, Stage) the application is deployed in.

Authorizing Sudo Access without Authentication

Because sudo is used and called by non-root processes (PHP and application deployment processes), the cache-clearing script must be authorized to run without requiring users to authenticate. Using the $ visudo command, the following line was added:

ALL ALL=(ALL) NOPASSWD: /root/scripts/nginx-clear-cache.php

NOTE: This is security vulnerability if not used wisely.

Rethinking the Solution

Ideally, it would be better if Nginx could be configured to create cache files with permissions that do not require sudo to be used. It would also be nice to consolidate the two cache-clearing scripts into one script that recursively calls itself as sudo if not initiated as sudo.

#!/usr/bin/php
<?php
// This script should be called by SUDO by another script and return number of items cleared
// Define duplicate cache keys to accommodate both label (static) and actual hostname (dynamic) calls
$sites = array(
"cachedemo-prod" => "/var/cache/nginx/cachedemo-prod",
"cachedemo.example.com" => "/var/cache/nginx/cachedemo-prod",
"cachedemo-stage" => "/var/cache/nginx/cachedemo-stage",
"cachedemo-stage.example.com" => "/var/cache/nginx/cachedemo-stage",
);
$siteArg = isset($argv[1])? $argv[1] : null;
if (!isset($sites[$siteArg])) {
echo "Valid site argument required: \n\n";
foreach ($sites as $key => $value) {
echo " $key\n";
}
echo "\nInvalid site argument: '$siteArg'";
exit(1);
}
$siteName = $argv[1];
$siteCachePath = $sites[$siteName];
$log = date("h:i:s A T") . " - $siteName - ";
$descriptorspec = array(
0 => array("pipe", "r"), // stdin
1 => array("pipe", "w"), // stdout
2 => array("pipe", "w"), // stderr
);
// execute Linux system process
$process = proc_open("find \"$siteCachePath\" -type f -exec rm -v {} \; | wc -l", $descriptorspec, $pipes, dirname(__FILE__), null);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if ($stderr) {
$log .= "ERROR $stderr";
} else {
$log .= $stdout;
}
// Log to file
file_put_contents('/var/log/nginx/cache-clear.log', "$log", FILE_APPEND);
// Output total cleared
echo $stdout;
@jordantrizz
Copy link

jordantrizz commented May 28, 2019

Great solution, but what about the module nginx-cache-purge? You can then limit requests from specific IP Addresses and allow purges.

@KarelWintersky
Copy link

@jordantrizz, it is available in NGINX Plus only.

@jordantrizz
Copy link

@jordantrizz, it is available in NGINX Plus only.

Right, I found this out shortly after. I did, however, find a free solution.

https://github.com/JeffCleverley/NginxFastCGICachePurger

Which uses a fork of FRICKLE https://github.com/torden/ngx_cache_purge

@nwallis
Copy link

nwallis commented Aug 22, 2019

Great post - I was thinking of doing some a little finer grained by searching the cache files for a particular string in the request so that when a product is updated on our site, I can clear its cached page and any category pages it might be in... or did you find clearing the whole cache to not be a particular drama?

Ideally I want all users seeing cached content for speed purposes, so I was thinking I might hit up the URLs after deletion...

@anthumchris
Copy link
Author

anthumchris commented Aug 29, 2019

@nwallis Clearing the whole cache was not a problem for our particular sites because it was a big improvement to already-existing performance problems they had. If you have time to invest, granularity is definitely better and Varnish Cache's fragments (think as "regions" of a page template) would be a step up over this Nginx implementation for high-traffic sites.

@jordantrizz
Copy link

@nwallis Clearing the whole cache was not a problem for our particular sites because it was a big improvement to already-existing performance problems they had. If you have time to invest, granularity is definitely better and Varnish Cache's fragments (think as "regions" of a page template) would be a step up over this Nginx implementation for high-traffic sites.

ESI is in LiteSpeed (paid) hopefully they bring it to OpenLiteSpeed. Clearing the whole cache isn't bad on smaller sites, but larger sites will be impacted until the caches fill up again.

@nwallis
Copy link

nwallis commented Aug 30, 2019

@nwallis Clearing the whole cache was not a problem for our particular sites because it was a big improvement to already-existing performance problems they had. If you have time to invest, granularity is definitely better and Varnish Cache's fragments (think as "regions" of a page template) would be a step up over this Nginx implementation for high-traffic sites.

ESI is in LiteSpeed (paid) hopefully they bring it to OpenLiteSpeed. Clearing the whole cache isn't bad on smaller sites, but larger sites will be impacted until the caches fill up again.

I ended up giving ESI a miss in my situation because of the way that pages were server rendered and cookies were delivered by my upstream application - it just became too messy and I really want to be able to cache category and products pages, even after they have something in their cart... I ended up just keeping all page content as static, cached content and stripped session cookie from upstream render (so it got cached) and doing an ajax retrieval for session data when page loaded on client side. I just make sure I don't cache any ajax requests... Just lucky my site is not an overly complex configuration.

Still haven't implemented any purge logic for my nginx cache though and just have a short time limit on the cache entries for now.

@szabyka
Copy link

szabyka commented Feb 5, 2022

@anthumchris "a trigger executes to initialize the cache-clearing script" how you've done it?

@anthumchris
Copy link
Author

anthumchris commented Feb 8, 2022

@szabyka For certain sites, I'm clearing the cache at build time by executing /usr/share/nginx-clear-cache-init.sh directly. For Content Management Systems, i'm executing that command from PHP when content is updated.

@marco910
Copy link

How did you manage to clear the cache when a page/post is updated in the WordPress backend?

@jordantrizz
Copy link

How did you manage to clear the cache when a page/post is updated in the WordPress backend?

You would need to create the appropriate PHP code to trigger a GET request to nginx-clear-cache.php which would then clear the cache.

If you want a better solution, then you could look at wordops.net which uses Redis Full Page caching or FastCGI cache. You can then use the Nginx helper plugin which uses common hooks for content updates and purges the cache.

https://docs.wordops.net/troubleshooting/common-issues/#when-i-update-a-page-changes-are-not-applied-on-the-site

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment