Varnish Cache + WordPress/WooCommerce on Docker: Beginner to Production

75 min read

Published · Target: Varnish Cache 7.x, VCL 4.1

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.

Engineering priority (non-negotiable for e-commerce):
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.

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 this part is for: cover: 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.

Scope: We do not replace Nginx with another proxy, move TLS to Varnish, migrate to Kubernetes, or require Redis. The mu-plugin in Part 8 / Part 19 / Part 35 works without third-party cache plugins.

Part 2 — Existing architecture

What this part is for: cover: Existing architecture.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — existing stack before Varnish (no action required).
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 this part is for: cover: 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.

TermMeaning
HITObject found in cache; backend not contacted
MISSNot in cache; fetched from backend, then stored
PASSDo not use cache for this request; always fetch backend
FETCHBackend response being retrieved
TTLTime object is fresh
GraceServe stale object while revalidating or if backend is sick
KeepIdle time before object is discarded after TTL+grace
BackendOrigin server (here: nginx-origin:8080)
PURGERemove one cache object by exact URL
BANAdd a ban-lister pattern; matching objects invalidated lazily
Cache objectStored HTTP response + metadata in Varnish memory
Cache keyHash inputs (Host, URL, Vary headers) identifying one object
Cache invalidationPURGE/BAN/TTL expiry removing or marking objects stale
Request coalescingOne backend fetch while many clients wait on the same MISS
Cache stampedeThundering herd when many clients miss cache simultaneously
Backend probeHealth check that marks backend sick/healthy

Part 4 — How HTTP caching works

What this part is for: cover: 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.

Freshness check (simplified):
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 this part is for: cover: Varnish request lifecycle.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — request path after Varnish is added.
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.

What you typically gain on catalog traffic (after tuning):
• 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 this part is for: cover: 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.

Cacheable (anonymous): homepage, categories, public product pages, blog posts, static assets.
Not cacheable: cart, checkout, my-account, login, AJAX/REST with private data, requests with session/auth cookies.

Part 7 — Designing the new architecture

What this part is for: cover: Designing the new architecture.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Target topology:

READ — production flow; TLS stays on Nginx edge.
Internet → Nginx:443 TLS → Varnish:6081 HTTP → Nginx origin:8080 → PHP-FPM → MariaDB
flowchart LR C[Client] -->|HTTPS 443| NE[Nginx Edge TLS] NE -->|HTTP 6081| V[Varnish 7.x] V -->|HTTP 8080| NO[Nginx Origin] NO --> PF[PHP-FPM WordPress] PF --> DB[(MariaDB)] CB[Certbot] -.->|renew certs| NE

Why Varnish must NOT terminate TLS

Do not move SSL termination to Varnish for this stack. Edge Nginx handles :443; Varnish receives already-decrypted HTTP from Nginx with X-Forwarded-Proto: https.

Part 8 — Docker architecture

What this part is for: cover: Docker architecture.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — project directory layout.
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.

PHP image: this guide uses a plain PHP-FPM image with wp-cli installed (any PHP 8.x you choose) — not the official 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.
ServiceImage (pinned)Port
nginx-edgenginx:1.27-alpine443, 80
varnishvarnish:7.5.0-alpine6081 (expose)
nginx-originnginx:1.27-alpine8080 (expose)
php-fpmlocal/php-fpm-wpcli:8.2 (build ./php)9000
mariadbmariadb:11.4.3-noble3306

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):

COPY FILE — save as 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
RUN — create directories on the host.
# 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
COPY FILE — save as .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=…
COPY FILE — save as 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:

COPY FILE — add these defines to 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

COPY FILE — save as 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

COPY FILE — save as 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

COPY FILE — save as 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)

COPY FILE — save as 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);
Recommended cache map (what the VCL in Part 35 actually does):
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).

RUN — build the PHP-FPM + wp-cli image.
docker compose build php-fpm
# Or: docker build -t local/php-fpm-wpcli:8.2 ./php
RUN — lab self-signed TLS (production: use Certbot instead).
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
RUN — download WordPress core into ./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.
Order: files from Part 8 → build php-fpm → lab certs → WordPress core + wp-config.phpdocker compose up (Part 9). Finish purge wiring with Part 19 Steps A–H if you have not already.

Part 9 — Installing Varnish

What this part is for: cover: Installing Varnish.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
RUN — validate VCL syntax before deploy.
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.

RUN — paste into a shell on the host this section describes.
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 pinghealthy 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 this part is for: cover: 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:

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).

Common production bug: setting 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 this part is for: cover: 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).

Debug headers: X-Cache: HIT|MISS|PASS and X-Cache-Hits help verify behavior without guessing.

Part 12 — WordPress caching

What this part is for: cover: WordPress caching.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Plugin conflict: If you run a full-page cache plugin (WP Rocket, W3 Total Cache page cache, LiteSpeed Cache, etc.) inside WordPress, it fights Varnish for the same job. Pick one full-page cache layer — here Varnish at the edge. Disable WordPress HTML page caching in those plugins; keep their asset/minify features only if you understand the interaction.

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.

COPY FILE — recommended WordPress/WooCommerce 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 typeVarnish TTL (example)Notes
CSS/JS (versioned)7dImmutable if hash in filename
Images WebP/AVIF7dPurge on media replace
Fonts woff230dCORS 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.

READ — static asset rule excerpt (full VCL in Part 35).
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 this part is for: cover: 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 / signalCache?Why
/cart/, /checkout/, /my-account/PASSSession-bound HTML
/?wc-ajax=, /wp-admin/admin-ajax.phpPASSCart fragments, checkout updates
add-to-cart=, coupons, cart query argsPASSMutates session state
woocommerce_items_in_cart cookiePASSUser has cart contents
wp_woocommerce_session_ cookiePASSWoo session identifier
Payment gateway callbacks (PayPal, Stripe, etc.)PASSVerify your gateway URL patterns explicitly
Public product/category/shop (anonymous)CacheableShort TTL + purge on stock/price change
Cart/checkout breakage is almost always a caching rule that should have been PASS. Dynamic pricing / stock plugins that embed user-specific prices in HTML may require PASS on product pages — test with real accounts.

Part 14 — Cookies and sessions

What this part is for: cover: Cookies and sessions.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Cookies drive cache variance. Dangerous extremes:

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 patternAction
wordpress_logged_in_*PASS — authenticated user
wp_woocommerce_session_*PASS — WooCommerce session
woocommerce_items_in_cart, woocommerce_cart_hashPASS — cart state
comment_author_*, wp-postpass_*PASS — private/comment flows
_ga, _gid, marketing pixelsOptional strip — only after A/B proof HTML is identical

Part 15 — Product and inventory correctness

What this part is for: cover: 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.

StrategyProsCons
Short TTL on product/shop URLsSimple; no app changesStaleness window remains; higher origin load
PURGE on single product URLPrecise after editCategory/shop pages still stale unless BAN
BAN patterns (^/shop/, ^/product/)Invalidates listing pagesRegex mistakes are dangerous — test patterns
PASS when stock shown in HTMLMaximum correctnessLow HIT ratio on product pages
App-driven invalidation (mu-plugin)Automated on save/stock hooksMust secure PURGE/BAN; monitor failures
Event/webhook from ERP/WMSExternal stock truthMore 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 this part is for: cover: 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.

InputIn default hash?WooCommerce note
HostYesMust match site domain in edge Nginx
URL path + queryYes?add-to-cart= should PASS before hash
CookieOnly if not stripped / variedWe PASS on session cookies; strip tracking only after proof
AuthorizationYes if presentAlways PASS in our VCL
Vary: Cookie from originForces per-cookie objectsFix 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.

READ — query-string normalization concept (implement on staging first).
# 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 this part is for: cover: 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.

PURGEBAN (std.ban())
ScopeOne exact URL + HostPattern / regex on metadata
SpeedImmediate object removalLazy at lookup time
Best forSingle product/post updatedShop/category pages, many URLs
RiskWrong URL → object remainsOver-broad regex → cache churn

Part 18 — BAN

What this part is for: cover: 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 this part is for: do every WordPress-side step yourself so that when you save a product in wp-admin, the Varnish cache for that page is cleared.
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.

Remember: PHP inside 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

  1. wordpress/wp-config.php defines VARNISH_HOST, VARNISH_PORT, VARNISH_PURGE_SECRET (same secret as in varnish/default.vcl).
  2. File exists: wordpress/wp-content/mu-plugins/varnish-purge.php.
  3. From php-fpm, a manual PURGE returns 200 Purged.
  4. 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:

READ — expected folders on the host.
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
RUN — on the host, from the 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):

RUN — confirm WordPress sees the install (inside php-fpm).
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.

COPY FILE — paste into 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');
Beginner tip: if 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.
Secret sync: open 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:

RUN — only if changes do not seem to apply.
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):

RUN — create empty file then edit, or create in one step.
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.

COPY FILE — save as 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

RUN — list must-use plugins (wp-cli inside php-fpm).
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-adminWordPress hookWhat 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
If your shop URL is not /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.

RUN — PURGE from php-fpm → varnish (replace Host + secret).
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:

ResultCauseWhat you do
200 PurgedOKContinue to Step G
403 ForbiddenWrong secret or IP not in VCL ACLMatch secret in wp-config ↔ VCL; keep both containers on wp-net
Connection refused / timeoutWrong host/port or varnish downdocker compose ps; host must be varnish not the public domain
Empty / hangnc missing or firewallUse 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.

  1. Warm the cache (anonymous): from a private/incognito window (logged out), open a product page twice.
RUN — expect MISS then HIT.
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).

  1. Log into wp-admin → Products → open that product → change the title or short description → click Update.
  2. Watch logs in another terminal while you click Update:
RUN — watch PURGE arrive on varnish (leave running).
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.

  1. Still logged out / incognito, request the product again:
RUN — after Update, first hit should be MISS (or new HTML).
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.

  1. 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)

SymptomCheckFix
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)
Do not run 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.
You are finished with the WordPress work when: Step F returns 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 this part is for: cover: Grace and Keep.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
TTL = fresh lifetime
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 typeTTL (example)GraceKeepNotes
Static assets7d1h6hSafe if filenames are versioned
Product/shop60–120s30s max60sDo not use long grace — stale stock risk
Home/blog300s60s120sPurge on publish
Cart/checkoutPASSNever cache

Part 21 — Backend health checks

What this part is for: cover: 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.

RUN — check backend health from varnish container.
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 this part is for: cover: 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).

RUN — warm critical URLs after Varnish restart (staging/prod maintenance window).
# 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/
After deploy: cold cache + flash sale = MariaDB spike. Warm top N URLs; keep product TTL short so coalescing window stays small.

Part 23 — Security hardening

What this part is for: cover: Security hardening.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
SurfaceExposureRule
Varnish :6081Edge Nginx onlyNever publish to public internet
varnishadmContainer localhostNo host port mapping
PURGE/BANACL private nets + secretMu-plugin uses internal Docker DNS
Origin :8080Docker networkNot in edge ports:

Part 24 — Cache poisoning

What this part is for: cover: 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.

RUN — staging test: wrong Host must not return cached site HTML.
# 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 this part is for: cover: 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.

KnobStart conservativeWhen to change
Product TTL120sLower if stock in HTML; raise only with reliable purge
malloc size256mRaise when n_lru_nuked grows in steady state
Backend timeouts60sMatch PHP-FPM max_execution_time
Pass rulesWide (Woo-safe)Narrow only per route with curl proof

Part 26 — Memory tuning

What this part is for: cover: 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.

Example: 8 GB host, 2 GB MariaDB, 1 GB PHP-FPM → 256–512 MB Varnish malloc is a starting point for medium catalog traffic. Measure and adjust.

Part 27 — Observability

What this part is for: cover: Observability.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
RUN — inspect cache hits/misses inside varnish container.
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 signalAction if bad
MAIN.cache_hit / cache_missHit ratio rises after warm-upReview pass rules; cold cache after restart
MAIN.n_objectStable after warm-upmalloc too small → evictions; increase -s malloc
MAIN.backend_busyLow on catalog trafficToo many MISS/PASS — tune TTL or fix origin slowness
MAIN.n_lru_nukedNear zero in steady stateMemory pressure — increase malloc or reduce TTL

Part 28 — Logging

What this part is for: cover: 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.

COPY FILE — optional sidecar in 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 this part is for: cover: 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.

RUN — anonymous product page: expect MISS then HIT on repeat.
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).

RUN — cart cookie must yield X-Cache: PASS.
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.

RUN — PURGE with ACL + X-Purge-Secret (from trusted network).
# 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.

RUN — paste into a shell on the host this section describes.
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.

RUN — paste into a shell on the host this section describes.
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.

RUN — paste into a shell on the host this section describes.
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 this part is for: cover: Load testing.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
RUN — load test in staging only (never production checkout).
# 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 this part is for: cover: 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 this part is for: gate production cutover.
What you should do: confirm every item before enabling aggressive cache scope.

Part 32 — Production deployment

What this part is for: cover: Production deployment.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
  1. Backup Nginx configs and Compose files
  2. Add Varnish service and origin split; keep edge Nginx on 443
  3. Validate VCL: varnishd -C
  4. Deploy with conservative TTLs; wide PASS rules
  5. Test HTTP/HTTPS, login, cart, checkout, PURGE
  6. Monitor HIT/MISS for 24–48h
  7. Gradually tighten cache scope only after metrics prove safety
Do not enable aggressive caching on day one. Correctness first.

Part 33 — Rollback

What this part is for: cover: Rollback.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — notes only. Not a script to execute.
# 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
RUN — emergency rollback: bypass Varnish on edge Nginx.
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 this part is for: cover: Troubleshooting.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
SymptomPossible causeInvestigationFix
Everything is MISSCold cache; TTL 0; Set-Cookie on response; pass rules too broadvarnishlog, check beresp.ttlFix vcl_backend_response; warm cache
Everything is PASSCookie bypass; WooCommerce rules; method not GETvarnishlog VCL_returnNarrow pass rules; strip tracking cookies only
Cache not storingCache-Control private; Set-Cookie; beresp.uncacheablevarnishlog -bFix origin headers or VCL
WooCommerce cart breaksCart page cachedcurl cart with cookiesPass /cart/ and cart cookies
Login breaksLogin POST cached or cached redirectCheck pass on wp-loginPass admin/login URLs
Checkout breaksCheckout cachedcurl checkoutPass checkout and payment URLs
Product stock staleTTL too long; no purge on stockCompare DB vs cached pageShort TTL; stock hook purge
PURGE returns 403ACL or wrong secretCheck client IP and X-Purge-SecretFix ACL/secret in VCL and mu-plugin
PURGE no effectWrong Host/URL keyPurge exact URL pathMatch cache key Host+URL
HTTPS redirect loopWordPress thinks HTTPCheck X-Forwarded-ProtoSet in edge Nginx + WP config
WordPress site URL HTTPMissing HTTPS in wp-configInspect $_SERVER HTTPSSet HTTPS + WP_HOME in wordpress/wp-config.php
Low HIT ratioToo many cookies; short TTLvarnishstatNormalize cookies; tune TTL — after correctness
Varnish memory highLarge objects; high cardinalityvarnishstat n_objectIncrease malloc or reduce TTL
Backend unhealthyProbe failingvarnishadm backend.listFix /healthz on origin
User sees wrong contentShared cache with auth cookie cachedAudit vcl_recv cookie rulesPass on session cookies immediately
CSS/JS staleLong TTL without versioningCheck asset query stringsUse versioned filenames; purge on deploy

Part 35 — Final production configuration

What this part is for: one-place reprint of the production stack files (same as Part 8).
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

READ — diagram or folder layout. Do not paste into a terminal.
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

COPY FILE — save as .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
Config location: there is no official WordPress image here — put 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:

COPY FILE — add these defines to 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

COPY FILE — save as 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

COPY FILE — save as 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

COPY FILE — save as 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

COPY FILE — save as 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)

COPY FILE — save as 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 this part is for: cover: Best practices.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
VCL secret: The example hardcodes 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 this part is for: cover: 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.