vcl 4.0;
import std;
# ═════════════════════════════════════════════
# Backend definition
# ═════════════════════════════════════════════
backend default {
.host = "127.0.0.1";
.port = "8080";
.first_byte_timeout = 600s;
.connect_timeout = 5s;
.between_bytes_timeout = 60s;
# Health probe disabled — if the probe URL (/) returns a redirect or
# non-200, Varnish marks the backend as sick and serves 503 to all
# requests. To re-enable, create a /health-check.php that returns
# HTTP 200, then uncomment:
#
# .probe = {
# .url = "/health-check.php";
# .timeout = 3s;
# .interval = 5s;
# .window = 5;
# .threshold = 3;
# }
}
# ═════════════════════════════════════════════
# ACL — hosts allowed to issue PURGE / BAN
# ═════════════════════════════════════════════
acl purger {
"localhost";
"127.0.0.1";
"172.17.0.1";
}
# ═════════════════════════════════════════════
# vcl_recv — Inbound request handling
# ═════════════════════════════════════════════
sub vcl_recv {
# ── Restarts: force a cache miss so we re-fetch from the backend ──
if (req.restarts > 0) {
set req.hash_always_miss = true;
}
# ── PURGE / BAN ──
if (req.method == "PURGE") {
if (client.ip !~ purger) {
return (synth(405, "Method not allowed"));
}
if (req.http.X-Cache-Tags) {
ban("obj.http.X-Cache-Tags ~ " + req.http.X-Cache-Tags);
} else {
# Ban-lurker-friendly expression: uses obj.http.* headers that
# are set in vcl_backend_response so the lurker can evaluate
# bans in the background without waiting for new requests.
ban("obj.http.X-Host == " + req.http.host
+ " && obj.http.X-Url == " + req.url);
}
return (synth(200, "Purged"));
}
# ── Reject non-standard methods ──
if (req.method != "GET" &&
req.method != "HEAD" &&
req.method != "PUT" &&
req.method != "POST" &&
req.method != "TRACE" &&
req.method != "OPTIONS" &&
req.method != "DELETE") {
return (pipe);
}
# Only GET and HEAD are cacheable
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# ── Grace-period tracking header ──
set req.http.grace = "none";
# ── Strip leading scheme (http:// / https://) from the URL ──
set req.url = regsub(req.url, "^http[s]?://", "");
# ── Collapse multiple Cookie headers into one ──
std.collect(req.http.Cookie);
# ─────────────────────────────────────────
# XENFORO (/community/)
# ─────────────────────────────────────────
if (req.url ~ "^/community($|/)") {
# Admin area — never cache
if (req.url ~ "^/community/admin") {
return (pass);
}
# Auth / account / messaging pages — never cache
if (req.url ~ "^/community/(login|register|account|conversations|admin\.php|index\.php\?login)") {
return (pass);
}
# ── Attachments ──
# Must be checked BEFORE the action-bypass regex, which would
# otherwise match the substring "attachment" and pass them.
if (req.url ~ "^/community/attachments/") {
# FIX: normalise trailing slash so /foo.64731 and /foo.64731/
# resolve to the same cache object, eliminating the 301
# redirect round-trip to the backend that was inflating misses.
if (req.url !~ "/$") {
set req.url = req.url + "/";
}
unset req.http.Cookie;
return (hash);
}
# ── Compiled CSS & versioned JS ──
# Cache-busting query params (&k= for CSS, ?_v= for JS) change
# on theme/version update, so long TTLs are safe.
if (req.url ~ "^/community/css\.php\?css=" ||
req.url ~ "^/community/(js|data/js)/.*\?_v=") {
unset req.http.Cookie;
return (hash);
}
# POST-like actions encoded in URLs — never cache
if (req.url ~ "^/community/.*(add-reply|edit|delete|save|react|vote|report|warn|approve|merge|move|upload)") {
return (pass);
}
# Internal data paths — never cache
# NOTE: /community/data/ contains avatars and thumbnails. These
# are static, but some may be permission-gated. If you want to
# cache public avatars, add a dedicated block for
# /community/data/avatars/ above this rule.
if (req.url ~ "^/community/(data|internal_data)/") {
return (pass);
}
# Logged-in XenForo users — never cache.
# Note: xf_csrf, xf_notice_dismiss, and xf_push_notice_dismiss are
# set for ALL visitors (including guests) and do NOT indicate a
# logged-in session.
if (req.http.Cookie ~ "xf_session" ||
req.http.Cookie ~ "xf_user" ||
req.http.Cookie ~ "xf_logged_in") {
return (pass);
}
# ── Guest XenForo HTML pages — pass to backend, do NOT cache ──
# XenForo embeds a unique xf_csrf token in both the Set-Cookie
# header and in hidden HTML form fields on every response.
# Caching these pages would serve every guest the same stale
# CSRF token, breaking form submissions (login, register, reply)
# and undermining anti-CSRF protection. Guest HTML is lightweight
# compared to attachments and static assets, so passing it
# through to nginx has minimal performance impact.
return (pass);
}
# ─────────────────────────────────────────
# WORDPRESS
# ─────────────────────────────────────────
# Admin / login / cron / previews — never cache
if (req.url ~ "^/wp-(admin|login|cron)" ||
req.url ~ "/wp-json/" ||
req.url ~ "preview=true" ||
req.url ~ "/xmlrpc\.php" ||
req.url ~ "/wp-comments-post\.php") {
return (pass);
}
# WooCommerce dynamic pages — never cache
if (req.url ~ "^/(cart|my-account|checkout|addtocart|add-to-cart)" ||
req.url ~ "\?(add-to-cart|wc-api|wc-ajax)" ||
req.url ~ "/wc-api/") {
return (pass);
}
# PayPal callback — never cache
if (req.url ~ "/paypal/") {
return (pass);
}
# WordPress logged-in / WooCommerce session cookies — never cache
if (req.http.Cookie ~ "wordpress_logged_in_" ||
req.http.Cookie ~ "wordpress_sec_" ||
req.http.Cookie ~ "wp-settings-" ||
req.http.Cookie ~ "wp_woocommerce_session_" ||
req.http.Cookie ~ "woocommerce_cart_hash" ||
req.http.Cookie ~ "woocommerce_items_in_cart" ||
req.http.Cookie ~ "comment_author_") {
return (pass);
}
# Bearer-token auth — never cache
if (req.http.Authorization ~ "^Bearer") {
return (pass);
}
# ─────────────────────────────────────────
# SHARED — Accept-Encoding normalisation
# ─────────────────────────────────────────
if (req.http.Accept-Encoding) {
# Already-compressed formats — encoding header is pointless
if (req.url ~ "\.(jpg|jpeg|png|gif|gz|tgz|bz2|tbz|mp3|mp4|ogg|swf|flv|woff|woff2|webp|avif)$") {
unset req.http.Accept-Encoding;
} elsif (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} elsif (req.http.Accept-Encoding ~ "deflate" && req.http.user-agent !~ "MSIE") {
set req.http.Accept-Encoding = "deflate";
} else {
unset req.http.Accept-Encoding;
}
}
# ─────────────────────────────────────────
# SHARED — strip tracking / marketing query params
# ─────────────────────────────────────────
# FIX: changed A-z to A-Za-z in the character class. A-z includes
# six ASCII characters between Z (0x5A) and a (0x61): [\]^_`
# which could cause unexpected matches on malformed URLs.
# FIX: changed [?|&] to [?&] — the pipe inside a character class
# is a literal "|", not alternation. It would strip a trailing
# pipe from the URL, which is harmless but incorrect.
if (req.url ~ "(\?|&)(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=") {
set req.url = regsuball(req.url, "(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=[-_A-Za-z0-9+()%.]+&?", "");
set req.url = regsub(req.url, "[?&]+$", "");
}
# ─────────────────────────────────────────
# SHARED — strip __SID / noCache params
# ─────────────────────────────────────────
# FIX: same A-z → A-Za-z and [?|&] → [?&] corrections as above.
if (req.url ~ "(\?|&)(__SID|noCache)=") {
set req.url = regsuball(req.url, "(__SID|noCache)=[-_A-Za-z0-9+()%.]+&?", "");
set req.url = regsub(req.url, "[?&]+$", "");
}
# ─────────────────────────────────────────
# SHARED — static file extensions (long cache, strip cookies)
# ─────────────────────────────────────────
# NOTE: this block only fires for non-/community/ URLs because
# the XenForo section above returns (pass) for everything that
# isn't an attachment or compiled CSS/JS. For WordPress and
# other paths, this is the primary static-asset entry point.
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
unset req.http.Cookie;
return (hash);
}
# ── Everything else: strip cookies, cache as guest content ──
# For WordPress pages this means guest HTML is eligible for
# caching, subject to backend Cache-Control headers.
unset req.http.Cookie;
return (hash);
}
# ═════════════════════════════════════════════
# vcl_hash — Cache-key construction
# ═════════════════════════════════════════════
sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}
# ═════════════════════════════════════════════
# vcl_backend_response — Backend reply handling
# ═════════════════════════════════════════════
sub vcl_backend_response {
# ── Ban-lurker metadata ──
# Stored on every cached object so ban expressions using obj.http.*
# can be evaluated in the background without incoming requests.
set beresp.http.X-Url = bereq.url;
set beresp.http.X-Host = bereq.http.host;
# ── Grace: serve stale content for up to 3 days while re-fetching ──
set beresp.grace = 3d;
# ── ESI support for text content ──
if (beresp.http.content-type ~ "text") {
set beresp.do_esi = true;
}
# ── Compress text-based responses in the cache ──
if (beresp.http.content-type ~ "(text|application/json|application/javascript|application/xml|text/xml|text/css)") {
set beresp.do_gzip = true;
}
# ─────────────────────────────────────────
# XENFORO (/community/)
# ─────────────────────────────────────────
if (bereq.url ~ "^/community($|/)") {
# Responses that set session cookies — never cache
if (beresp.http.set-cookie ~ "xf_session" ||
beresp.http.set-cookie ~ "xf_user") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# ── Attachments (200 + 301) ──
# Checked BEFORE the general redirect block so that attachment
# 301s (trailing-slash redirects) are cached rather than marked
# uncacheable. The vcl_recv trailing-slash normalisation should
# prevent most 301s from reaching here, but this serves as a
# safety net for edge cases.
if (bereq.url ~ "^/community/attachments/") {
if (beresp.status == 200) {
unset beresp.http.set-cookie;
# FIX: XenForo sets a legacy Expires header from 1981 on
# all responses. This conflicts with our Cache-Control
# and prevents Cloudflare from caching these assets
# (cfCacheStatus = DYNAMIC). Removing Expires lets the
# Cache-Control header take precedence at every layer.
unset beresp.http.Expires;
set beresp.http.Cache-Control = "public, max-age=604800";
set beresp.ttl = 7d;
return (deliver);
}
if (beresp.status == 301) {
# Cache the trailing-slash redirect itself for 7 days
set beresp.ttl = 7d;
return (deliver);
}
}
# General XenForo redirects (login, post-action, etc.) — never cache
if (beresp.status == 301 || beresp.status == 302 || beresp.status == 303) {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# ── Compiled CSS & versioned JS — 30-day TTL, immutable ──
# FIX: moved ABOVE the extension-based static asset block.
# Previously, a URL like /community/js/xf/core.js?_v=12345
# matched the extension block first (7d) and never reached
# this rule (30d immutable). Order matters: specific rules
# must come before generic ones.
# Cache-busting params (&k= / ?_v=) change on theme/version
# update, so stale copies are impossible. "immutable" tells
# browsers to skip conditional requests entirely.
if ((bereq.url ~ "^/community/css\.php\?css=" ||
bereq.url ~ "^/community/(js|data/js)/.*\?_v=") && beresp.status == 200) {
unset beresp.http.set-cookie;
unset beresp.http.Expires;
set beresp.http.Cache-Control = "public, max-age=2592000, immutable";
set beresp.ttl = 30d;
return (deliver);
}
# Static assets (extension-based) — 7-day TTL
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)(\?.*)?$") {
unset beresp.http.set-cookie;
unset beresp.http.Expires;
set beresp.ttl = 7d;
return (deliver);
}
# ── All other XenForo responses — do not cache ──
# This catches guest HTML pages, error responses, and anything
# not matched above. Guest pages carry unique xf_csrf tokens
# in both Set-Cookie and hidden form fields; caching them would
# serve every guest the same stale token, breaking forms and
# undermining CSRF protection.
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# ─────────────────────────────────────────
# WORDPRESS / general backend responses
# ─────────────────────────────────────────
# Static assets (extension-based) — 7-day TTL
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
unset beresp.http.set-cookie;
set beresp.ttl = 7d;
return (deliver);
}
# Non-200/404 responses — do not cache
if (beresp.status != 200 && beresp.status != 404) {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# Backend explicitly says private / no-cache / no-store — honour it
if (beresp.http.Cache-Control ~ "private" ||
beresp.http.Cache-Control ~ "no-cache" ||
beresp.http.Cache-Control ~ "no-store") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# Strip Set-Cookie from cacheable GET/HEAD responses
if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
unset beresp.http.set-cookie;
}
# No Cache-Control at all — do not cache
if (!beresp.http.Cache-Control) {
set beresp.ttl = 0s;
set beresp.uncacheable = true;
}
return (deliver);
}
# ═════════════════════════════════════════════
# vcl_deliver — Final response to the client
# ═════════════════════════════════════════════
sub vcl_deliver {
# ── Debug headers ──
set resp.http.X-Cache-Age = resp.http.Age;
unset resp.http.Age;
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
# ── Prevent browser caching of dynamic HTML ──
# Static assets keep their backend Cache-Control so browsers cache
# them locally without re-downloading on every page load.
if (resp.http.Cache-Control !~ "private" && resp.http.Content-Type ~ "text/html") {
set resp.http.Pragma = "no-cache";
set resp.http.Expires = "-1";
set resp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0";
}
# ── Strip internal / server-info headers ──
unset resp.http.X-Powered-By;
unset resp.http.Server;
unset resp.http.X-Varnish;
unset resp.http.Via;
unset resp.http.Link;
# Ban-lurker headers — needed internally, not by clients
unset resp.http.X-Url;
unset resp.http.X-Host;
# ── FIX: deduplicate response headers ──
# XenForo / nginx sometimes send duplicate X-Frame-Options,
# X-Content-Type-Options, and Referrer-Policy headers. Browsers
# handle duplicates inconsistently, so we normalise to one each.
if (resp.http.X-Frame-Options) {
set resp.http.X-Frame-Options = "SAMEORIGIN";
}
if (resp.http.X-Content-Type-Options) {
set resp.http.X-Content-Type-Options = "nosniff";
}
if (resp.http.Referrer-Policy) {
set resp.http.Referrer-Policy = "strict-origin-when-cross-origin";
}
}
# ═════════════════════════════════════════════
# vcl_hit — Cache-hit handling
# ═════════════════════════════════════════════
sub vcl_hit {
# Varnish's native grace + background-fetch logic handles stale
# serving automatically as long as beresp.grace is set (3 d).
return (deliver);
}
# ═════════════════════════════════════════════
# vcl_synth — Synthetic responses (errors, purge confirmations)
# ═════════════════════════════════════════════
sub vcl_synth {
if (resp.status == 200) {
set resp.http.Content-Type = "text/plain; charset=utf-8";
synthetic("Success: " + resp.reason);
return (deliver);
}
}