How to Automate Cloudflare Cache Purge in WordPress (Full Script)
In this article16 sections
To automate Cloudflare cache purge for WordPress, create an API token that can only purge cache for one domain, run a single install script that checks the token, saves your settings in wp-config.php and adds a small plugin, and then confirm the result with a MISS-then-HIT test. From then on, every time you publish, update or unpublish a post, WordPress asks Cloudflare to drop the stored copies of the pages that changed, so visitors see your edit in seconds instead of when the cache timer ends.
This is the second half of a pair. The first half, caching WordPress pages on Cloudflare’s free plan, makes your pages cacheable. This guide gives you the complete script and every installation step. You need SSH access and sudo, so it suits a VPS or a dedicated server. If your site is slow for other reasons, start with this diagnosis guide.
What the script does
- It asks for your Zone ID, your API token (hidden as you type) and one blog address.
- It checks the formats, then asks Cloudflare whether the token is active.
- It sends a real test purge for the address you gave. If this fails, the script stops before it changes anything.
- It backs up
wp-config.phpto/root. - It adds the Zone ID and token to
wp-config.phpand checks the file’s syntax. If the check fails, it restores the backup. - It installs a small plugin in
wp-content/mu-pluginsand checks its syntax. - It reloads PHP-FPM, prints the result with the token hidden, and shows you the rollback command.
Before you start
- SSH access with sudo, and the folder where WordPress lives, for example
/var/www/blog. - A proxied domain. The DNS record must show an orange cloud in Cloudflare, and your pages should already be cacheable with the rule from the first guide. Purging a page that was never stored does nothing useful.
- No other plugin that purges Cloudflare on publish. Two of them would send two requests for every save.
- A recent site backup. The script backs up
wp-config.php, but a full backup is a good habit before editing any server file.
Step 1: Find your Zone ID
Open your domain in the Cloudflare dashboard. On the Overview page, the right-hand column has an API section that shows the Zone ID, a string of 32 letters and digits. Copy it. It identifies your domain and is not a secret on its own, but there is no reason to publish it.
Step 2: Create a token that can only purge
An API token is a password with limits. Give this one the smallest limits possible, so that if it ever leaks, the worst outcome is that someone clears your cache.
- In Cloudflare, click your profile icon, then My Profile, then API Tokens.
- Click Create Token, scroll to the bottom, choose Create Custom Token, then Get started.
- Name it
Blog cache purge. - Under Permissions, choose Zone, then Cache Purge, then Purge. Add no other permission.
- Under Zone Resources, choose Include, Specific zone, and your domain.
- Continue to the summary, click Create Token, and copy the token. Cloudflare shows it only once.
Warning. Never paste a token into a chat, a support ticket or a public file. If you do, open API Tokens, delete it and create a new one. Deleting takes effect immediately.
Step 3: Save the install script
Create the file, paste the script below, and save. The script is short enough to read, and you should read any script before you run it as root.
nano ~/install-cf-purge.sh
#!/usr/bin/env bash
# install-cf-purge.sh
# Installs automatic Cloudflare cache purge for a WordPress site.
# Usage: sudo bash install-cf-purge.sh [/path/to/wordpress]
set -euo pipefail
WP_DIR="${1:-/var/www/blog}"
CONFIG="$WP_DIR/wp-config.php"
MU_DIR="$WP_DIR/wp-content/mu-plugins"
PLUGIN="$MU_DIR/sh-cf-purge.php"
WEB_USER="www-data"
PHP_SERVICE="php8.4-fpm"
if [ "$(id -u)" -ne 0 ]; then echo "STOP: run this script with sudo"; exit 1; fi
if [ ! -f "$CONFIG" ]; then echo "STOP: $CONFIG not found"; exit 1; fi
if grep -q "SH_CF_ZONE_ID" "$CONFIG"; then echo "STOP: Cloudflare settings are already in wp-config.php"; exit 1; fi
if [ -e "$PLUGIN" ]; then echo "STOP: $PLUGIN already exists"; exit 1; fi
read -rp "Cloudflare Zone ID: " ZONE
read -rsp "Cloudflare API token (typing is hidden): " TOKEN
echo
read -rp "Blog address for a test purge (for example https://example.com/blog/): " TESTURL
if ! [[ "$ZONE" =~ ^[0-9a-f]{32}$ ]]; then echo "STOP: the Zone ID should be 32 characters (0-9, a-f)"; exit 1; fi
if ! [[ "$TOKEN" =~ ^[A-Za-z0-9_-]{30,}$ ]]; then echo "STOP: that does not look like an API token"; exit 1; fi
if ! [[ "$TESTURL" =~ ^https:// ]]; then echo "STOP: the test address must start with https://"; exit 1; fi
# Sends the token to curl through standard input, so it never appears in the process list
cf() { printf 'header = "Authorization: Bearer %s"n' "$TOKEN" | curl -s -K - "$@"; }
RESULT=$(cf https://api.cloudflare.com/client/v4/user/tokens/verify || true)
case "$RESULT" in
*'"status":"active"'*) echo "token check: active" ;;
*) echo "STOP: Cloudflare did not accept this token. Nothing was changed."; exit 1 ;;
esac
RESULT=$(cf -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" -H "Content-Type: application/json" --data "{"files":["$TESTURL"]}" || true)
case "$RESULT" in
*'"success":true'*) echo "test purge: success" ;;
*) echo "STOP: the test purge failed. Check the Zone ID and the token permissions. Nothing was changed."; exit 1 ;;
esac
LINE=$(grep -n "stop editing" "$CONFIG" | head -n 1 | cut -d: -f1 || true)
if [ -z "$LINE" ]; then LINE=$(grep -n "wp-settings.php" "$CONFIG" | head -n 1 | cut -d: -f1 || true); fi
if [ -z "$LINE" ]; then echo "STOP: could not find where to add the settings. Nothing was changed."; exit 1; fi
BACKUP="/root/wp-config.php.bak-$(date +%Y%m%d-%H%M%S)"
cp -p "$CONFIG" "$BACKUP"
echo "backup: $BACKUP"
TMP=$(mktemp /root/wpc.XXXXXX)
ZONE="$ZONE" TOKEN="$TOKEN" LINE="$LINE" awk 'NR == ENVIRON["LINE"] { q = " 47"; print "/* Cloudflare cache purge (mu-plugins/sh-cf-purge.php) */"; print "define( " q "SH_CF_ZONE_ID" q ", " q ENVIRON["ZONE"] q " );"; print "define( " q "SH_CF_API_TOKEN" q ", " q ENVIRON["TOKEN"] q " );"; print "" } { print }' "$CONFIG" > "$TMP"
cat "$TMP" > "$CONFIG"
rm -f "$TMP"
if php -l "$CONFIG" > /dev/null; then
echo "wp-config syntax OK"
else
cp -p "$BACKUP" "$CONFIG"
echo "SYNTAX ERROR in wp-config.php. The backup was restored."
exit 1
fi
mkdir -p "$MU_DIR"
cat > "$PLUGIN" <<'PHP'
<?php
/**
* Plugin Name: Cloudflare purge on publish
* Description: Purges Cloudflare's cached copies of pages that changed.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action(
'wp_after_insert_post',
function ( $post_id, $post, $update, $post_before ) {
if ( ! defined( 'SH_CF_ZONE_ID' ) || ! defined( 'SH_CF_API_TOKEN' ) ) {
return;
}
if ( 'post' !== $post->post_type ) {
return;
}
$was_live = $post_before && 'publish' === $post_before->post_status;
if ( 'publish' !== $post->post_status && ! $was_live ) {
return;
}
$urls = array( home_url( '/' ) );
if ( 'publish' === $post->post_status ) {
$urls[] = get_permalink( $post );
}
if ( $was_live ) {
$urls[] = get_permalink( $post_before );
}
$terms = wp_get_post_terms( $post_id, array( 'category', 'post_tag' ) );
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$link = get_term_link( $term );
if ( ! is_wp_error( $link ) ) {
$urls[] = $link;
}
}
}
$recent = get_posts(
array(
'numberposts' => 20,
'post_status' => 'publish',
'fields' => 'ids',
)
);
foreach ( $recent as $id ) {
$urls[] = get_permalink( $id );
}
$urls = array_slice( array_values( array_unique( $urls ) ), 0, 30 );
$response = wp_remote_post(
'https://api.cloudflare.com/client/v4/zones/' . SH_CF_ZONE_ID . '/purge_cache',
array(
'timeout' => 5,
'headers' => array(
'Authorization' => 'Bearer ' . SH_CF_API_TOKEN,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array( 'files' => $urls ) ),
)
);
if ( is_wp_error( $response ) ) {
error_log( 'Cloudflare purge failed: ' . $response->get_error_message() );
} elseif ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
error_log( 'Cloudflare purge failed: HTTP ' . wp_remote_retrieve_response_code( $response ) );
}
},
99,
4
);
PHP
chown "$WEB_USER:$WEB_USER" "$PLUGIN"
chmod 644 "$PLUGIN"
if php -l "$PLUGIN" > /dev/null; then
echo "plugin syntax OK"
else
rm -f "$PLUGIN"
cp -p "$BACKUP" "$CONFIG"
echo "SYNTAX ERROR in the plugin. It was removed and wp-config.php was restored."
exit 1
fi
systemctl reload "$PHP_SERVICE" || echo "WARNING: could not reload $PHP_SERVICE. Reload your PHP-FPM service by hand."
echo
echo "Installed. Settings now in wp-config.php (token hidden):"
grep -n "SH_CF_" "$CONFIG" | sed -E "s/(SH_CF_API_TOKEN', ')[^']*/1(hidden)/"
ls -l "$CONFIG" "$PLUGIN"
echo
echo "Rollback: cp -p $BACKUP $CONFIG ; rm $PLUGIN ; systemctl reload $PHP_SERVICE ; then delete the token in Cloudflare."
unset TOKEN
Step 4: Check three settings
Near the top of the script, three values may differ on your server:
- The WordPress folder. You pass it when you run the script, and the default is
/var/www/blog. WEB_USER. This iswww-dataon Ubuntu with Nginx or Apache. Other systems use names such asnginxorapache.PHP_SERVICE. It must match your PHP-FPM version. To find the name, runsystemctl list-units --type=service | grep fpm.
If a value differs, change it with nano ~/install-cf-purge.sh before you continue.
Step 5: Run it
sudo bash ~/install-cf-purge.sh /var/www/blog
Replace the folder with your own. You will see three prompts. Paste the Zone ID, then paste the token (nothing appears on screen, and that is normal), then enter your blog address, for example https://example.com/blog/. A successful run looks like this, with your own file names and times:
token check: active
test purge: success
backup: /root/wp-config.php.bak-20260101-120000
wp-config syntax OK
plugin syntax OK
The script then prints the two settings with the token replaced by (hidden), the permissions of both files, and the rollback command. Copy that last line somewhere safe.
Tip. Any line that starts with STOP means the script ended on purpose. If the message says nothing was changed, fix the Zone ID or token and run it again. A clean stop is safer than a half-finished install.
Step 6: Test the whole chain
Request a post twice, so that Cloudflare has a stored copy:
U=https://example.com/blog/your-post/
for i in 1 2; do curl -s -o /dev/null -D - "$U" | grep -i '^cf-cache-status'; done
The last line should say HIT. Now open the post in WordPress, change a word and click Update. Request the same address twice again. The first answer should be a MISS, which proves the purge removed the stored copy, and the second should be a HIT with your edit in it. If you only see HIT, go to the troubleshooting section below.
How the plugin decides what to purge
- It runs after the save is complete. The hook
wp_after_insert_postfires once the post, its categories, tags and fields are saved, so the purge never runs against half-saved data. - It only acts on posts that are or were live. Drafts, autosaves and revisions are ignored. If you unpublish or trash a post, its old address is still purged, because the code also looks at how the post was before the change.
- It builds a short URL list: the blog home page, the post, its category and tag pages, and your 20 newest posts, which is where most sidebars and “recent posts” lists show a new article. The list is cut to 30 addresses, the free plan’s limit per call.
- It logs a failure and moves on. If Cloudflare is unreachable or refuses the request, a line goes to the PHP error log, and saving the post is not blocked.
Keep your server cache in the right order
If your server keeps its own page cache, such as the one in our Nginx FastCGI cache guide, it must be cleared before Cloudflare is purged. Otherwise Cloudflare can fetch the old copy from your server straight after the purge and store it again. The plugin runs late on purpose, after the normal save hooks, which is where a server-cache purge usually happens. If you clear your cache some other way, make sure that happens first.
Roll back or uninstall
Use the backup file name the script printed:
sudo cp -p /root/wp-config.php.bak-20260101-120000 /var/www/blog/wp-config.php
sudo rm /var/www/blog/wp-content/mu-plugins/sh-cf-purge.php
sudo systemctl reload php8.4-fpm
Then open API Tokens in Cloudflare and delete the token. Restoring the backup also removes any other change you made to wp-config.php after the install, so check the file if you edited it since.
Limits to know about
- 30 URLs per call. That is Cloudflare’s limit on the free plan for purging by URL. It can change, so check Cloudflare’s documentation.
- Older posts keep old sidebars. A post outside the 20 newest keeps its cached copy until the timer from the Cache Rule guide ends. A shorter timer reduces that.
- Bulk imports. Importing hundreds of posts triggers a purge call for each one. Remove the two settings from
wp-config.phpduring the import, and purge by hand afterwards. - Exact addresses. A purge only matches a cached address exactly, including
httpsand the trailing slash.
Troubleshooting
- “Cloudflare did not accept this token.” The token was copied wrongly, expired or deleted. Create a new one.
- “The test purge failed.” The Zone ID is wrong, the token lacks the Cache Purge permission for this zone, or the test address belongs to a different domain.
- “Could not find where to add the settings.” Your
wp-config.phpdoes not follow the standard layout. Add the twodefinelines by hand above the line that loadswp-settings.php. - Nothing happens after an update. Check that both settings exist, that the plugin file is in
mu-plugins, and that PHP was reloaded. The plugin handles posts, not pages. - The call succeeds but you still see a HIT. The purged address did not match the cached one. Compare the scheme, the host, the trailing slash and any
www. - Where is the error log? It is your PHP error log. Its location depends on your setup, so check your PHP-FPM or web server error log for lines that start with
Cloudflare purge failed.
Common mistakes to avoid
- Using a global API key instead of a scoped token. A global key can change everything in your Cloudflare account.
- Purging before the server cache is cleared. Cloudflare then stores the old page again.
- Running the script twice. It stops on purpose if the settings already exist. Roll back first if you want a clean reinstall.
- Testing while logged in. Use
curlor a private window. - Keeping extra copies of
wp-config.phpin public folders. The backup goes to/rootfor a reason.
Running WordPress on shared hosting?
The install script needs root access, so it is meant for servers where you control the system. On shared hosting, you can add the two define lines and the plugin file yourself in cPanel’s File Manager, because mu-plugins is just a folder inside wp-content. Server-side page caching there comes from LiteSpeed Cache, which our 5-step speed guide sets up. Cloudflare’s rule and this purge are an optional extra layer on top.
Related guides: monitoring VPS bandwidth with vnStat and setting up free SSL on Nginx with Certbot.
Frequently asked questions
Do I need the official Cloudflare plugin instead?
Not necessarily. A plugin gives you a settings page and more features. This script installs one small file that does a single job, has no settings to maintain and is easy to read and audit.
Is it safe to keep the token in wp-config.php?
It is the usual place for WordPress secrets. Keep the file permissions tight, and use a token limited to purging one zone, so the damage from a leak is small.
Why does the script send a test purge before it changes anything?
A token can be active and still lack the right permission or zone. The test purge proves the exact combination works, before your configuration file is touched.
What if I have more than 30 pages to purge?
Send several calls of 30 or fewer, or shorten the cache timer so that old copies expire on their own. Check Cloudflare’s current limits before you build a larger system.
Will this slow down saving a post?
Only by the time of one request to Cloudflare, which has a limit of 5 seconds. A failed purge does not stop the save.
The short version: create a purge-only token, save and run the install script, let it test and back up before it changes anything, then confirm with a MISS then a HIT after an edit.