Varnish Cache + WordPress/WooCommerce on Docker: Beginner to Production
75 min read
You already run WordPress/WooCommerce behind Nginx, PHP-FPM, and MariaDB in Docker. This guide adds Varnish as an HTTP cache layer without moving TLS off Nginx or rewriting the application.
Correctness > Security > Availability > Performance > Cache HIT ratio
A high HIT ratio is worthless if customers see wrong stock, another user’s cart, or stale prices.
How to read this guide (start here)
Every code block has a colored label. Do only what the label says.
- Read Diagram, formula, or explanation — do not paste into a terminal.
- Copy file Create this file on disk (path is in the label or first comment). Edit values for your environment.
- Run Paste into a shell on the machine this section describes.
- SQL Paste into a database client, not bash.
- Sample output A realistic excerpt immediately after an observation command; compare fields and read the Meaning note before continuing.
Follow parts in order. Change domain names, secrets, and TTLs to match your shop before production cutover.
Suggested path: Parts 1–7 (concepts) → Part 8 files + Part 9 start → Parts 10–18 (VCL) → Part 19 (WordPress steps A–H) → 20–33 (ops) → Part 35 (same files in one place).
Part 1 — Introduction
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
This is a production-oriented playbook for adding Varnish Cache 7.x (VCL 4.1) to an existing Dockerized WordPress/WooCommerce stack (Nginx + plain PHP-FPM with wp-cli + MariaDB — not the official WordPress image). You keep Nginx for TLS and Certbot, PHP-FPM for application logic, and MariaDB for data. Varnish sits between the edge Nginx and the origin Nginx, caching anonymous HTML and static assets so PHP and MariaDB are not hit on every page view.
The guide progresses from concepts (what is a cache HIT?) through WooCommerce-specific safety rules, invalidation, observability, testing, deployment, and complete configuration files you can adapt.
Part 2 — Existing architecture
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Internet | | HTTPS :443 v Nginx (TLS, Certbot, FastCGI) | +----------------------+ | | v v PHP-FPM MariaDB | v WordPress / WooCommerce
Nginx terminates TLS, serves certificates via Certbot, and forwards PHP requests to PHP-FPM. WordPress files are mounted into the PHP container. This works but under high traffic every product page executes PHP and queries MariaDB.
Part 3 — What Varnish is
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Varnish is an HTTP reverse proxy and cache. It stores responses (cache objects) keyed by request attributes (Host, URL, cookies when varied). VCL (Varnish Configuration Language) 4.1 defines policy in vcl_recv, vcl_backend_response, vcl_hit, and vcl_deliver.
| Term | Meaning |
|---|---|
| HIT | Object found in cache; backend not contacted |
| MISS | Not in cache; fetched from backend, then stored |
| PASS | Do not use cache for this request; always fetch backend |
| FETCH | Backend response being retrieved |
| TTL | Time object is fresh |
| Grace | Serve stale object while revalidating or if backend is sick |
| Keep | Idle time before object is discarded after TTL+grace |
| Backend | Origin server (here: nginx-origin:8080) |
| PURGE | Remove one cache object by exact URL |
| BAN | Add a ban-lister pattern; matching objects invalidated lazily |
| Cache object | Stored HTTP response + metadata in Varnish memory |
| Cache key | Hash inputs (Host, URL, Vary headers) identifying one object |
| Cache invalidation | PURGE/BAN/TTL expiry removing or marking objects stale |
| Request coalescing | One backend fetch while many clients wait on the same MISS |
| Cache stampede | Thundering herd when many clients miss cache simultaneously |
| Backend probe | Health check that marks backend sick/healthy |
Part 4 — How HTTP caching works
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Caches store representations of resources. A cache key typically includes Host and URL (and sometimes cookies or Vary headers). If two users should see different content, they must not share a key.
if now < object.expires → serve from cache (HIT)
else → MISS or stale-with-grace depending on VCL
Cache invalidation removes or marks objects stale when content changes. Without invalidation, TTL alone determines freshness — risky for inventory.
Part 5 — Varnish request lifecycle
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Client ↓ HTTPS Nginx :443 (TLS) ↓ HTTP Varnish :6081 ↓ HIT → cached HTML (PHP/DB not used) ↓ MISS/PASS Nginx origin :8080 ↓ PHP-FPM → WordPress → MariaDB
On HIT, Varnish returns stored HTML. PHP-FPM worker exhaustion and MariaDB read load drop for anonymous catalog browsing.
• Fewer PHP-FPM workers busy serving identical product/category HTML
• Fewer MariaDB read queries for every anonymous page view
• Lower p95 latency on cacheable GETs
• More headroom during flash sales — as long as stock/cart/checkout rules stay correct
Part 6 — Why WordPress needs special handling
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
WordPress is dynamic: sessions, admin bar, comment cookies, WooCommerce carts. Blindly caching all GET requests causes session leakage and stale carts. The VCL must pass authenticated and commerce-critical routes.
Not cacheable: cart, checkout, my-account, login, AJAX/REST with private data, requests with session/auth cookies.
Part 7 — Designing the new architecture
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Target topology:
Internet → Nginx:443 TLS → Varnish:6081 HTTP → Nginx origin:8080 → PHP-FPM → MariaDB
Why Varnish must NOT terminate TLS
- Certbot workflow — Your existing Nginx + Certbot HTTP-01/renewal hooks stay unchanged.
- Operational familiarity — TLS ciphers, HSTS, and certificate storage remain in one place (edge Nginx).
- Varnish role — Optimized for HTTP caching, not certificate lifecycle management.
- Security boundary — Varnish listens on the internal Docker network on plain HTTP; it is not exposed to the public internet directly.
:443; Varnish receives already-decrypted HTTP from Nginx with X-Forwarded-Proto: https.Part 8 — Docker architecture
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
varnish-wp/
├── .env
├── .env.example
├── docker-compose.yml
├── certbot/
│ ├── conf/
│ └── www/
├── nginx/
│ ├── edge.conf # TLS :443 → varnish:6081
│ └── origin.conf # :8080 → PHP-FPM
├── php/
│ └── Dockerfile # PHP-FPM + wp-cli (Compose build)
├── varnish/
│ └── default.vcl # VCL 4.1 for Varnish 7.x
└── wordpress/ # full WordPress tree on host (not official WP image)
├── wp-config.php # DB + HTTPS + VARNISH_* constants
├── wp-content/
│ └── mu-plugins/
│ └── varnish-purge.php
└── …
Services on a single Compose network wp-net: nginx-edge (public 443), varnish (6081 internal), nginx-origin (8080 internal), php-fpm (9000), mariadb, certbot. Pin images; never use latest in production.
wordpress: image. WordPress files live in ./wordpress on the host. Put DB/HTTPS/Varnish constants in wordpress/wp-config.php, not in Compose WORDPRESS_CONFIG_EXTRA.| Service | Image (pinned) | Port |
|---|---|---|
| nginx-edge | nginx:1.27-alpine | 443, 80 |
| varnish | varnish:7.5.0-alpine | 6081 (expose) |
| nginx-origin | nginx:1.27-alpine | 8080 (expose) |
| php-fpm | local/php-fpm-wpcli:8.2 (build ./php) | 9000 |
| mariadb | mariadb:11.4.3-noble | 3306 |
Starter files — create these before you start Compose
Part 35 reprints the same stack. Create the tree and COPY the blocks below (Compose, VCL, nginx, Dockerfile). For wp-config.php + mu-plugin you can copy here now or follow Part 19 Steps A–H in detail — do not define the same constant twice. Without these mounts, Part 9 fails.
Lab TLS: either issue a real Let’s Encrypt cert into certbot/conf, or generate a self-signed pair and point edge.conf at those paths. Put the full WordPress tree under ./wordpress (core + wp-config.php). WP_HOME in wp-config.php must match the browser hostname.
Build this image before compose up (Compose build: uses the same Dockerfile):
php/Dockerfile (required; Compose builds this).FROM php:8.2-fpm-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libpng-dev libjpeg62-turbo-dev libfreetype6-dev libicu-dev \
unzip curl less mariadb-client \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) mysqli opcache zip gd intl bcmath exif \
&& rm -rf /var/lib/apt/lists/*
# wp-cli binary only (this is NOT the official WordPress app image)
COPY --from=wordpress:cli-2.10 /usr/local/bin/wp /usr/local/bin/wp
WORKDIR /var/www/html
# Do NOT set USER www-data — php-fpm master must start as root, then drops workers
# Bash / Git Bash / WSL:
mkdir -p varnish-wp/{certbot/{conf,www},nginx,varnish,php,wordpress/wp-content/mu-plugins}
cd varnish-wp
# PowerShell (Windows host):
# New-Item -ItemType Directory -Force certbot\conf, certbot\www, nginx, varnish, php, wordpress\wp-content\mu-plugins | Out-Null
.env (copy from this example; set secrets).# Site / MariaDB (used by MariaDB container; copy same values into wp-config.php) DOMAIN=shop.example.com MARIADB_ROOT_PASSWORD=change_me_root_password MARIADB_DATABASE=wordpress MARIADB_USER=wpuser MARIADB_PASSWORD=change_me_strong_password # Reminder only — PHP reads these from wp-config.php defines, not from this file # VARNISH_HOST=varnish VARNISH_PORT=6081 VARNISH_PURGE_SECRET=…
docker-compose.yml (pinned images; edit domains/secrets).services:
nginx-edge:
image: nginx:1.27-alpine
container_name: wp-nginx-edge
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/edge.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
varnish:
condition: service_healthy
networks:
- wp-net
restart: unless-stopped
varnish:
image: varnish:7.5.0-alpine
container_name: wp-varnish
expose:
- "6081"
volumes:
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
command:
- varnishd
- -F
- -a
- :6081
- -f
- /etc/varnish/default.vcl
- -s
- malloc,256m
healthcheck:
test: ["CMD", "varnishadm", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
depends_on:
nginx-origin:
condition: service_started
networks:
- wp-net
restart: unless-stopped
nginx-origin:
image: nginx:1.27-alpine
container_name: wp-nginx-origin
expose:
- "8080"
volumes:
- ./nginx/origin.conf:/etc/nginx/conf.d/default.conf:ro
- ./wordpress:/var/www/html:ro
depends_on:
php-fpm:
condition: service_started
networks:
- wp-net
restart: unless-stopped
# Not the official WordPress image — build php/Dockerfile (PHP-FPM + wp-cli).
# WordPress core/theme/plugin files live in ./wordpress on the host.
php-fpm:
build:
context: ./php
image: local/php-fpm-wpcli:8.2
container_name: wp-php-fpm
working_dir: /var/www/html
volumes:
- ./wordpress:/var/www/html
depends_on:
mariadb:
condition: service_healthy
networks:
- wp-net
restart: unless-stopped
mariadb:
image: mariadb:11.4.3-noble
container_name: wp-mariadb
env_file:
- .env
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- wp-net
restart: unless-stopped
certbot:
image: certbot/certbot:v2.11.0
container_name: wp-certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done'
networks:
- wp-net
restart: unless-stopped
networks:
wp-net:
driver: bridge
volumes:
mariadb_data:
varnish/default.vcl, nginx, and mu-plugin
Create these files now (same content as Part 35). Compose already mounts them.
wordpress/wp-config.php (required on plain PHP-FPM)
Because the app container is not the official WordPress image, Compose does not inject WORDPRESS_* env vars. Edit wp-config.php in the mounted tree:
wordpress/wp-config.php (above “That’s all, stop editing!”).// Behind nginx-edge TLS
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
define('WP_HOME', 'https://shop.example.com');
define('WP_SITEURL', 'https://shop.example.com');
define('DB_NAME', 'wordpress');
define('DB_USER', 'wpuser');
define('DB_PASSWORD', 'change_me_strong_password');
define('DB_HOST', 'mariadb');
// Used by mu-plugins/varnish-purge.php — HTTP to the varnish container (not VCL syntax)
define('VARNISH_HOST', 'varnish');
define('VARNISH_PORT', 6081);
define('VARNISH_PURGE_SECRET', 'change_me_purge_secret_min_32_chars');
varnish/default.vcl
varnish/default.vcl (Varnish 7.x, VCL 4.1; edit purge secret).vcl 4.1;
import std;
import directors;
# Target: Varnish Cache 7.x — VCL 4.1 syntax
# Backend: nginx-origin (WordPress origin, not public)
acl purge {
"localhost";
"127.0.0.1";
"::1";
"10.0.0.0"/8;
"172.16.0.0"/12;
"192.168.0.0"/16;
}
backend origin {
.host = "nginx-origin";
.port = "8080";
.connect_timeout = 5s;
.first_byte_timeout = 60s;
.between_bytes_timeout = 60s;
.max_connections = 300;
.probe = {
.url = "/healthz";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
.expected_response = 200;
}
}
sub vcl_init {
new vdir = directors.round_robin();
vdir.add_backend(origin);
}
sub vcl_recv {
set req.backend_hint = vdir.backend();
# Normalize Host — reject missing/invalid Host early
if (!req.http.Host) {
return (synth(400, "Bad Request"));
}
# PURGE — ACL + secret header (never public)
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return (synth(403, "Forbidden"));
}
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
return (purge);
}
# BAN — pattern invalidation from trusted networks only
if (req.method == "BAN") {
if (!client.ip ~ purge) {
return (synth(403, "Forbidden"));
}
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
if (req.http.X-Ban-Url) {
std.ban("req.url ~ " + req.http.X-Ban-Url);
return (synth(200, "Ban added"));
}
if (req.http.X-Ban-Host) {
std.ban("req.http.host == \"" + req.http.X-Ban-Host + "\"");
return (synth(200, "Ban added"));
}
return (synth(400, "Missing X-Ban-Url or X-Ban-Host"));
}
# Only cache GET/HEAD
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Never cache authenticated / admin surfaces
if (req.url ~ "^/(wp-admin|wp-login\.php|xmlrpc\.php|wp-cron\.php)") {
return (pass);
}
# WooCommerce AJAX and account routes — always pass (adapt to your permalinks)
if (req.url ~ "wc-ajax=") {
return (pass);
}
if (req.url ~ "^/(cart|checkout|my-account|wc-api|wp-json/wc/store)(/|\?|$)") {
return (pass);
}
if (req.url ~ "add-to-cart=|remove_item=|apply_coupon=|update_cart=") {
return (pass);
}
# REST/AJAX with private data
if (req.url ~ "^/wp-admin/admin-ajax\.php" || req.url ~ "^/wp-json/") {
return (pass);
}
# Session / auth cookies — bypass (correctness over HIT ratio)
if (req.http.Cookie ~ "(wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_)") {
return (pass);
}
# Optional: strip tracking cookies only (document risk before enabling)
# unset req.http.Cookie;
# Authorization must never hit shared cache
if (req.http.Authorization) {
return (pass);
}
# Static assets — long TTL at Varnish layer (origin still sets headers)
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
unset req.http.Cookie;
return (hash);
}
# Public HTML — cacheable for anonymous visitors
unset req.http.Cookie;
return (hash);
}
sub vcl_backend_response {
# Do not cache Set-Cookie responses (except static where cookie already stripped)
if (beresp.http.Set-Cookie) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Respect origin no-store / private
if (beresp.http.Cache-Control ~ "(private|no-store|no-cache)" ||
beresp.http.Vary ~ "Cookie") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Static assets
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
set beresp.ttl = 7d;
set beresp.grace = 1h;
set beresp.keep = 6h;
return (deliver);
}
# Product/inventory-sensitive pages — short TTL (tune per site)
if (bereq.url ~ "^/product/|^/shop/|^/\?post_type=product") {
set beresp.ttl = 120s;
set beresp.grace = 30s;
set beresp.keep = 60s;
return (deliver);
}
# General public pages
if (beresp.status == 200 && (bereq.method == "GET" || bereq.method == "HEAD")) {
set beresp.ttl = 300s;
set beresp.grace = 60s;
set beresp.keep = 120s;
}
if (beresp.status >= 500) {
set beresp.ttl = 0s;
set beresp.grace = 30s;
}
return (deliver);
}
sub vcl_hit {
if (obj.ttl >= 0s) {
return (deliver);
}
if (obj.ttl + obj.grace > 0s) {
return (deliver);
}
# Object past TTL+grace — fetch fresh and allow re-store (not pass)
return (miss);
}
sub vcl_deliver {
if (obj.uncacheable) {
set resp.http.X-Cache = "PASS";
} elsif (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
unset resp.http.X-Varnish;
unset resp.http.Via;
return (deliver);
}
nginx/edge.conf
nginx/edge.conf (TLS :443 → Varnish :6081; Certbot paths).# nginx/edge.conf — TLS termination, proxy to Varnish (HTTP only to cache tier)
server {
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 64m;
location / {
proxy_pass http://varnish:6081;
proxy_http_version 1.1;
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 https;
proxy_set_header Connection "";
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
nginx/origin.conf
nginx/origin.conf (FastCGI to PHP-FPM; /healthz probe).# nginx/origin.conf — origin server for WordPress/PHP-FPM (not exposed publicly)
map $http_x_forwarded_proto $fastcgi_https {
default off;
https on;
}
upstream php-fpm {
server php-fpm:9000;
}
server {
listen 8080;
server_name shop.example.com;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php-fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS $fastcgi_https;
fastcgi_read_timeout 120s;
}
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
location ~ /\. {
deny all;
}
}
wordpress mu-plugin (required — full beginner walkthrough in Part 19)
wordpress/wp-content/mu-plugins/varnish-purge.php (automated PURGE/BAN on save/stock change).<?php
/**
* Plugin Name: Varnish Purge on Save
* Description: PURGE/BAN Varnish when WordPress/WooCommerce content changes.
* Must-use plugin — drop in wp-content/mu-plugins/
*/
if (!defined('ABSPATH')) {
exit;
}
// Prefer constants from wp-config.php (this stack is plain PHP-FPM, not official WP image)
define('VPURGE_HOST', defined('VARNISH_HOST') ? VARNISH_HOST : 'varnish');
define('VPURGE_PORT', defined('VARNISH_PORT') ? (int) VARNISH_PORT : 6081);
define('VPURGE_SECRET', defined('VARNISH_PURGE_SECRET') ? VARNISH_PURGE_SECRET : 'change_me_purge_secret_min_32_chars');
function vpurge_request(string $method, string $path, array $extra_headers = []): void {
$fp = @fsockopen(VPURGE_HOST, VPURGE_PORT, $errno, $errstr, 2.0);
if (!$fp) {
error_log("Varnish purge failed: $errstr ($errno)");
return;
}
$host = parse_url(home_url(), PHP_URL_HOST) ?: 'localhost';
$headers = array_merge([
"Host: $host",
'X-Purge-Secret: ' . VPURGE_SECRET,
'Connection: close',
], $extra_headers);
$req = "$method $path HTTP/1.1\r\n" . implode("\r\n", $headers) . "\r\n\r\n";
fwrite($fp, $req);
fclose($fp);
}
function vpurge_url(string $url_path): void {
vpurge_request('PURGE', $url_path);
}
function vpurge_ban_pattern(string $pattern): void {
vpurge_request('BAN', '/', ['X-Ban-Url: ' . $pattern]);
}
function vpurge_on_save(int $post_id, WP_Post $post): void {
if (wp_is_post_revision($post_id) || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE)) {
return;
}
// PURGE the public URL only when the object is live
if ($post->post_status === 'publish') {
$permalink = wp_make_link_relative(get_permalink($post_id));
vpurge_url($permalink ?: '/');
}
// BAN listings even on unpublish/draft so archives drop the old HTML
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('save_post', 'vpurge_on_save', 10, 2);
function vpurge_on_delete(int $post_id): void {
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('deleted_post', 'vpurge_on_delete');
add_action('wp_trash_post', 'vpurge_on_delete');
function vpurge_on_stock_change($product): void {
if (!is_a($product, 'WC_Product')) {
return;
}
$permalink = wp_make_link_relative(get_permalink($product->get_id()));
vpurge_url($permalink ?: '/');
vpurge_ban_pattern('^/shop/');
}
function vpurge_on_wc_product_id($product_id): void {
if (!function_exists('wc_get_product')) {
return;
}
$product = wc_get_product($product_id);
if ($product) {
vpurge_on_stock_change($product);
}
}
// Mu-plugins load BEFORE normal plugins — WooCommerce class is not ready yet.
// Register Woo hooks on plugins_loaded so class_exists('WooCommerce') is true.
function vpurge_register_woo_hooks(): void {
if (!class_exists('WooCommerce')) {
return;
}
add_action('woocommerce_product_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_variation_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_update_product', 'vpurge_on_wc_product_id');
}
add_action('plugins_loaded', 'vpurge_register_woo_hooks', 20);
HASH (cache): anonymous GET/HEAD of public HTML (home, posts, product, shop, category) and static assets (
.css .js .jpg …) after session cookies are absent.PASS (never cache):
wp-admin, wp-login.php, xmlrpc.php, wp-cron.php, wc-ajax, /cart/ /checkout/ /my-account/, Woo REST, admin-ajax.php, /wp-json/, any request with wordpress_logged_in_, Woo session/cart cookies, or Authorization.PURGE/BAN: only from Docker/private ACL +
X-Purge-Secret.Before Part 9 — build image, TLS lab cert, WordPress core
Part 9 runs docker compose up. Do these on the host first or the stack will fail (missing image, missing certs, empty ./wordpress).
docker compose build php-fpm # Or: docker build -t local/php-fpm-wpcli:8.2 ./php
mkdir -p certbot/conf/live/shop.example.com openssl req -x509 -nodes -newkey rsa:2048 -days 365 \ -keyout certbot/conf/live/shop.example.com/privkey.pem \ -out certbot/conf/live/shop.example.com/fullchain.pem \ -subj "/CN=shop.example.com" # edge.conf already points at these Let's Encrypt-style paths
./wordpress if the folder is empty.# After MariaDB is up you can also: docker compose run --rm php-fpm wp core download --allow-root
# Offline / first time on host:
docker run --rm -v "${PWD}/wordpress:/var/www/html" local/php-fpm-wpcli:8.2 \
wp core download --allow-root --path=/var/www/html
# Then copy wp-config.php defines (Part 8 / Part 19) and mu-plugin before relying on purge.
php-fpm → lab certs → WordPress core + wp-config.php → docker compose up (Part 9). Finish purge wiring with Part 19 Steps A–H if you have not already.Part 9 — Installing Varnish
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
docker run --rm -v "${PWD}/varnish/default.vcl:/etc/varnish/default.vcl:ro" varnish:7.5.0-alpine varnishd -C -f /etc/varnish/default.vcl
Sample output — varnishd -C -f /etc/varnish/default.vcl (excerpt)
/* compiled VCL C source omitted */ VCL compiled.
Meaning: Varnish parsed VCL 4.1 and generated C; the long generated source is expected and may be omitted from runbooks. Expected ';', Symbol not found, or a non-zero exit means do not deploy—fix the referenced VCL line/import first.
docker compose --env-file .env up -d --build docker compose ps docker compose logs varnish --tail 50 docker compose logs php-fpm --tail 50
Sample output — docker compose ps (healthy stack)
NAME IMAGE STATUS wp-mariadb mariadb:11.4.3-noble Up (healthy) wp-php-fpm local/php-fpm-wpcli:8.2 Up wp-nginx-origin nginx:1.27-alpine Up wp-varnish varnish:7.5.0-alpine Up (healthy) wp-nginx-edge nginx:1.27-alpine Up
Meaning: Varnish healthcheck is varnishadm ping — healthy means varnishd is running and VCL compiled. If varnish is unhealthy or restarting, run docker compose logs varnish; a VCL compile error prints Error: VCL compilation failed and the container will not stay up. Edge Nginx waits on Varnish health, so a bad VCL also blocks :443.
Varnish 7.x uses VCL 4.1. The official image entrypoint runs varnishd -F with mounted default.vcl. Healthcheck uses varnishadm ping.
Part 10 — Connecting Varnish to Nginx
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Edge Nginx proxies to varnish:6081 and sets:
X-Forwarded-For— client IP chainX-Forwarded-Proto: https— so WordPress generates HTTPS URLsHost— must match your site domain
Origin Nginx passes the FastCGI HTTPS parameter from X-Forwarded-Proto so WordPress does not think the site is HTTP (avoid redirect loops and mixed-content).
fastcgi_param HTTPS $http_x_forwarded_proto; sends the literal string https, but WordPress is_ssl() expects on. Use an Nginx map (shown in Part 35 origin.conf).Part 11 — Basic VCL
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
VCL 4.1 structure: backend definition, vcl_recv (decide hash/pass/purge), vcl_backend_response (TTL/grace), vcl_deliver (X-Cache headers). Full file is in Part 8 (and again in Part 35).
X-Cache: HIT|MISS|PASS and X-Cache-Hits help verify behavior without guessing.Part 12 — WordPress caching
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Cache anonymous GET/HEAD for public pages. Bypass wp-admin, wp-login.php, xmlrpc.php, wp-cron.php. Respect Cache-Control: private and Set-Cookie from origin — do not override blindly.
This is the production-recommended vcl_recv decision block (aligned with Part 8 / Part 35; the full file also has X-Ban-Host and backend/ACL sections). Merge into varnish/default.vcl — do not run this fragment alone.
vcl_recv (cache vs pass). Merge into varnish/default.vcl.sub vcl_recv {
set req.backend_hint = vdir.backend();
if (!req.http.Host) {
return (synth(400, "Bad Request"));
}
# Invalidation — never from the public internet without ACL+secret
if (req.method == "PURGE") {
if (!client.ip ~ purge) { return (synth(403, "Forbidden")); }
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
return (purge);
}
if (req.method == "BAN") {
if (!client.ip ~ purge) { return (synth(403, "Forbidden")); }
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
if (req.http.X-Ban-Url) {
std.ban("req.url ~ " + req.http.X-Ban-Url);
return (synth(200, "Ban added"));
}
return (synth(400, "Missing X-Ban-Url"));
}
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Never cache WordPress admin / login / cron / xmlrpc
if (req.url ~ "^/(wp-admin|wp-login\.php|xmlrpc\.php|wp-cron\.php)") {
return (pass);
}
# WooCommerce — cart, checkout, account, ajax, store API
if (req.url ~ "wc-ajax=") { return (pass); }
if (req.url ~ "^/(cart|checkout|my-account|wc-api|wp-json/wc/store)(/|\?|$)") {
return (pass);
}
if (req.url ~ "add-to-cart=|remove_item=|apply_coupon=|update_cart=") {
return (pass);
}
# REST + admin-ajax — private data (safe default: pass all)
if (req.url ~ "^/wp-admin/admin-ajax\.php" || req.url ~ "^/wp-json/") {
return (pass);
}
# Logged-in, cart, Woo session — never share cache
if (req.http.Cookie ~ "(wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_)") {
return (pass);
}
if (req.http.Authorization) {
return (pass);
}
# Anonymous static + public HTML
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
unset req.http.Cookie;
return (hash);
}
unset req.http.Cookie;
return (hash);
}
/wp-json/ tradeoff: Our VCL passes all REST (safe for WooCommerce Store API). If you need to cache public wp-json/wp/v2/posts for headless frontends, add narrow hash rules on staging — never cache authenticated or cart-related routes.Static assets (CSS, JS, images, fonts)
Versioned filenames (app.a1b2c3.js) allow long TTL at Varnish and browser. Unversioned assets need shorter TTL or purge on deploy. Strip cookies for static extensions in vcl_recv only when the origin never returns personalized assets for those URLs — a .js URL that returns user-specific JSON must still pass.
| Asset type | Varnish TTL (example) | Notes |
|---|---|---|
| CSS/JS (versioned) | 7d | Immutable if hash in filename |
| Images WebP/AVIF | 7d | Purge on media replace |
| Fonts woff2 | 30d | CORS headers from origin |
Serve static files with long TTL at Varnish when URLs are versioned (app.js?ver=6.6.2 or hashed filenames). Browser cache and Varnish cache are complementary: Varnish protects origin; browsers reduce repeat downloads. Do not cache .js/.css responses that carry Set-Cookie or dynamic Cache-Control: private — extension and plugin endpoints sometimes masquerade as static paths.
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
unset req.http.Cookie;
return (hash);
}
Part 13 — WooCommerce safety
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Always return (pass) for commerce-critical routes. Adapt paths to your permalink structure — verify with curl on staging, do not assume defaults.
| Route / signal | Cache? | Why |
|---|---|---|
/cart/, /checkout/, /my-account/ | PASS | Session-bound HTML |
/?wc-ajax=, /wp-admin/admin-ajax.php | PASS | Cart fragments, checkout updates |
add-to-cart=, coupons, cart query args | PASS | Mutates session state |
woocommerce_items_in_cart cookie | PASS | User has cart contents |
wp_woocommerce_session_ cookie | PASS | Woo session identifier |
| Payment gateway callbacks (PayPal, Stripe, etc.) | PASS | Verify your gateway URL patterns explicitly |
| Public product/category/shop (anonymous) | Cacheable | Short TTL + purge on stock/price change |
Part 14 — Cookies and sessions
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Cookies drive cache variance. Dangerous extremes:
if (req.http.Cookie) { return (pass); }— safe but zero cache for tracking cookies- Strip all cookies and cache everything — session leakage risk
Production strategy: pass on wordpress_logged_in_, woocommerce_items_in_cart, wp_woocommerce_session_; optionally strip analytics cookies only after verifying they never affect HTML.
| Cookie pattern | Action |
|---|---|
wordpress_logged_in_* | PASS — authenticated user |
wp_woocommerce_session_* | PASS — WooCommerce session |
woocommerce_items_in_cart, woocommerce_cart_hash | PASS — cart state |
comment_author_*, wp-postpass_* | PASS — private/comment flows |
_ga, _gid, marketing pixels | Optional strip — only after A/B proof HTML is identical |
Part 15 — Product and inventory correctness
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Varnish caches HTTP responses, not database rows. If product pages show stock counts, cached pages can show stale inventory.
| Strategy | Pros | Cons |
|---|---|---|
| Short TTL on product/shop URLs | Simple; no app changes | Staleness window remains; higher origin load |
| PURGE on single product URL | Precise after edit | Category/shop pages still stale unless BAN |
BAN patterns (^/shop/, ^/product/) | Invalidates listing pages | Regex mistakes are dangerous — test patterns |
| PASS when stock shown in HTML | Maximum correctness | Low HIT ratio on product pages |
| App-driven invalidation (mu-plugin) | Automated on save/stock hooks | Must secure PURGE/BAN; monitor failures |
| Event/webhook from ERP/WMS | External stock truth | More moving parts; idempotency required |
Recommendation for most shops: short TTL (60–120s) on product/shop + automated PURGE/BAN on stock/price change. Correctness beats HIT ratio.
Part 16 — Cache keys
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Default key: Host + URL (including query string). Over-varying (every cookie) destroys HIT ratio. Under-varying (ignore auth cookies) leaks private content. Match key variance to content variance.
| Input | In default hash? | WooCommerce note |
|---|---|---|
Host | Yes | Must match site domain in edge Nginx |
| URL path + query | Yes | ?add-to-cart= should PASS before hash |
Cookie | Only if not stripped / varied | We PASS on session cookies; strip tracking only after proof |
Authorization | Yes if present | Always PASS in our VCL |
Vary: Cookie from origin | Forces per-cookie objects | Fix origin or PASS — do not fight Vary blindly |
Query strings
UTM and tracking params (utm_source, fbclid) fragment the cache — one page becomes hundreds of keys. Safe normalization removes tracking params only after verifying they never change HTML output. Never strip functional params (?add-to-cart=, ?s= search, pagination ?page=) without testing.
# Pseudocode — test every stripped param on staging first # if url has only utm_* → normalize to path-only for hash # if url has add-to-cart → return (pass)
Part 17 — PURGE
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
PURGE removes the object for an exact URL. Use after single post/product update. Protect with ACL + X-Purge-Secret; never expose unrestricted PURGE to the internet.
| PURGE | BAN (std.ban()) | |
|---|---|---|
| Scope | One exact URL + Host | Pattern / regex on metadata |
| Speed | Immediate object removal | Lazy at lookup time |
| Best for | Single product/post updated | Shop/category pages, many URLs |
| Risk | Wrong URL → object remains | Over-broad regex → cache churn |
Part 18 — BAN
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
BAN (via std.ban() in Varnish 7) invalidates by regex pattern — e.g. all /product/ URLs when one product changes. Lazy: objects checked at lookup time. Use for category/shop pages affected by many products.
Part 19 — WordPress invalidation (beginner walkthrough)
What you should do: follow Steps A→H in order. Do not skip the test at the end.
This part is only about the WordPress / php-fpm side. You do not write VCL here. VCL already lives in the varnish container (Part 8). Here you only: (1) put three settings in wp-config.php, (2) add one small PHP file under mu-plugins, (3) prove it works.
php-fpm never runs VCL. It only opens a network connection to varnish:6081 and sends a short HTTP message that says “please purge this URL”. Varnish reads that message and runs its own VCL.Checklist — what “done” looks like
wordpress/wp-config.phpdefinesVARNISH_HOST,VARNISH_PORT,VARNISH_PURGE_SECRET(same secret as invarnish/default.vcl).- File exists:
wordpress/wp-content/mu-plugins/varnish-purge.php. - From
php-fpm, a manual PURGE returns200 Purged. - You edit a published product in wp-admin → next anonymous visit is a cache MISS (or fresh content), not yesterday’s HTML.
Step A — Make sure WordPress files are on disk
Your Compose stack mounts ./wordpress into the php-fpm container at /var/www/html. On the host machine (where you run docker compose), you should already see a normal WordPress tree:
varnish-wp/wordpress/
├── wp-config.php ← you will edit this
├── wp-admin/
├── wp-includes/
├── index.php
└── wp-content/
├── plugins/
├── themes/
└── mu-plugins/ ← you will create this if missing
└── varnish-purge.php ← you will create this file
varnish-wp project folder.pwd ls wordpress/wp-config.php ls wordpress/wp-content mkdir -p wordpress/wp-content/mu-plugins ls -la wordpress/wp-content/mu-plugins
Sample output — pwd and WordPress path inspection
/opt/varnish-wp wordpress/wp-config.php wordpress/wp-content total 8 drwxr-xr-x 2 user user 4096 Aug 30 12:00 . drwxr-xr-x 5 user user 4096 Aug 30 12:00 ..
Meaning: mu-plugins exists and is empty (or will soon contain your purge file). If wp-config.php is missing, WordPress is not installed in ./wordpress yet — finish core install (download WordPress / restore backup / wp core download via wp-cli) before continuing.
Optional with wp-cli (same container image that has wp-cli):
docker compose exec php-fpm wp core is-installed --allow-root docker compose exec php-fpm wp option get siteurl --allow-root
Sample output — wp core is-installed and wp option get siteurl
https://shop.example.com
Meaning: WordPress boots. The hostname here must match the Host header used in cache keys and in PURGE tests below.
Step B — Put Varnish settings in wp-config.php
Open wordpress/wp-config.php in an editor on the host. Find the line that says something like:
/* That's all, stop editing! Happy publishing. */
Above that line (never below it), paste the block below. Replace the domain and the secret with your real values. The secret must be character-for-character identical to the string in varnish/default.vcl where it checks X-Purge-Secret.
wordpress/wp-config.php (above “That’s all, stop editing!”).// --- TLS behind nginx-edge ---
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
define('WP_HOME', 'https://shop.example.com');
define('WP_SITEURL', 'https://shop.example.com');
// --- Database (must match MariaDB service / .env) ---
define('DB_NAME', 'wordpress');
define('DB_USER', 'wpuser');
define('DB_PASSWORD', 'change_me_strong_password');
define('DB_HOST', 'mariadb');
// --- Varnish purge (read by mu-plugin; NOT VCL code) ---
// VARNISH_HOST = Docker Compose service name of the varnish container
define('VARNISH_HOST', 'varnish');
define('VARNISH_PORT', 6081);
// Must match varnish/default.vcl exactly:
define('VARNISH_PURGE_SECRET', 'change_me_purge_secret_min_32_chars');
DB_NAME / DB_USER / … already exist higher in the same file, do not duplicate them — only add the three VARNISH_* lines (and HTTPS / WP_HOME if missing). Duplicate define() causes a PHP fatal error and the site goes white-screen.varnish/default.vcl, search for X-Purge-Secret, copy that string into VARNISH_PURGE_SECRET. If they differ by one character, every purge returns 403 and the cache never clears.After saving, no Compose restart is required for PHP file changes on a bind mount — the next HTTP request to PHP will read the new wp-config.php. If you use OPcache with a long revalidate time and still see old behaviour, restart once:
docker compose restart php-fpm
Step C — Create the must-use plugin file
A must-use plugin (mu-plugin) is a PHP file in wp-content/mu-plugins/. WordPress loads it on every request automatically. You do not activate it in wp-admin → Plugins. That is intentional: nobody can turn off cache purge by accident.
Create the file on the host (path relative to the Compose project):
mkdir -p wordpress/wp-content/mu-plugins # Linux/macOS: touch wordpress/wp-content/mu-plugins/varnish-purge.php # Windows PowerShell: # New-Item -ItemType File -Force wordpress\wp-content\mu-plugins\varnish-purge.php
Paste the entire PHP below into that file and save. Do not leave HTML or Markdown around it — the first characters must be <?php.
wordpress/wp-content/mu-plugins/varnish-purge.php (full file).<?php
/**
* Plugin Name: Varnish Purge on Save
* Description: When content changes, send HTTP PURGE/BAN to the Varnish container.
* Must-use plugin — place in wp-content/mu-plugins/ (no activation in wp-admin).
*/
if (!defined('ABSPATH')) {
exit;
}
// Values come from wp-config.php define('VARNISH_…')
define('VPURGE_HOST', defined('VARNISH_HOST') ? VARNISH_HOST : 'varnish');
define('VPURGE_PORT', defined('VARNISH_PORT') ? (int) VARNISH_PORT : 6081);
define('VPURGE_SECRET', defined('VARNISH_PURGE_SECRET') ? VARNISH_PURGE_SECRET : 'change_me_purge_secret_min_32_chars');
/**
* Open TCP to varnish:6081 and write one HTTP request.
* This is plain HTTP. It is NOT VCL. Varnish will run VCL after it receives this.
*/
function vpurge_request(string $method, string $path, array $extra_headers = []): void {
$fp = @fsockopen(VPURGE_HOST, VPURGE_PORT, $errno, $errstr, 2.0);
if (!$fp) {
error_log("Varnish purge failed: $errstr ($errno)");
return;
}
$host = parse_url(home_url(), PHP_URL_HOST) ?: 'localhost';
$headers = array_merge([
"Host: $host",
'X-Purge-Secret: ' . VPURGE_SECRET,
'Connection: close',
], $extra_headers);
$req = "$method $path HTTP/1.1\r\n" . implode("\r\n", $headers) . "\r\n\r\n";
fwrite($fp, $req);
fclose($fp);
}
function vpurge_url(string $url_path): void {
// Exact URL purge, e.g. /product/blue-shirt/
vpurge_request('PURGE', $url_path);
}
function vpurge_ban_pattern(string $pattern): void {
// Pattern invalidation, e.g. all URLs matching ^/shop/
vpurge_request('BAN', '/', ['X-Ban-Url: ' . $pattern]);
}
function vpurge_on_save(int $post_id, WP_Post $post): void {
// Ignore autosave / revisions (editor heartbeat would spam PURGE)
if (wp_is_post_revision($post_id) || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE)) {
return;
}
if ($post->post_status === 'publish') {
$permalink = wp_make_link_relative(get_permalink($post_id));
vpurge_url($permalink ?: '/');
}
// Listing pages still show old cards without BAN
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('save_post', 'vpurge_on_save', 10, 2);
function vpurge_on_delete(int $post_id): void {
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('deleted_post', 'vpurge_on_delete');
add_action('wp_trash_post', 'vpurge_on_delete');
function vpurge_on_stock_change($product): void {
if (!is_a($product, 'WC_Product')) {
return;
}
$permalink = wp_make_link_relative(get_permalink($product->get_id()));
vpurge_url($permalink ?: '/');
vpurge_ban_pattern('^/shop/');
}
function vpurge_on_wc_product_id($product_id): void {
if (!function_exists('wc_get_product')) {
return;
}
$product = wc_get_product($product_id);
if ($product) {
vpurge_on_stock_change($product);
}
}
// Mu-plugins load BEFORE normal plugins — WooCommerce class is not ready yet.
// Register Woo hooks on plugins_loaded so class_exists('WooCommerce') is true.
function vpurge_register_woo_hooks(): void {
if (!class_exists('WooCommerce')) {
return;
}
add_action('woocommerce_product_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_variation_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_update_product', 'vpurge_on_wc_product_id');
}
add_action('plugins_loaded', 'vpurge_register_woo_hooks', 20);
Step D — Verify WordPress loaded the mu-plugin
docker compose exec php-fpm ls -la /var/www/html/wp-content/mu-plugins/ docker compose exec php-fpm wp plugin list --status=must-use --allow-root
Sample output — ls and wp plugin list --status=must-use
-rw-r--r-- 1 www-data www-data 3200 Aug 30 12:10 varnish-purge.php name status update version varnish-purge must-use none
Meaning: the file is inside the container and WordPress registered it as must-use. If ls is empty, the host path is wrong or the volume is not mounted. If the file exists but wp plugin list fails, fix WordPress / DB first.
You will not see this plugin under wp-admin → Plugins as an activatable row. That is normal for mu-plugins.
Step E — What each hook does (plain language)
WordPress calls functions you register with add_action(...) when something happens. Our plugin listens for:
| When you do this in wp-admin | WordPress hook | What our PHP file does |
|---|---|---|
| Publish or Update a post / page / product | save_post |
Sends HTTP PURGE for that page URL, then HTTP BAN for shop/product/category/home patterns |
| Change product stock | woocommerce_product_set_stock / variation stock |
PURGE the product URL + BAN ^/shop/ |
| Trash / delete a product | wp_trash_post / deleted_post |
BAN listing patterns so archives drop the old card |
/shop/ (for example WooCommerce → Settings → Permalinks uses /store/), edit every vpurge_ban_pattern('^/shop/') in the PHP file to ^/store/ (or whatever path you use). Wrong pattern = listings stay stale.Step F — Manual network test (before using wp-admin)
Prove that php-fpm can talk to varnish on the Docker network. This uses the same kind of HTTP bytes the plugin sends. Install nc in the image if missing, or use the one-liner below.
docker compose exec php-fpm sh -c 'printf "PURGE / HTTP/1.1\r\nHost: shop.example.com\r\nX-Purge-Secret: change_me_purge_secret_min_32_chars\r\nConnection: close\r\n\r\n" | nc -w 2 varnish 6081 || true'
# If nc is missing in your image:
docker compose exec php-fpm php -r '
$fp = fsockopen("varnish", 6081, $e, $s, 2);
if (!$fp) { fwrite(STDERR, "$s\n"); exit(1); }
fwrite($fp, "PURGE / HTTP/1.1\r\nHost: shop.example.com\r\nX-Purge-Secret: change_me_purge_secret_min_32_chars\r\nConnection: close\r\n\r\n");
echo stream_get_contents($fp);
fclose($fp);
'
Sample output — manual PHP-FPM-to-Varnish PURGE /
HTTP/1.1 200 Purged Date: Sun, 30 Aug 2026 10:00:00 GMT X-Varnish: 32770 Age: 0
Meaning: varnishd accepted the request; ACL + secret matched. Next fix whatever fails:
| Result | Cause | What you do |
|---|---|---|
200 Purged | OK | Continue to Step G |
403 Forbidden | Wrong secret or IP not in VCL ACL | Match secret in wp-config ↔ VCL; keep both containers on wp-net |
Connection refused / timeout | Wrong host/port or varnish down | docker compose ps; host must be varnish not the public domain |
| Empty / hang | nc missing or firewall | Use the php -r variant above |
Step G — End-to-end test with wp-admin (the real goal)
Do this on staging first. Replace the product URL with one that exists on your shop.
- Warm the cache (anonymous): from a private/incognito window (logged out), open a product page twice.
curl -sI https://shop.example.com/product/sample-product/ | grep -E 'HTTP/|X-Cache' curl -sI https://shop.example.com/product/sample-product/ | grep -E 'HTTP/|X-Cache'
Sample output — two anonymous product-header requests
HTTP/2 200 X-Cache: MISS HTTP/2 200 X-Cache: HIT
Meaning: Varnish is caching that URL. If you never see X-Cache, you are not hitting this VCL stack (wrong DNS / CDN in front).
- Log into wp-admin → Products → open that product → change the title or short description → click Update.
- Watch logs in another terminal while you click Update:
docker compose exec varnish varnishlog -g request -q 'ReqMethod eq PURGE or ReqMethod eq BAN'
Sample output — varnishlog filtered to PURGE/BAN (excerpt)
* << Request >> 32774 - ReqMethod PURGE - ReqURL /product/sample-product/ - ReqHeader Host: shop.example.com - RespStatus 200
Meaning: request ID 32774 reached Varnish as PURGE for the exact product/Host and returned 200. No record means the mu-plugin did not send, Docker DNS/network failed, or the filter is wrong; status 403 means ACL/secret mismatch.
- Still logged out / incognito, request the product again:
curl -sI https://shop.example.com/product/sample-product/ | grep -E 'HTTP/|X-Cache' curl -s https://shop.example.com/product/sample-product/ | grep -o 'YourNewTitleFragment' | head -1
Sample output — first product request after WordPress Update
HTTP/2 200 X-Cache: MISS YourNewTitleFragment
Meaning: purge removed the old object, the next request fetched a MISS, and the new title is present in the body. HIT plus old title means invalidation failed; MISS without new title points to WordPress/origin data rather than Varnish.
If title changed in HTML but X-Cache still says HIT with old body, purge did not run — check Step H.
- WooCommerce stock: change stock quantity on the same product → Update → confirm shop listing no longer shows the old “in stock” count (BAN of
^/shop/).
Step H — Troubleshooting (WordPress side only)
| Symptom | Check | Fix |
|---|---|---|
| Site white screen after editing wp-config | docker compose logs php-fpm --tail 50 |
Duplicate define() or syntax error — remove duplicate DB lines |
| Update product, cache still old | mu-plugin file path; varnishlog while saving | File must be exactly wp-content/mu-plugins/varnish-purge.php (not inside a subfolder) |
| varnishlog shows nothing on Update | PHP error_log for Varnish purge failed |
VARNISH_HOST wrong; containers not on same network; fsockopen blocked |
| varnishlog shows 403 | Secret in wp-config vs VCL | Make strings identical; restart varnish after VCL edit |
| Product OK, shop page still stale | Shop permalink slug | Change BAN pattern from ^/shop/ to your real path |
| PURGE storm / slow admin | Autosave every few seconds | Keep the revision/autosave guards in the plugin (do not delete those if lines) |
varnishadm or edit .vcl from inside the php-fpm container. Ops commands belong on the host or in docker compose exec varnish …. WordPress only sends HTTP PURGE/BAN.200 Purged, Step D lists the mu-plugin, and Step G shows fresh content after an Update without waiting for TTL.Part 20 — Grace and Keep
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Grace = serve stale after TTL while fetching or if backend unhealthy
Keep = retain idle object after TTL+grace before eviction
Grace helps during PHP-FPM spikes or deploys but can serve stale stock — keep product TTL short and grace modest (e.g. 30–60s). Availability vs freshness vs correctness: tune per route.
| Route type | TTL (example) | Grace | Keep | Notes |
|---|---|---|---|---|
| Static assets | 7d | 1h | 6h | Safe if filenames are versioned |
| Product/shop | 60–120s | 30s max | 60s | Do not use long grace — stale stock risk |
| Home/blog | 300s | 60s | 120s | Purge on publish |
| Cart/checkout | PASS | — | — | Never cache |
Part 21 — Backend health checks
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Probe /healthz on origin Nginx (lightweight 200). Avoid probing / if it hits PHP/DB. Parameters: interval 5s, timeout 2s, window 5, threshold 3.
docker compose exec varnish varnishadm backend.list # Healthy: origin ... Healthy ... probe ... 200 docker compose exec varnish varnishadm backend.set_health origin sick # Lab only — confirm edge still serves grace/HIT; then: docker compose exec varnish varnishadm backend.set_health origin auto
Sample output — varnishadm backend.list and temporary health override
Backend name Admin Probe Health Last change boot.origin probe 5/5 healthy Sun, 30 Aug 2026 16:31:04 GMT 200 0 200 0
Meaning: origin has 5/5 successful probes and is healthy; each 200 0 is varnishadm’s command status for forcing sick then restoring auto. Unhealthy probe details indicate origin DNS/port/status/timeout failure. Never leave a production backend forced sick.
Part 22 — Cache stampede
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
When 1000 clients request the same uncached URL, Varnish request coalescing sends one backend fetch; others wait. Without Varnish, 1000 PHP requests hit simultaneously (thundering herd).
# Varnish :6081 is NOT published on the host — warm via the public edge (HTTPS)
# or from a container on wp-net.
URLS="/ /shop/ /product/sample-product/"
for u in $URLS; do
curl -sk -o /dev/null "https://shop.example.com${u}"
done
# Lab alternative (same Docker network):
# docker compose exec nginx-edge wget -q -O /dev/null http://varnish:6081/shop/
Part 23 — Security hardening
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
- Do not publish Varnish :6081 or
varnishadmto the internet - ACL + secret for PURGE/BAN
- Validate Host header
- Strip or unset internal debug headers in hardened production if desired
- Internal Docker network only for origin
| Surface | Exposure | Rule |
|---|---|---|
| Varnish :6081 | Edge Nginx only | Never publish to public internet |
varnishadm | Container localhost | No host port mapping |
| PURGE/BAN | ACL private nets + secret | Mu-plugin uses internal Docker DNS |
| Origin :8080 | Docker network | Not in edge ports: |
Part 24 — Cache poisoning
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Risks: Host header manipulation, unkeyed headers affecting content, inconsistent query normalization. Defensive VCL: normalize Host, pass on Authorization, do not cache responses with Set-Cookie, reject missing Host.
# This VCL rejects *missing* Host, not a wrong Host on an already-TLS edge. # Poisoning test: confirm a forged Host does not return *your* shop HTML from cache. curl -sk -sI --resolve shop.example.com:443:127.0.0.1 -H "Host: evil.example.com" https://shop.example.com/ | grep -E 'HTTP/|X-Cache|content-type' # Expected: not a HIT of shop.example.com HTML (often edge 404/default server, or empty cache key for evil host)
Sample output — forged-Host cache-poisoning check
HTTP/2 404 content-type: text/html
Meaning: the edge rejected/routed the unrecognized Host and did not return an X-Cache: HIT for shop HTML. A 200 HIT with the shop body means Host validation/cache-key routing is unsafe; fix edge server_name and VCL Host policy before production.
Part 25 — Performance tuning
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Measure before tuning: hit ratio alone is not success if stock is wrong. Tune TTL by content type, backend timeouts, and thread pools per Varnish docs for 7.x.
| Knob | Start conservative | When to change |
|---|---|---|
| Product TTL | 120s | Lower if stock in HTML; raise only with reliable purge |
malloc size | 256m | Raise when n_lru_nuked grows in steady state |
| Backend timeouts | 60s | Match PHP-FPM max_execution_time |
| Pass rules | Wide (Woo-safe) | Narrow only per route with curl proof |
Part 26 — Memory tuning
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Storage: -s malloc,256m (example — size to working set). Too small → evictions; too large → wasted RAM. Monitor varnishstat for object count and OOM.
Docker resource limits
Set Compose deploy.resources.limits.memory (Swarm) or mem_limit with headroom above malloc size. CPU limits are optional; Varnish is rarely CPU-bound unless TLS (not our case) or huge regex ban lists. Under memory pressure, Linux OOM killer may restart Varnish — cache goes cold. ulimits: default is usually sufficient; raise file descriptors only if varnishstat shows dropped connections.
Part 27 — Observability
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
docker compose exec varnish varnishstat -1 docker compose exec varnish varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss -f MAIN.n_object docker compose exec varnish varnishlog -g request -i ReqURL,ReqMethod,VCL_call,RespStatus docker compose exec varnish varnishadm backend.list
Sample output — varnishstat, request log, and backend health (excerpt)
MAIN.cache_hit 18422 MAIN.cache_miss 2317 MAIN.n_object 1489 * ReqMethod GET ReqURL /shop/ RespStatus 200 boot.origin probe 5/5 healthy
Meaning: hit/miss are cumulative counters, n_object is current cached objects, the request trace ties method/URL/status together, and origin probes pass 5/5. Flat hits with rising misses suggests bypass/TTL/cookies; falling objects plus n_lru_nuked growth means storage pressure; unhealthy origin explains grace/503 behavior.
Ask: Why MISS? Why PASS? Why stale? Trace with varnishlog and check X-Cache response headers.
Metric (varnishstat) | Healthy signal | Action if bad |
|---|---|---|
MAIN.cache_hit / cache_miss | Hit ratio rises after warm-up | Review pass rules; cold cache after restart |
MAIN.n_object | Stable after warm-up | malloc too small → evictions; increase -s malloc |
MAIN.backend_busy | Low on catalog traffic | Too many MISS/PASS — tune TTL or fix origin slowness |
MAIN.n_lru_nuked | Near zero in steady state | Memory pressure — increase malloc or reduce TTL |
Part 28 — Logging
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Use varnishncsa for access-style logs: URL, status, hit/miss. Do not log full Cookie or Authorization headers. Log PURGE/BAN from ACL IPs for audit.
docker-compose.yml (access log to stdout).# Add alongside varnish service (same network namespace pattern):
varnish-log:
image: varnish:7.5.0-alpine
container_name: wp-varnish-log
command: ["sh", "-c", "varnishncsa -a -w /dev/stdout -F \"%{Varnish:handling}x %r %s %b\""]
network_mode: "service:varnish"
depends_on:
varnish:
condition: service_healthy
Part 29 — Testing
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Complete test plan with curl. Replace domain and secrets.
curl -sI https://shop.example.com/product/sample-product/ | grep -E 'HTTP/|X-Cache' # Expected first request: X-Cache: MISS curl -sI https://shop.example.com/product/sample-product/ | grep -E 'HTTP/|X-Cache' # Expected second request: X-Cache: HIT
Sample output — anonymous product page (first then second GET)
HTTP/2 200 X-Cache: MISS X-Cache-Hits: 0 HTTP/2 200 X-Cache: HIT X-Cache-Hits: 1
Meaning: first request filled the cache (MISS). Second request was served from Varnish without PHP (HIT, hits ≥ 1). If both stay MISS, cookies are still present, VCL is returning pass, or origin sent Cache-Control: private/Set-Cookie. If you never see X-Cache, the response is not going through this VCL vcl_deliver (wrong Host, or you hit origin directly).
curl -sI -H "Cookie: woocommerce_items_in_cart=1" https://shop.example.com/product/sample-product/ | grep X-Cache # Expected: X-Cache: PASS
Sample output — cart-cookie bypass test
X-Cache: PASS
Meaning: WooCommerce cart state bypassed shared cache. HIT is a correctness defect that can leak/stale personalized state; no header means the request missed this Varnish path.
# Host cannot resolve Compose DNS "varnish" — run from php-fpm (same as the mu-plugin)
docker compose exec php-fpm php -r '
$fp = fsockopen("varnish", 6081, $e, $s, 2) or exit("$s\n");
fwrite($fp, "PURGE /product/sample-product/ HTTP/1.1\r\nHost: shop.example.com\r\nX-Purge-Secret: change_me_purge_secret_min_32_chars\r\nConnection: close\r\n\r\n");
echo stream_get_contents($fp);
'
# Expected: HTTP/1.1 200 Purged
Sample output — trusted product PURGE request
HTTP/1.1 200 Purged X-Varnish: 32801 Content-Length: 0
Meaning: ACL/secret passed and Varnish invalidated the exact cache key; X-Varnish identifies the transaction. 403 means secret/source mismatch, and connection errors mean Docker DNS, port, or varnishd health.
docker compose exec php-fpm php -r '
$fp = fsockopen("varnish", 6081, $e, $s, 2) or exit("$s\n");
fwrite($fp, "BAN / HTTP/1.1\r\nHost: shop.example.com\r\nX-Purge-Secret: change_me_purge_secret_min_32_chars\r\nX-Ban-Url: ^/shop/\r\nConnection: close\r\n\r\n");
echo stream_get_contents($fp);
'
# Expected: HTTP/1.1 200 Ban added
Sample output — trusted BAN ^/shop/ request
HTTP/1.1 200 Ban added Content-Length: 0
Meaning: the ban expression was accepted for matching shop URLs. This does not enumerate removed objects; verify the next matching request is MISS. 403 is auth/ACL failure; 400 usually means a malformed/missing ban pattern.
curl -sI http://shop.example.com/ | grep -E 'HTTP/|Location' # Expected: 301 to https:// curl -sI https://shop.example.com/ | grep -E 'HTTP/|Location' # Expected: 200, no Location loop
Sample output — HTTP-to-HTTPS redirect and HTTPS loop check
HTTP/1.1 301 Moved Permanently Location: https://shop.example.com/ HTTP/2 200
Meaning: plain HTTP redirects once to the canonical HTTPS URL, which returns 200 without another Location. Repeated 301/302 indicates forwarded-proto/WordPress HTTPS handling is wrong; an incorrect Location host indicates Host/canonical configuration trouble.
curl -sI https://shop.example.com/cart/ | grep X-Cache # Expected: X-Cache: PASS
Sample output — cart-route bypass test
X-Cache: PASS
Meaning: the route itself bypasses cache even without relying on a cart cookie. HIT is unsafe and requires fixing the cart/checkout path rules before traffic.
Also test: login, checkout, product update → purge, inventory change → short TTL or ban, backend down → grace behavior.
Part 30 — Load testing
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
# Without Varnish (baseline — bypass cache tier in staging) ab -n 5000 -c 50 https://staging.example.com/product/sample-product/ # With Varnish enabled ab -n 5000 -c 50 https://staging.example.com/product/sample-product/ # wrk example wrk -t4 -c100 -d30s https://staging.example.com/shop/ # hey example hey -n 2000 -c 40 https://staging.example.com/
Sample output — wrk staging load test (excerpt)
4 threads and 100 connections Requests/sec: 8421.37 Latency 11.84ms avg 28.31ms stdev 241.09ms max Non-2xx or 3xx responses: 0
Meaning: concurrency, throughput, latency distribution, maximum, and failed-status count summarize the cached path. Compare the same URL/duration against baseline plus PHP/DB/cache metrics; non-2xx responses or rising p99/max mean saturation or correctness failure, not a successful benchmark.
Compare requests/sec, p99 latency, PHP-FPM CPU, MariaDB QPS, and cache HIT ratio. Load test in staging — never surprise production checkout.
Part 31 — Failure scenarios
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Scenario 1 — PHP-FPM slow
Cached pages still HIT. Uncached/pass routes wait; grace may serve stale HTML briefly. Monitor backend timeouts.
Scenario 2 — MariaDB unavailable
Cache HITs still work; MISS/PASS return 502/503 from origin. Grace serves stale only where configured — not for cart/checkout (they pass).
Scenario 3 — Varnish restarted
Cache cold → all MISS until repopulated. Expect DB load spike; consider warming critical URLs.
Scenario 4 — Product price changes
save_post → PURGE product URL + BAN shop/category patterns via mu-plugin.
Scenario 5 — Stock 5 → 0
Purge product URL on stock hook; short TTL (120s) limits window; verify no cached "in stock" after purge.
Scenario 6 — Logged-in customer on product page
VCL passes due to wordpress_logged_in_ cookie; no shared cache with anonymous users.
Scenario 7 — Add to cart
Cart cookie set → subsequent requests pass; add-to-cart AJAX URLs pass by rule.
Scenario 8 — Product update affects category
PURGE product + BAN ^/category/ and ^/shop/ patterns.
Pre-flight checklist (Senior DevOps)
What you should do: confirm every item before enabling aggressive cache scope.
- Staging: anonymous product page → MISS then HIT; cart/checkout → PASS
- Rollback drill completed: edge →
nginx-origin:8080without data loss - WordPress full-page cache plugins disabled (Varnish is the edge cache)
- PURGE/BAN secret rotated; not committed to public git in production values
varnishd -Cpasses; healthcheck green; origin/healthzreturns 200- HTTPS:
is_ssl()works (FastCGIHTTPS on, not literalhttps) - Inventory/price change triggers purge verified on staging
- Monitoring:
X-Cache,varnishstat, PHP-FPM/MariaDB dashboards ready
Part 32 — Production deployment
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
- Backup Nginx configs and Compose files
- Add Varnish service and origin split; keep edge Nginx on 443
- Validate VCL:
varnishd -C - Deploy with conservative TTLs; wide PASS rules
- Test HTTP/HTTPS, login, cart, checkout, PURGE
- Monitor HIT/MISS for 24–48h
- Gradually tighten cache scope only after metrics prove safety
Part 33 — Rollback
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
# In nginx/edge.conf location / : # proxy_pass http://php-fpm:9000; # WRONG for FPM — use origin without Varnish: # proxy_pass http://nginx-origin:8080; # For full rollback, point edge directly to nginx-origin:8080 and stop varnish service
docker compose stop varnish # Edit nginx/edge.conf: proxy_pass http://nginx-origin:8080; docker compose exec nginx-edge nginx -s reload # Stack reverts to Nginx → origin → PHP-FPM → MariaDB; no data loss
Revert files: nginx/edge.conf, remove varnish from compose if desired. MariaDB named volume and the ./wordpress bind mount stay untouched (no data wipe).
Part 34 — Troubleshooting
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
| Symptom | Possible cause | Investigation | Fix |
|---|---|---|---|
| Everything is MISS | Cold cache; TTL 0; Set-Cookie on response; pass rules too broad | varnishlog, check beresp.ttl | Fix vcl_backend_response; warm cache |
| Everything is PASS | Cookie bypass; WooCommerce rules; method not GET | varnishlog VCL_return | Narrow pass rules; strip tracking cookies only |
| Cache not storing | Cache-Control private; Set-Cookie; beresp.uncacheable | varnishlog -b | Fix origin headers or VCL |
| WooCommerce cart breaks | Cart page cached | curl cart with cookies | Pass /cart/ and cart cookies |
| Login breaks | Login POST cached or cached redirect | Check pass on wp-login | Pass admin/login URLs |
| Checkout breaks | Checkout cached | curl checkout | Pass checkout and payment URLs |
| Product stock stale | TTL too long; no purge on stock | Compare DB vs cached page | Short TTL; stock hook purge |
| PURGE returns 403 | ACL or wrong secret | Check client IP and X-Purge-Secret | Fix ACL/secret in VCL and mu-plugin |
| PURGE no effect | Wrong Host/URL key | Purge exact URL path | Match cache key Host+URL |
| HTTPS redirect loop | WordPress thinks HTTP | Check X-Forwarded-Proto | Set in edge Nginx + WP config |
| WordPress site URL HTTP | Missing HTTPS in wp-config | Inspect $_SERVER HTTPS | Set HTTPS + WP_HOME in wordpress/wp-config.php |
| Low HIT ratio | Too many cookies; short TTL | varnishstat | Normalize cookies; tune TTL — after correctness |
| Varnish memory high | Large objects; high cardinality | varnishstat n_object | Increase malloc or reduce TTL |
| Backend unhealthy | Probe failing | varnishadm backend.list | Fix /healthz on origin |
| User sees wrong content | Shared cache with auth cookie cached | Audit vcl_recv cookie rules | Pass on session cookies immediately |
| CSS/JS stale | Long TTL without versioning | Check asset query strings | Use versioned filenames; purge on deploy |
Part 35 — Final production configuration
What you should do: if you already copied Part 8, skip this; otherwise COPY here once.
Same stack and filenames as Part 8. Part 8 also has the php/Dockerfile and beginner notes; Part 19 is the WordPress walkthrough for wp-config.php + mu-plugin. Prefer one copy — do not paste conflicting secrets twice.
Directory tree
varnish-wp/
├── .env
├── .env.example
├── docker-compose.yml
├── certbot/
│ ├── conf/
│ └── www/
├── nginx/
│ ├── edge.conf # TLS :443 → varnish:6081
│ └── origin.conf # :8080 → PHP-FPM
├── php/
│ └── Dockerfile # PHP-FPM + wp-cli (Compose build)
├── varnish/
│ └── default.vcl # VCL 4.1 for Varnish 7.x
└── wordpress/ # full WordPress tree on host (not official WP image)
├── wp-config.php # DB + HTTPS + VARNISH_* constants
├── wp-content/
│ └── mu-plugins/
│ └── varnish-purge.php
└── …
.env.example
.env (copy from .env.example; set secrets).# Site / MariaDB (MariaDB container). Mirror DB_* into wordpress/wp-config.php. DOMAIN=shop.example.com MARIADB_ROOT_PASSWORD=change_me_root_password MARIADB_DATABASE=wordpress MARIADB_USER=wpuser MARIADB_PASSWORD=change_me_strong_password
DB_*, WP_HOME, HTTPS detection, and VARNISH_* in wordpress/wp-config.php inside the mounted WordPress tree.wordpress/wp-config.php (required on plain PHP-FPM)
Because the app container is not the official WordPress image, Compose does not inject WORDPRESS_* env vars. Edit wp-config.php in the mounted tree:
wordpress/wp-config.php (above “That’s all, stop editing!”).// Behind nginx-edge TLS
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
define('WP_HOME', 'https://shop.example.com');
define('WP_SITEURL', 'https://shop.example.com');
define('DB_NAME', 'wordpress');
define('DB_USER', 'wpuser');
define('DB_PASSWORD', 'change_me_strong_password');
define('DB_HOST', 'mariadb');
// Used by mu-plugins/varnish-purge.php — HTTP to the varnish container (not VCL syntax)
define('VARNISH_HOST', 'varnish');
define('VARNISH_PORT', 6081);
define('VARNISH_PURGE_SECRET', 'change_me_purge_secret_min_32_chars');
docker-compose.yml
docker-compose.yml (pinned images; edit domains/secrets).services:
nginx-edge:
image: nginx:1.27-alpine
container_name: wp-nginx-edge
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/edge.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
varnish:
condition: service_healthy
networks:
- wp-net
restart: unless-stopped
varnish:
image: varnish:7.5.0-alpine
container_name: wp-varnish
expose:
- "6081"
volumes:
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
command:
- varnishd
- -F
- -a
- :6081
- -f
- /etc/varnish/default.vcl
- -s
- malloc,256m
healthcheck:
test: ["CMD", "varnishadm", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
depends_on:
nginx-origin:
condition: service_started
networks:
- wp-net
restart: unless-stopped
nginx-origin:
image: nginx:1.27-alpine
container_name: wp-nginx-origin
expose:
- "8080"
volumes:
- ./nginx/origin.conf:/etc/nginx/conf.d/default.conf:ro
- ./wordpress:/var/www/html:ro
depends_on:
php-fpm:
condition: service_started
networks:
- wp-net
restart: unless-stopped
# Not the official WordPress image — build php/Dockerfile (PHP-FPM + wp-cli).
# WordPress core/theme/plugin files live in ./wordpress on the host.
php-fpm:
build:
context: ./php
image: local/php-fpm-wpcli:8.2
container_name: wp-php-fpm
working_dir: /var/www/html
volumes:
- ./wordpress:/var/www/html
depends_on:
mariadb:
condition: service_healthy
networks:
- wp-net
restart: unless-stopped
mariadb:
image: mariadb:11.4.3-noble
container_name: wp-mariadb
env_file:
- .env
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- wp-net
restart: unless-stopped
certbot:
image: certbot/certbot:v2.11.0
container_name: wp-certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done'
networks:
- wp-net
restart: unless-stopped
networks:
wp-net:
driver: bridge
volumes:
mariadb_data:
varnish/default.vcl
varnish/default.vcl (Varnish 7.x, VCL 4.1; edit purge secret).vcl 4.1;
import std;
import directors;
# Target: Varnish Cache 7.x — VCL 4.1 syntax
# Backend: nginx-origin (WordPress origin, not public)
acl purge {
"localhost";
"127.0.0.1";
"::1";
"10.0.0.0"/8;
"172.16.0.0"/12;
"192.168.0.0"/16;
}
backend origin {
.host = "nginx-origin";
.port = "8080";
.connect_timeout = 5s;
.first_byte_timeout = 60s;
.between_bytes_timeout = 60s;
.max_connections = 300;
.probe = {
.url = "/healthz";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
.expected_response = 200;
}
}
sub vcl_init {
new vdir = directors.round_robin();
vdir.add_backend(origin);
}
sub vcl_recv {
set req.backend_hint = vdir.backend();
# Normalize Host — reject missing/invalid Host early
if (!req.http.Host) {
return (synth(400, "Bad Request"));
}
# PURGE — ACL + secret header (never public)
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return (synth(403, "Forbidden"));
}
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
return (purge);
}
# BAN — pattern invalidation from trusted networks only
if (req.method == "BAN") {
if (!client.ip ~ purge) {
return (synth(403, "Forbidden"));
}
if (req.http.X-Purge-Secret != "change_me_purge_secret_min_32_chars") {
return (synth(403, "Forbidden"));
}
if (req.http.X-Ban-Url) {
std.ban("req.url ~ " + req.http.X-Ban-Url);
return (synth(200, "Ban added"));
}
if (req.http.X-Ban-Host) {
std.ban("req.http.host == \"" + req.http.X-Ban-Host + "\"");
return (synth(200, "Ban added"));
}
return (synth(400, "Missing X-Ban-Url or X-Ban-Host"));
}
# Only cache GET/HEAD
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Never cache authenticated / admin surfaces
if (req.url ~ "^/(wp-admin|wp-login\.php|xmlrpc\.php|wp-cron\.php)") {
return (pass);
}
# WooCommerce AJAX and account routes — always pass (adapt to your permalinks)
if (req.url ~ "wc-ajax=") {
return (pass);
}
if (req.url ~ "^/(cart|checkout|my-account|wc-api|wp-json/wc/store)(/|\?|$)") {
return (pass);
}
if (req.url ~ "add-to-cart=|remove_item=|apply_coupon=|update_cart=") {
return (pass);
}
# REST/AJAX with private data
if (req.url ~ "^/wp-admin/admin-ajax\.php" || req.url ~ "^/wp-json/") {
return (pass);
}
# Session / auth cookies — bypass (correctness over HIT ratio)
if (req.http.Cookie ~ "(wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_)") {
return (pass);
}
# Optional: strip tracking cookies only (document risk before enabling)
# unset req.http.Cookie;
# Authorization must never hit shared cache
if (req.http.Authorization) {
return (pass);
}
# Static assets — long TTL at Varnish layer (origin still sets headers)
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
unset req.http.Cookie;
return (hash);
}
# Public HTML — cacheable for anonymous visitors
unset req.http.Cookie;
return (hash);
}
sub vcl_backend_response {
# Do not cache Set-Cookie responses (except static where cookie already stripped)
if (beresp.http.Set-Cookie) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Respect origin no-store / private
if (beresp.http.Cache-Control ~ "(private|no-store|no-cache)" ||
beresp.http.Vary ~ "Cookie") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Static assets
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)(\?|$)") {
set beresp.ttl = 7d;
set beresp.grace = 1h;
set beresp.keep = 6h;
return (deliver);
}
# Product/inventory-sensitive pages — short TTL (tune per site)
if (bereq.url ~ "^/product/|^/shop/|^/\?post_type=product") {
set beresp.ttl = 120s;
set beresp.grace = 30s;
set beresp.keep = 60s;
return (deliver);
}
# General public pages
if (beresp.status == 200 && (bereq.method == "GET" || bereq.method == "HEAD")) {
set beresp.ttl = 300s;
set beresp.grace = 60s;
set beresp.keep = 120s;
}
if (beresp.status >= 500) {
set beresp.ttl = 0s;
set beresp.grace = 30s;
}
return (deliver);
}
sub vcl_hit {
if (obj.ttl >= 0s) {
return (deliver);
}
if (obj.ttl + obj.grace > 0s) {
return (deliver);
}
# Object past TTL+grace — fetch fresh and allow re-store (not pass)
return (miss);
}
sub vcl_deliver {
if (obj.uncacheable) {
set resp.http.X-Cache = "PASS";
} elsif (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
unset resp.http.X-Varnish;
unset resp.http.Via;
return (deliver);
}
nginx/edge.conf
nginx/edge.conf (TLS :443 → Varnish :6081; Certbot paths).# nginx/edge.conf — TLS termination, proxy to Varnish (HTTP only to cache tier)
server {
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 64m;
location / {
proxy_pass http://varnish:6081;
proxy_http_version 1.1;
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 https;
proxy_set_header Connection "";
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
nginx/origin.conf
nginx/origin.conf (FastCGI to PHP-FPM; /healthz probe).# nginx/origin.conf — origin server for WordPress/PHP-FPM (not exposed publicly)
map $http_x_forwarded_proto $fastcgi_https {
default off;
https on;
}
upstream php-fpm {
server php-fpm:9000;
}
server {
listen 8080;
server_name shop.example.com;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php-fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS $fastcgi_https;
fastcgi_read_timeout 120s;
}
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|avif|woff2?|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
location ~ /\. {
deny all;
}
}
wordpress mu-plugin (required — full beginner walkthrough in Part 19)
wordpress/wp-content/mu-plugins/varnish-purge.php (automated PURGE/BAN on save/stock change).<?php
/**
* Plugin Name: Varnish Purge on Save
* Description: PURGE/BAN Varnish when WordPress/WooCommerce content changes.
* Must-use plugin — drop in wp-content/mu-plugins/
*/
if (!defined('ABSPATH')) {
exit;
}
// Prefer constants from wp-config.php (this stack is plain PHP-FPM, not official WP image)
define('VPURGE_HOST', defined('VARNISH_HOST') ? VARNISH_HOST : 'varnish');
define('VPURGE_PORT', defined('VARNISH_PORT') ? (int) VARNISH_PORT : 6081);
define('VPURGE_SECRET', defined('VARNISH_PURGE_SECRET') ? VARNISH_PURGE_SECRET : 'change_me_purge_secret_min_32_chars');
function vpurge_request(string $method, string $path, array $extra_headers = []): void {
$fp = @fsockopen(VPURGE_HOST, VPURGE_PORT, $errno, $errstr, 2.0);
if (!$fp) {
error_log("Varnish purge failed: $errstr ($errno)");
return;
}
$host = parse_url(home_url(), PHP_URL_HOST) ?: 'localhost';
$headers = array_merge([
"Host: $host",
'X-Purge-Secret: ' . VPURGE_SECRET,
'Connection: close',
], $extra_headers);
$req = "$method $path HTTP/1.1\r\n" . implode("\r\n", $headers) . "\r\n\r\n";
fwrite($fp, $req);
fclose($fp);
}
function vpurge_url(string $url_path): void {
vpurge_request('PURGE', $url_path);
}
function vpurge_ban_pattern(string $pattern): void {
vpurge_request('BAN', '/', ['X-Ban-Url: ' . $pattern]);
}
function vpurge_on_save(int $post_id, WP_Post $post): void {
if (wp_is_post_revision($post_id) || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE)) {
return;
}
// PURGE the public URL only when the object is live
if ($post->post_status === 'publish') {
$permalink = wp_make_link_relative(get_permalink($post_id));
vpurge_url($permalink ?: '/');
}
// BAN listings even on unpublish/draft so archives drop the old HTML
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('save_post', 'vpurge_on_save', 10, 2);
function vpurge_on_delete(int $post_id): void {
vpurge_ban_pattern('^/product/');
vpurge_ban_pattern('^/shop/');
vpurge_ban_pattern('^/category/');
vpurge_ban_pattern('^/$');
}
add_action('deleted_post', 'vpurge_on_delete');
add_action('wp_trash_post', 'vpurge_on_delete');
function vpurge_on_stock_change($product): void {
if (!is_a($product, 'WC_Product')) {
return;
}
$permalink = wp_make_link_relative(get_permalink($product->get_id()));
vpurge_url($permalink ?: '/');
vpurge_ban_pattern('^/shop/');
}
function vpurge_on_wc_product_id($product_id): void {
if (!function_exists('wc_get_product')) {
return;
}
$product = wc_get_product($product_id);
if ($product) {
vpurge_on_stock_change($product);
}
}
// Mu-plugins load BEFORE normal plugins — WooCommerce class is not ready yet.
// Register Woo hooks on plugins_loaded so class_exists('WooCommerce') is true.
function vpurge_register_woo_hooks(): void {
if (!class_exists('WooCommerce')) {
return;
}
add_action('woocommerce_product_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_variation_set_stock', 'vpurge_on_stock_change');
add_action('woocommerce_update_product', 'vpurge_on_wc_product_id');
}
add_action('plugins_loaded', 'vpurge_register_woo_hooks', 20);
Part 36 — Best practices
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
- Pin image versions; upgrade deliberately
- Correctness > security > availability > performance > HIT ratio
- Start with pass-heavy VCL; widen cache scope with metrics
- Automate invalidation on publish and stock change
- Keep TLS on Nginx; Certbot unchanged
- Run rollback drill before production cutover
- Document TTL and purge policy for the team
- Generate VCL or inject purge secret at deploy time — avoid hardcoding production secrets in git
- One full-page cache layer only: Varnish at edge, not WP Rocket/W3TC page cache inside PHP
X-Purge-Secret in VCL for clarity. In production, render VCL from a template (envsubst, Ansible, etc.) or mount a generated file so secrets never live in version control.Part 37 — Conclusion
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Varnish Cache 7.x with VCL 4.1 fits between Nginx and your WordPress origin to absorb anonymous read traffic. WooCommerce demands strict pass rules, cookie awareness, inventory invalidation, and secured PURGE/BAN. Build correctness first; treat HIT ratio as a secondary metric once stock, sessions, and checkout are proven safe.