{"id":157,"date":"2026-08-17T00:53:06","date_gmt":"2026-08-17T00:53:06","guid":{"rendered":"http:\/\/127.0.0.1\/en\/docs\/running-wordpress-on-kubernetes-mariadb-galera-varnish-backups\/"},"modified":"2026-08-17T00:54:02","modified_gmt":"2026-08-17T00:54:02","slug":"running-wordpress-on-kubernetes-mariadb-galera-varnish-backups","status":"publish","type":"post","link":"https:\/\/wp.radut.info\/ro\/2026\/08\/17\/running-wordpress-on-kubernetes-mariadb-galera-varnish-backups\/","title":{"rendered":"Running WordPress on Kubernetes: MariaDB Galera, Varnish &#038; Backups"},"content":{"rendered":"<p><strong>Scope:<\/strong> this documents the actual infrastructure this WordPress site (the one you&#8217;re reading this on) runs on \u2014 a Kubernetes-native replacement for an older Ansible\/VM-provisioned stack (nginx+php-fpm, a single MySQL instance, Varnish), deployed via Helm + <code>kubectl apply<\/code> (no ArgoCD for this addon). Real values from this deployment (replica counts, storage classes\/sizes, resource limits) are included throughout; credentials are intentionally omitted \u2014 described by where they live (which Kubernetes Secret \/ values key), never pasted as literal values.<\/p>\n<h2>1. Architecture at a glance<\/h2>\n<p>Ingress (TLS terminated here) \u2192 Varnish (2 replicas, caching) \u2192 WordPress (2 replicas, shared storage) \u2192 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.<\/p>\n<pre><code>Ingress (haproxy + cert-manager)\n  \u251c\u2500 cached host  \u2192 Varnish Service (2 pods)  \u2192 WordPress Service (2 pods) \u2192 MariaDB Galera Service (3 pods)\n  \u2514\u2500 bypass host  \u2192 WordPress Service directly (skips Varnish entirely)<\/code><\/pre>\n<p>Everything lives in its own namespace, deployed with plain <code>helm upgrade --install<\/code> per component (MariaDB Galera \u2192 WordPress \u2192 Varnish \u2192 Ingress, in that order) rather than a single umbrella chart \u2014 matches this repo&#8217;s general addon pattern of curated, minimal Helm values files plus hand-written Kubernetes manifests for anything the charts don&#8217;t cover.<\/p>\n<h2>2. Why WordPress needs CephFS but MariaDB is fine on RBD<\/h2>\n<p>The two Ceph-backed storage classes available in this cluster are <strong>RBD<\/strong> (block storage, <code>ReadWriteOnce<\/code> \u2014 exactly one pod can mount a given volume at a time) and <strong>CephFS<\/strong> (a real shared filesystem, <code>ReadWriteMany<\/code> \u2014 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 <em>same<\/em> files at once?<\/p>\n<ul>\n<li><strong>MariaDB Galera \u2192 RBD.<\/strong> Each of the 3 Galera pods is an independent database node with its own local datadir \u2014 node 1 never reads node 2&#8217;s files directly, replication happens over the wire via the Galera protocol itself. Three pods, three separate RBD volumes, each <code>ReadWriteOnce<\/code> to its own pod. No sharing needed.<\/li>\n<li><strong>WordPress \u2192 CephFS.<\/strong> WordPress runs 2 replicas behind the same Service, and both need to see the <em>identical<\/em> <code>wp-content<\/code> directory \u2014 the same uploaded media, the same active-plugin state, the same &#8220;WordPress is already installed&#8221; 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 <code>ContainerCreating<\/code> forever waiting for a volume already claimed elsewhere. CephFS&#8217;s RWX access mode is what makes a >1 WordPress replica count possible at all.<\/li>\n<\/ul>\n<p>Concretely, in the Helm values: MariaDB Galera&#8217;s <code>persistence.storageClass<\/code> is the RBD storage class at 10Gi per node (3 independent volumes); WordPress&#8217;s <code>persistence.storageClass<\/code> is the CephFS storage class with <code>accessModes: [ReadWriteMany]<\/code> at 20Gi (one shared volume, both pods attached).<\/p>\n<h2>3. Deploying it<\/h2>\n<p>Order matters \u2014 MariaDB Galera has to exist before WordPress&#8217;s external-database values can point at it, and Varnish&#8217;s VCL backend has to resolve WordPress&#8217;s Service:<\/p>\n<pre><code>helm upgrade --install mariadb-galera oci:\/\/registry-1.docker.io\/bitnamicharts\/mariadb-galera \\\n  -n wordpress --create-namespace --values values-mariadb-galera.yaml --wait --timeout=10m\n\nhelm upgrade --install wordpress oci:\/\/registry-1.docker.io\/bitnamicharts\/wordpress \\\n  -n wordpress --values values-wordpress.yaml --wait --timeout=10m\n\nhelm upgrade --install varnish oci:\/\/registry-1.docker.io\/varnish\/varnish-cache \\\n  -n wordpress --values values-varnish.yaml --wait --timeout=5m\n\nkubectl apply -f ingress.yaml<\/code><\/pre>\n<p><strong>A real gotcha worth knowing before you hit it yourself:<\/strong> Bitnami relocated every one of its chart images to a different Docker Hub repository in August 2025 \u2014 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&#8217;re starting from a stock copy of either chart&#8217;s default values, you&#8217;ll need the same override or the pods will sit in <code>ImagePullBackOff<\/code>.<\/p>\n<p>WordPress&#8217;s plugin and theme set is installed once, automatically, via a first-boot init script (a Helm <code>customPostInitScripts<\/code> hook) \u2014 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&#8217;t silently drop a plugin) and does not hard-fail the whole script if one plugin is permanently gone from the registry \u2014 it logs a summary of failures at the end instead, so a dead\/renamed plugin doesn&#8217;t crash-loop the container.<\/p>\n<h2>4. Offloading media: the &#8220;stateless&#8221; plugin<\/h2>\n<p>A media-offload plugin (the well-known &#8220;WP-Stateless&#8221;-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:<\/p>\n<ul>\n<li>The shared CephFS <code>wp-content<\/code> volume doesn&#8217;t grow unbounded as media accumulates \u2014 uploads land in object storage, not the PVC, so a fixed-size volume (20Gi here) doesn&#8217;t need to be resized every time a new batch of images gets uploaded.<\/li>\n<li>It keeps the WordPress pods themselves closer to stateless in practice \u2014 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.<\/li>\n<\/ul>\n<p>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 <code>Deployment<\/code> (single replica \u2014 nginx&#8217;s on-disk <code>proxy_cache<\/code> isn&#8217;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&#8217;d want in front of object storage. It&#8217;s fronted by its own <code>Ingress<\/code> host, separate from the two WordPress hostnames.<\/p>\n<h2>5. Varnish caching &amp; purging<\/h2>\n<p>Varnish sits in front of WordPress on the production hostname only \u2014 the bypass hostname skips it entirely. Cache purging is triggered from inside WordPress (a caching-integration plugin) via an HTTP <code>PURGE<\/code> request carrying a shared secret header; Varnish&#8217;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&#8217;t map cleanly onto a Kubernetes Service&#8217;s pod-churn IPs the way they did on a single static VM).<\/p>\n<p>That purge key, the Varnish admin secret, and the session-cookie name Varnish uses to decide &#8220;is this visitor logged in, and therefore must bypass cache&#8221; are kept byte-identical to the values already baked into the imported production database&#8217;s cache-plugin settings \u2014 change them here without also updating the corresponding database rows and purge-from-wp-admin silently stops working. Not shown here since it&#8217;s a live secret value, not because the mechanism is complicated.<\/p>\n<h2>6. Backup &amp; restore<\/h2>\n<p>Two independent paths exist for backing up the database, both producing the same filename shape (a fixed-width, sortable timestamp \u2014 <code>&lt;service&gt;_&lt;YYYYMMDD-HHMM&gt;.sql.gz<\/code>, one folder per calendar month \u2014 so &#8220;find the latest backup&#8221; is always just &#8220;the last filename alphabetically&#8221;, no need to parse dates):<\/p>\n<ul>\n<li><strong>On-demand, from your own machine<\/strong> \u2014 a local script port-forwards to the Galera cluster&#8217;s Service and pipes a <code>mysqldump<\/code>\/<code>mariadb-dump<\/code>-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&#8217;t have, unless you explicitly disable that query), and retries the dump up to 3 times if it hits a transient &#8220;table definition changed&#8221; error \u2014 which does happen for real on a live site, if WordPress&#8217;s cron or a plugin runs a schema change in the middle of a <code>--single-transaction<\/code> dump.<\/li>\n<li><strong>Automated, in-cluster, every 6 hours<\/strong> \u2014 a <code>CronJob<\/code> 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 <code>kubectl create job --from=cronjob\/&lt;name&gt; &lt;job-name&gt;<\/code>.<\/li>\n<\/ul>\n<p>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 \u2014 or you can point it at any specific dump file, including the original one-time migration dump this whole stack was seeded from.<\/p>\n<p><strong>Known gap, worth calling out rather than glossing over:<\/strong> as of this writing there&#8217;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 \u2014 everything lives on the same underlying Ceph cluster that also backs the live volumes it&#8217;s protecting against. Both are reasonable near-term hardening items for this setup, not yet implemented.<\/p>\n<h2>7. A subtle real gotcha: WordPress behind a TLS-terminating Ingress<\/h2>\n<p>TLS terminates once, at the Ingress \u2014 every hop behind it (Ingress \u2192 Varnish \u2192 WordPress) is plain HTTP. Out of the box, this silently breaks WordPress&#8217;s own idea of &#8220;is this request secure&#8221;: WordPress&#8217;s <code>is_ssl()<\/code> 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.<\/p>\n<p>That one function backs more than you&#8217;d expect \u2014 <strong>Application Passwords<\/strong> (WordPress&#8217;s built-in REST-API auth mechanism) refuses to work at all unless <code>is_ssl()<\/code> is true, and secure-cookie behavior and any plugin logic gating on &#8220;is this connection secure&#8221; silently degrades the same way. The fix is a standard one for anything running WordPress behind a TLS-terminating proxy: trust the <code>X-Forwarded-Proto<\/code> header the Ingress sets, and tell WordPress its connection is secure whenever that header says <code>https<\/code>. This is wired in as a small snippet injected directly into <code>wp-config.php<\/code> via the chart&#8217;s extra-config-content mechanism, so it survives a from-scratch redeploy rather than being a manual hand-patch on the live pod.<\/p>\n<h2>Summary<\/h2>\n<ol>\n<li>Ingress terminates TLS; everything behind it is plain HTTP \u2014 including the X-Forwarded-Proto fix that makes WordPress aware it&#8217;s actually secure (\u00a77)<\/li>\n<li>Varnish (2 replicas) caches the production hostname; a second hostname bypasses it entirely for debugging (\u00a71, \u00a75)<\/li>\n<li>WordPress (2 replicas) needs CephFS (RWX) because both pods share one <code>wp-content<\/code>; MariaDB Galera (3 replicas) is fine on RBD (RWO) because each node owns its own volume (\u00a72)<\/li>\n<li>Media is offloaded to object storage via a stateless-storage plugin, fronted by its own in-cluster caching proxy (\u00a74)<\/li>\n<li>Database backups run two ways \u2014 on-demand locally, and automated every 6h in-cluster \u2014 both with retry-on-transient-DDL-error handling; restore auto-finds the latest dump (\u00a76)<\/li>\n<li>Known gap: no backup retention\/pruning and no off-cluster copy yet (\u00a76)<\/li>\n<\/ol>\n","protected":false},"excerpt":{"rendered":"<p>Scope: this documents the actual infrastructure this WordPress site (the one you&#8217;re reading this on) runs on \u2014 a Kubernetes-native replacement for an older Ansible\/VM-provisioned&#8230;<\/p>\n<div class=\"more-link-wrapper\"><a class=\"more-link\" href=\"https:\/\/wp.radut.info\/ro\/2026\/08\/17\/running-wordpress-on-kubernetes-mariadb-galera-varnish-backups\/\">Continue reading<span class=\"screen-reader-text\">Running WordPress on Kubernetes: MariaDB Galera, Varnish &#038; Backups<\/span><\/a><\/div>","protected":false},"author":0,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"qubely_global_settings":"","qubely_interactions":"","_coblocks_attr":"","_coblocks_dimensions":"","_coblocks_responsive_height":"","_coblocks_accordion_ie_support":"","_uag_custom_page_level_css":"","footnotes":""},"categories":[18,15,1],"tags":[],"class_list":["post-157","post","type-post","status-publish","format-standard","hentry","category-devops","category-kubernetes","category-uncategorized","entry"],"qubely_featured_image_url":null,"qubely_author":{"display_name":"","author_link":"https:\/\/wp.radut.info\/ro\/author\/"},"qubely_comment":0,"qubely_category":"<a href=\"https:\/\/wp.radut.info\/ro\/category\/devops\/\" rel=\"category tag\">DevOps<\/a> <a href=\"https:\/\/wp.radut.info\/ro\/category\/kubernetes\/\" rel=\"category tag\">Kubernetes<\/a> <a href=\"https:\/\/wp.radut.info\/ro\/category\/uncategorized\/\" rel=\"category tag\">Uncategorized<\/a>","qubely_excerpt":"Scope: this documents the actual infrastructure this WordPress site (the one you&#8217;re reading this on) runs on \u2014 a Kubernetes-native replacement for an older Ansible\/VM-provisioned&#8230;Continue readingRunning WordPress on Kubernetes: MariaDB Galera, Varnish &#038; Backups","uagb_featured_image_src":{"full":false,"thumbnail":false,"medium":false,"medium_large":false,"large":false,"1536x1536":false,"2048x2048":false,"trp-custom-language-flag":false,"qubely_landscape":false,"qubely_portrait":false,"qubely_thumbnail":false},"uagb_author_info":{"display_name":"","author_link":"https:\/\/wp.radut.info\/ro\/author\/"},"uagb_comment_info":0,"uagb_excerpt":"Scope: this documents the actual infrastructure this WordPress site (the one you&#8217;re reading this on) runs on \u2014 a Kubernetes-native replacement for an older Ansible\/VM-provisioned&#8230;Continue readingRunning WordPress on Kubernetes: MariaDB Galera, Varnish &#038; Backups","_links":{"self":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts\/157","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/comments?post=157"}],"version-history":[{"count":1,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts\/157\/revisions"}],"predecessor-version":[{"id":160,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts\/157\/revisions\/160"}],"wp:attachment":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/media?parent=157"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/categories?post=157"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/tags?post=157"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}