Skip to main content

component-varnish

A Varnish full-page cache in front of the app, for sites built on oym/craft-cacheable (Surrogate-Key tagging, xkey purges and ESI fragments). Without it that plugin caches no pages and turns every includeEsiBlock() into a loopback HTTP request, so any site that calls setCacheControl() in its templates needs this component.

What it creates (all in the project namespace):

ObjectNamePurpose
StatefulSetvarnishreplicaCount pods, varnish:9.1 (bundles the xkey, cookie and std vmods) plus reloader and metrics sidecars
Servicevarnish-serviceport 80 → listener http (6081). Point the ingress here
Service (headless)varnish-headlessper-pod DNS varnish-0.varnish-headless, … on listener purge (6082). The app sends PURGE here
ConfigMapvarnish-vcl-defaultthe reference VCL, only when vcl.existingConfigMap is empty
PodDisruptionBudgetvarnish-pdbmaxUnavailable: 1
PodMonitorvarnishPrometheus scrapes the metrics sidecar (port 9131) of every pod

Traffic path: Traefik → varnish-service → Varnish → app-service:80. Static assets keep going straight to app-service through the cache-static IngressRoute of component-traefik-ingress.

Enable it​

component-varnish:
enabled: true
component-traefik-ingress:
hosts:
- host: www.example.com
paths:
- path: /
pathType: Prefix
service:
name: varnish-service
port: 80

And in Infisical, for the app:

VARNISH_SERVER=http://varnish-0.varnish-headless:6082,http://varnish-1.varnish-headless:6082
VARNISH_DEFAULT_MAX_AGE=86400
VARNISH_SERVER lists pods by name

The cacheable plugin fans a purge out to every comma-separated entry. List exactly replicaCount pods (varnish-0 … varnish-N), without a trailing slash. A pod that is missing from the list keeps serving stale pages after an editor saves.

Defaults​

image: { repository: varnish, tag: "9.1" }
replicaCount: 2
storage: "malloc,512m" # main cache
transientStorage: "malloc,128m" # uncacheable / short-lived objects, capped so passes can't exhaust the pod
extraArgs: ["-p", "thread_pool_min=50", "-p", "thread_pool_max=1000"]
resources: { requests: { cpu: 100m, memory: 768Mi }, limits: { memory: 1Gi } }
ports: { http: 6081, purge: 6082, admin: 6083 }
vcl: { existingConfigMap: "", key: default.vcl }
reloader: { enabled: true, pollSeconds: 5 }
metrics:
enabled: true
exporterImage: { repository: ghcr.io/otto-de/prometheus-varnish-exporter, tag: "1.8.3-varnish-9.0.0" }
port: 9131
interval: 30s
resources: { requests: { cpu: 10m, memory: 32Mi }, limits: { memory: 64Mi } }
pdb: { enabled: true, maxUnavailable: 1 }

Keep resources.limits.memory above storage + transientStorage + ~128Mi.

Purge isolation​

PURGE is only honoured on the purge listener. Traefik reaches the http listener, so a PURGE from the internet gets a 405 whatever its source IP. Inside the namespace the app talks to varnish-headless:6082 directly. The headless Service publishes not-ready pods too, so a purge during a rolling restart still reaches every replica.

Supported purge requests (what oym/craft-cacheable sends):

HeaderEffect
xkey-purge: tag1 tag2drop every object tagged with one of the keys (Surrogate-Key from Craft)
X-Regex-Url: <regex>ban every object whose URL matches
noneban every object whose URL matches the request URL

Owning the VCL​

The chart ships a reference VCL (k8s/helm/charts/components/varnish/files/default.vcl). Most sites need their own rules (legacy redirects, blocked paths), so the VCL is meant to live as a file in the project's overlays directory, one per environment:

k8s/app/platform/<slug>/
├── definition/xplatformapp.yaml ← overlaysDir: k8s/app/platform/<slug>/overlays
├── helm/values-*.yaml ← component-varnish.vcl.existingConfigMap: varnish-vcl
└── overlays/
├── staging/
│ ├── kustomization.yaml
│ └── default.vcl
└── production/
├── kustomization.yaml
└── default.vcl

kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
generatorOptions:
disableNameSuffixHash: true # fixed name, the reloader sidecar picks up changes
configMapGenerator:
- name: varnish-vcl
files:
- default.vcl

Kustomize generators can only read files inside their own directory, hence one copy per environment. Start from the reference VCL and put site rules in the marked Site redirects block:

if (req.http.x-path ~ "^/old-page/$") { return (synth(301, "/new-page/")); }
if (req.http.x-path ~ "^/gone/") { return (synth(302, "https://other.example.com/")); }

synth(301|302, "<path or url>") is turned into a redirect by vcl_synth; the query string is dropped, like the Apache rules this replaces.

How an edit goes live​

  1. Push the changed default.vcl; ArgoCD syncs the ConfigMap.
  2. Kubelet updates the mounted file inside the pods (up to a minute).
  3. The reloader sidecar notices and runs varnishreload. No pod restart, no cache loss.
  4. A VCL that does not compile is rejected and the previous VCL stays active. Check with:
kubectl -n <slug>-<env> logs varnish-0 -c reloader
kubectl -n <slug>-<env> exec varnish-0 -c varnish -- varnishadm -n /var/lib/varnish vcl.list

Compile-check locally before pushing:

docker run --rm --add-host app-service:127.0.0.1 \
-v "$PWD/default.vcl:/etc/varnish/default.vcl:ro" varnish:9.1 \
varnishd -C -f /etc/varnish/default.vcl > /dev/null

What the reference VCL does​

  • Health: /ping answers 200 Pong without a backend; it is the startup, readiness and liveness probe. Readiness is deliberately not tied to backend health, so Varnish keeps serving grace while the app is down.
  • Grace: objects are kept 6h; a healthy backend gets at most 120s of stale, a sick one the full 6h.
  • ESI is on for every response; Surrogate-Key becomes xkey.
  • Cookies are stripped except on /esm, /actions, /logout (control panel and action requests pass through with only CraftSessionId); Set-Cookie never enters the cache.
  • Trailing-slash redirect (/page → /page/), skipping files, the CP, actions and the api/cache paths.
  • Tracking parameters (utm_*, gclid, fbclid, …) are stripped from the cache key.
  • X-Cache: HIT|MISS and X-Cache-Hits are added, server headers removed.
  • X-Cache-Skip: 1 bypasses the cache; Authorization / X-Sentry-Token requests pass.

Monitoring​

Every pod runs a metrics sidecar that turns varnishstat counters into Prometheus metrics (varnish_main_*, varnish_sma_*, varnish_backend_*). A PodMonitor in the project namespace registers it with the cluster Prometheus, and Grafana has one dashboard for all sites:

OYM / Varnish Cache — filter on Site (namespace) and Pod.

How to read it:

PanelHealthyLook closer when
Hit rate> 80% for a content siteit drops after a deploy (the app stopped sending Cache-Control, or every page now sets a cookie)
Pass sharea few percent (control panel, actions)it climbs: something marks pages uncacheable, see vcl_backend_response
Storage used / LRU evictionsbelow 100%, zero evictionsevictions are sustained: raise storage (and resources.limits.memory)
Fetches refused0the /health probe of app-service marked it sick and Varnish served grace instead of fetching
Child restarts0varnishd panicked; kubectl logs varnish-N -c varnish
Expiry and evictionssteadya step down in Objects in cache without a restart is a purge or ban

The hit rate is hits ÷ (hits + misses). Passes, pipes and synthetic responses (/ping, redirects, purge replies) are never cache lookups and are shown separately.

Each pod's hit rate counts separately: with replicaCount: 2 a page is fetched from the app once per pod, so a cold second pod halves the hit rate until it has seen the same traffic.

varnishstat can only read the shared memory of a varnishd with the same version. The sidecar therefore runs the chart's own varnish image and executes the exporter binary that an init container copies out of metrics.exporterImage, so bumping image.tag never breaks the metrics.

Debugging​

# Hit / miss per request
kubectl -n <ns> exec varnish-0 -c varnish -- varnishlog -n /var/lib/varnish -g request -q 'ReqMethod eq PURGE'
# Hit ratio
kubectl -n <ns> exec varnish-0 -c varnish -- varnishstat -n /var/lib/varnish -1 -f MAIN.cache_hit -f MAIN.cache_miss
# Backend health
kubectl -n <ns> exec varnish-0 -c varnish -- varnishadm -n /var/lib/varnish backend.list