Skip to content

Running WordPress on Kubernetes: MariaDB Galera, Varnish & Backups

Scope: this documents the actual infrastructure this WordPress site (the one you’re reading this on) runs on — a Kubernetes-native replacement for an older Ansible/VM-provisioned stack (nginx+php-fpm, a single MySQL instance, Varnish), deployed via Helm + kubectl apply (no ArgoCD for this addon). Real values from this deployment (replica counts, storage classes/sizes, resource limits) are included throughout; credentials are intentionally omitted — described by where they live (which Kubernetes Secret / values key), never pasted as literal values.

1. Architecture at a glance

Ingress (TLS terminated here) → Varnish (2 replicas, caching) → WordPress (2 replicas, shared storage) → MariaDB Galera (3 replicas, one volume each). Two public hostnames point at different points in that chain: the cached production path, and a direct cache-bypass path for debugging whether a problem is in the app or in the cache layer.

Ingress (haproxy + cert-manager)
  ├─ cached host  → Varnish Service (2 pods)  → WordPress Service (2 pods) → MariaDB Galera Service (3 pods)
  └─ bypass host  → WordPress Service directly (skips Varnish entirely)

Everything lives in its own namespace, deployed with plain helm upgrade --install per component (MariaDB Galera → WordPress → Varnish → Ingress, in that order) rather than a single umbrella chart — matches this repo’s general addon pattern of curated, minimal Helm values files plus hand-written Kubernetes manifests for anything the charts don’t cover.

2. Why WordPress needs CephFS but MariaDB is fine on RBD

The two Ceph-backed storage classes available in this cluster are RBD (block storage, ReadWriteOnce — exactly one pod can mount a given volume at a time) and CephFS (a real shared filesystem, ReadWriteMany — many pods can mount the same volume concurrently). Which one a component needs comes down to one question: do multiple pods need to see the same files at once?

  • MariaDB Galera → RBD. Each of the 3 Galera pods is an independent database node with its own local datadir — node 1 never reads node 2’s files directly, replication happens over the wire via the Galera protocol itself. Three pods, three separate RBD volumes, each ReadWriteOnce to its own pod. No sharing needed.
  • WordPress → CephFS. WordPress runs 2 replicas behind the same Service, and both need to see the identical wp-content directory — the same uploaded media, the same active-plugin state, the same “WordPress is already installed” marker file the first-boot script writes. If this were an RBD (RWO) volume, only one of the two pods could ever mount it; the second would get stuck in ContainerCreating forever waiting for a volume already claimed elsewhere. CephFS’s RWX access mode is what makes a >1 WordPress replica count possible at all.

Concretely, in the Helm values: MariaDB Galera’s persistence.storageClass is the RBD storage class at 10Gi per node (3 independent volumes); WordPress’s persistence.storageClass is the CephFS storage class with accessModes: [ReadWriteMany] at 20Gi (one shared volume, both pods attached).

3. Deploying it

Order matters — MariaDB Galera has to exist before WordPress’s external-database values can point at it, and Varnish’s VCL backend has to resolve WordPress’s Service:

helm upgrade --install mariadb-galera oci://registry-1.docker.io/bitnamicharts/mariadb-galera \
  -n wordpress --create-namespace --values values-mariadb-galera.yaml --wait --timeout=10m

helm upgrade --install wordpress oci://registry-1.docker.io/bitnamicharts/wordpress \
  -n wordpress --values values-wordpress.yaml --wait --timeout=10m

helm upgrade --install varnish oci://registry-1.docker.io/varnish/varnish-cache \
  -n wordpress --values values-varnish.yaml --wait --timeout=5m

kubectl apply -f ingress.yaml

A real gotcha worth knowing before you hit it yourself: Bitnami relocated every one of its chart images to a different Docker Hub repository in August 2025 — the public image tags these charts reference by default now 404. Both the MariaDB Galera and WordPress values files explicitly override the image repository to point at the relocated location; if you’re starting from a stock copy of either chart’s default values, you’ll need the same override or the pods will sit in ImagePullBackOff.

WordPress’s plugin and theme set is installed once, automatically, via a first-boot init script (a Helm customPostInitScripts hook) — whichever of the 2 pods boots first runs it against the shared CephFS volume; the second pod sees WordPress already installed and just starts serving. The script wraps every plugin install in a 3-attempt retry (a transient WordPress.org download hiccup shouldn’t silently drop a plugin) and does not hard-fail the whole script if one plugin is permanently gone from the registry — it logs a summary of failures at the end instead, so a dead/renamed plugin doesn’t crash-loop the container.

4. Offloading media: the “stateless” plugin

A media-offload plugin (the well-known “WP-Stateless”-style plugin for Google Cloud Storage) rewrites every media upload URL to point at an external GCS bucket instead of storing files on local pod storage. This is genuinely useful in a k8s context for two reasons:

  • The shared CephFS wp-content volume doesn’t grow unbounded as media accumulates — uploads land in object storage, not the PVC, so a fixed-size volume (20Gi here) doesn’t need to be resized every time a new batch of images gets uploaded.
  • It keeps the WordPress pods themselves closer to stateless in practice — the PVC still holds plugin/theme code and the WordPress install itself, but the fastest-growing, least-code-like data (media) lives outside it entirely.

Because the imported production database already has thousands of post-content/metadata references baked in as absolute URLs pointing at a caching-proxy hostname in front of that bucket, this addon also ships a small in-cluster nginx Deployment (single replica — nginx’s on-disk proxy_cache isn’t safe for concurrent multi-process access to the same cache directory) that reverse-proxies and caches that hostname, rewriting requests through to the GCS bucket with the standard cache-control/gzip/stale-while-revalidate tuning you’d want in front of object storage. It’s fronted by its own Ingress host, separate from the two WordPress hostnames.

5. Varnish caching & purging

Varnish sits in front of WordPress on the production hostname only — the bypass hostname skips it entirely. Cache purging is triggered from inside WordPress (a caching-integration plugin) via an HTTP PURGE request carrying a shared secret header; Varnish’s VCL checks that header against a fixed key before honoring the purge, rather than trusting purge requests by source IP alone (IP-based ACLs don’t map cleanly onto a Kubernetes Service’s pod-churn IPs the way they did on a single static VM).

That purge key, the Varnish admin secret, and the session-cookie name Varnish uses to decide “is this visitor logged in, and therefore must bypass cache” are kept byte-identical to the values already baked into the imported production database’s cache-plugin settings — change them here without also updating the corresponding database rows and purge-from-wp-admin silently stops working. Not shown here since it’s a live secret value, not because the mechanism is complicated.

6. Backup & restore

Two independent paths exist for backing up the database, both producing the same filename shape (a fixed-width, sortable timestamp — <service>_<YYYYMMDD-HHMM>.sql.gz, one folder per calendar month — so “find the latest backup” is always just “the last filename alphabetically”, no need to parse dates):

  • On-demand, from your own machine — a local script port-forwards to the Galera cluster’s Service and pipes a mysqldump/mariadb-dump-compatible dump through the tunnel. It automatically works around a real Oracle-MySQL-8-vs-MariaDB incompatibility (a MySQL 8+ client queries a system table MariaDB doesn’t have, unless you explicitly disable that query), and retries the dump up to 3 times if it hits a transient “table definition changed” error — which does happen for real on a live site, if WordPress’s cron or a plugin runs a schema change in the middle of a --single-transaction dump.
  • Automated, in-cluster, every 6 hours — a CronJob writing to its own dedicated CephFS-backed volume (200Gi, separate from the WordPress content volume), with the same retry-on-transient-error logic built into its inline dump script. It can also be triggered manually on demand outside its schedule via kubectl create job --from=cronjob/<name> <job-name>.

Restoring is the mirror of the on-demand path: a script port-forwards to Galera the same way and, with no arguments, automatically finds and imports whichever backup file sorts last (i.e. the newest) under the local backups directory — or you can point it at any specific dump file, including the original one-time migration dump this whole stack was seeded from.

Known gap, worth calling out rather than glossing over: as of this writing there’s no retention/pruning on the automated 6-hourly backups (the CephFS volume is large but finite) and no off-cluster copy of any backup — everything lives on the same underlying Ceph cluster that also backs the live volumes it’s protecting against. Both are reasonable near-term hardening items for this setup, not yet implemented.

7. A subtle real gotcha: WordPress behind a TLS-terminating Ingress

TLS terminates once, at the Ingress — every hop behind it (Ingress → Varnish → WordPress) is plain HTTP. Out of the box, this silently breaks WordPress’s own idea of “is this request secure”: WordPress’s is_ssl() check only looks at the literal connection it received, which is always plain HTTP from its point of view, so it always evaluates to false no matter how the visitor actually connected.

That one function backs more than you’d expect — Application Passwords (WordPress’s built-in REST-API auth mechanism) refuses to work at all unless is_ssl() is true, and secure-cookie behavior and any plugin logic gating on “is this connection secure” silently degrades the same way. The fix is a standard one for anything running WordPress behind a TLS-terminating proxy: trust the X-Forwarded-Proto header the Ingress sets, and tell WordPress its connection is secure whenever that header says https. This is wired in as a small snippet injected directly into wp-config.php via the chart’s extra-config-content mechanism, so it survives a from-scratch redeploy rather than being a manual hand-patch on the live pod.

Summary

  1. Ingress terminates TLS; everything behind it is plain HTTP — including the X-Forwarded-Proto fix that makes WordPress aware it’s actually secure (§7)
  2. Varnish (2 replicas) caches the production hostname; a second hostname bypasses it entirely for debugging (§1, §5)
  3. WordPress (2 replicas) needs CephFS (RWX) because both pods share one wp-content; MariaDB Galera (3 replicas) is fine on RBD (RWO) because each node owns its own volume (§2)
  4. Media is offloaded to object storage via a stateless-storage plugin, fronted by its own in-cluster caching proxy (§4)
  5. Database backups run two ways — on-demand locally, and automated every 6h in-cluster — both with retry-on-transient-DDL-error handling; restore auto-finds the latest dump (§6)
  6. Known gap: no backup retention/pruning and no off-cluster copy yet (§6)
Published inDevOpsKubernetesUncategorized
ro_RORO