Immutable WordPress architecture
Intent
WordPress is a frequent target of automated attacks that exploit writable core, plugin and theme files. A site here runs from a read-only container image: WordPress core, plugins and the theme are baked in at build time and cannot be modified at runtime. In-application updates are disabled; the only way to change code is to build and deploy a new image.
This buys three things:
-
Security — the web process cannot write to its own code, so a compromised request cannot drop a web-shell into
wp-content. -
Reproducibility — the running site is exactly what the image says it is; there is no drift.
-
Infrastructure as code — the whole site is a git repository plus a small amount of externalised state.
The cost is that convenience features (one-click updates, installing a plugin from the admin) are deliberately unavailable. Plugin provisioning explains how that trade-off is managed.
The three layers
| Layer | Contents | Mutability |
|---|---|---|
Immutable |
WordPress core, plugins, theme, PHP runtime |
Baked into the image; changes only via image rebuild. |
Mutable state |
Database, uploaded media |
External managed MySQL + a persistent volume; survives every redeploy. |
Ephemeral |
Caches, restore staging, Apache runtime files |
|
The whole design follows from keeping these strictly separated.
|
Mutability is a property of the posture, not of the design. The table above describes
|
Build
The image is a Roots Bedrock application built in two stages.
-
Stage 1 (Composer) installs WordPress core (via
roots/wordpress) and all public plugins into the Bedrock layout (web/wp,web/app/plugins). Versions are pinned incomposer.lock. -
Stage 2 (runtime) is the official
php:<version>-apacheimage, pinned to an exact patch release (for examplephp:8.5.12-apache). It installs the PHP extensions WordPress needs (mysqli gd zip exif intl, plusopcachebefore PHP 8.5, where it became built in), WP-CLI and a MySQL client, copies the Composer output, bakes in the private artefacts (see Plugin & theme provisioning), and hardens the core (strips write bits, sets ownership to the runtime user).
The container runs Apache as the non-root www-data user on port 8080.
|
Pin the PHP base to an exact patch release, and build with
--pullA floating tag such as
|
Registry: the image is private
The image bakes licensed, commercial plugin code (see Licensing), so the Docker Hub repository that holds it is private. The chart is published to the same repository name as an OCI artefact, so it is private too. Two credentials follow from that:
-
Image pulls use the
christhonie-dockerimage pull secret, set asimagePullSecretsin the chart values. It already exists in most namespaces on the cluster; for a new namespace, copy it across rather than creating a new one. -
Chart pulls are made by ArgoCD, which needs a repository credential for
registry-1.docker.io(Helm, OCI). Without it an Application on a private chart fails to sync.
Docker Hub creates a repository on first push with the account’s default visibility, which is public. Create the repository as private before the first push of either the image or the chart.
|
The base image tag is chosen to satisfy the theme’s PHP compatibility, not just the latest available. Lint the theme and plugins against the target PHP version at build time; drop the base image a minor version if the theme is not yet clean on the newest PHP. |
Configuration
Bedrock reads all configuration from environment variables (config/application.php), so the
image carries no site-specific config. In Kubernetes these come from:
-
a ConfigMap for non-secret values —
WP_ENV,WP_HOME,WP_SITEURL, database host/name/user, the site title; -
a Secret for
DB_PASSWORD, the eight WordPress salts, and the bootstrap admin credentials.
config/application.php sets DISALLOW_FILE_MODS (production), DISALLOW_FILE_EDIT,
AUTOMATIC_UPDATER_DISABLED, and — critically for a site behind a TLS-terminating ingress —
honours X-Forwarded-Proto so WordPress builds https URLs:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
Storage and the read-only root filesystem
The pod runs with readOnlyRootFilesystem: true. Every path WordPress or a plugin must write
to is a mounted volume; everything else is read-only. The rules for choosing the volume type
are set out in Plugin provisioning — writable
paths. The baseline set:
Path (under wp-content unless noted) |
Volume | Purpose |
|---|---|---|
|
PVC (Longhorn, RWO) |
Uploaded media — the primary persistent state. |
|
separate PVC (Longhorn, RWO) |
Backup staging — must not share the uploads PVC (see UpdraftPlus (Premium)). |
|
|
Plugin/restore unpacking area; the app deletes and recreates it. |
|
|
Regenerable caches / firewall logs. |
|
|
PHP temp + sessions, and Apache’s run/pid/lock dir. |
|
Apache on Debian resolves its PID/run/lock directory from
Editing |
|
A plugin’s own directory is never writable — send diagnostics to
/tmpThe table above lists paths under The event payment plugin hit exactly this. Its generated API client was configured with Guzzle
debug tracing enabled unconditionally, writing to
Under the read-only root filesystem the Two rules follow, and both are now applied in the plugin:
Treat a failure to open a diagnostic file as non-fatal. A plugin must not lose business work
because it could not write a log line — catch |
Database
The site uses a schema on the shared managed MySQL cluster, with a dedicated user scoped to
that schema. The schema and grant are created out-of-band (they are not managed by the chart);
the password lives in the Secret. Bedrock connects over mysqli, so no MySQL client binary is
needed to serve the site — but one is installed anyway so WP-CLI’s db subcommands work
(readiness check, export/import for migrations).
Ingress, TLS and DNS
Standard cluster edge: ingressClassName: nginx, TLS issued by cert-manager
(letsencrypt-prod), force-ssl-redirect. DNS is either managed by external-dns or created
manually, depending on which zone the site’s domain lives in — for a manually-managed zone,
the record must resolve to the ingress load balancer before first sync so the ACME
HTTP-01 challenge can complete.
|
Health probes must be |
Every name the domain answers on needs an ingress host
A host that resolves to the cluster but appears in no Ingress is not redirected anywhere.
ingress-nginx has no rule for it, so it presents its built-in default certificate
(CN=Kubernetes Ingress Controller Fake Certificate) and answers 404. To a visitor that is
a certificate warning, followed by a blank error page.
The usual case is www. A DNS wildcard, or a www record created alongside the apex, sends
it to the cluster even though nobody listed it. Neither WordPress nor cert-manager notices,
because the request never reaches the pod and no certificate is requested for a name the
Ingress does not carry. So list www explicitly in ingress.hosts. The chart gives each host
its own <host>-tls certificate, and WordPress 301s any non-canonical host to WP_HOME once
the request reaches it.
This happened on the Tour de Worcester site: for some time after go-live, www served the
fake certificate, while the apex and the retained staging alias were both fine.
Bootstrap
The site is installed and reconciled by an idempotent WP-CLI init container running the same image (the code is baked in, so it exists at init time — no separate post-deploy Job is needed). On every start it:
-
waits for the database;
-
runs
wp core installon first boot (guarded bywp core is-installed); -
activates the theme and the plugin set;
-
applies store defaults (currency, country, skip the WooCommerce wizard);
-
seeds scheduling options.
Because activation is idempotent and cheap, running it every start keeps the active theme/plugin set reconciled with the image after an upgrade.
The theme, the plugin set and the applied options are chart values, not lines in the
script — so they are the thing a promotion edits, and the chart carries no site-specific
content. Options are applied through WP-CLI’s eval-file rather than a sequence of
wp option update calls: WordPress stores structured options as serialized PHP, which does
not survive shell quoting, and update_option() stores a value exactly the way WordPress
itself would.
In the mutable posture the bootstrap additionally seeds the writable code trees and records a configuration baseline; both are covered in Dev site and promotion.
|
The image has no mail transport
|
WP-Cron
Request-triggered WP-Cron is disabled (DISABLE_WP_CRON=true) and driven out-of-band by a
CronJob that curls \/wp/wp-cron.php. The call must send X-Forwarded-Proto: https, or
WordPress 301-redirects it and cron never runs. Triggering cron over HTTP (rather than mounting
the uploads PVC into a second pod) keeps the RWO volume attached to the single web pod.
Deployment and upgrade
The site is a Helm chart published as an OCI artefact and deployed by ArgoCD (automated sync).
To change anything in the immutable layer — core, a plugin version, PHP — rebuild the image
with a new tag, bump the tag (and chart version if templates changed), and let ArgoCD roll it
out. The Recreate strategy is used because the uploads PVC is ReadWriteOnce.
See Helm Chart Patterns and ArgoCD Deployment for the shared mechanics.
New site checklist
In order, for a new site on this pattern. Each item links to the section that explains it.
-
Source repository cloned from an existing site repository, private on GitHub.
-
Docker Hub repository created private before the first push (see Registry: the image is private).
-
Image built by the site’s
scripts/build-image.sh(exact PHP patch release,--pull); smoke-tested locally against a throwaway database before it is pushed. -
Pull secret
christhonie-dockerpresent in the target namespace; ArgoCD holds a credential for the private chart. -
Database: a dedicated schema and user on the managed MySQL (see Database). Never a schema another application uses.
-
Secrets: the site’s
scripts/create-secret.shgenerates the site Secret (database password, salts, admin login) and the basic-auth Secret, and prints the grant. -
DNS for the staging host resolves to the ingress before the first sync (see Ingress, TLS and DNS).
-
Staging gate: the whole staging host behind basic auth, with no
unauthenticatedPathsunless a test needs one machine endpoint. Under mod_php a PHP core flaw is reachable through any path that reaches PHP, so exempting/wp/defeats the gate. AddWP_NOINDEX: "true". -
Mail route chosen per the sending domain (see FluentSMTP). If the route admits the cluster by source address (an IP-allowlisted relay), record it in the cluster’s register of address-dependent services.
-
SPF for the sending domain covers the server that actually delivers the mail. Through a provider’s relay that is the provider’s servers (for example
include:_spf.google.com), not the cluster, so no cluster address is added. Addip4:entries (a range such asip4:197.189.214.88/29is valid) only if the cluster delivers directly. Remove the old host’sip4:entry once it is decommissioned. -
ArgoCD Application in
idl-xnl-jhb-rc01/argocd/, merged only after the chart and image are published. -
Content: for a site moving from another host, an UpdraftPlus restore of the database and uploads only. Declare as few
bootstrap.optionsas possible on such a site: the immutable posture re-applies them on every start, over the restored settings. -
Cutover, as below.
Staging and cutover
This is the flow for standing up a new site. Once a site is live, a throwaway staging host is the wrong tool for changing it — use the permanent dev posture described in Dev site and promotion, which keeps a promotion path back into the chart instead of ending in a cutover.
A new site is stood up behind a temporary staging host, built out, then cut over to its final domain:
-
add the final host and its
wwwalias toingress.hosts, as separate entries; let cert-manager issue a certificate for each (see Every name the domain answers on needs an ingress host); -
change
WP_HOME/WP_SITEURLand redeploy; -
rewrite stored URLs with
wp search-replace '<staging-host>' '<final-host>'(WordPress stores absolute URLs in content); -
repoint the final domain’s DNS and decommission the old site;
-
check every name the domain answers on — the apex,
www, and any staging host kept as an alias. Each must present its own Let’s Encrypt certificate, and every name except the canonical one must 301 to it:for h in example.co.za www.example.co.za; do echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null \ | openssl x509 -noout -subject -issuer curl -sI "https://$h" | grep -iE '^(HTTP|location)' done
What immutability does not cover
Restoring code (plugins, themes, core) from a backup tool is a no-op — those directories are read-only. A backup/restore workflow therefore covers the database and uploads only; code is "restored" by redeploying the matching image tag. This is called out again on the UpdraftPlus page.
Nor does it cover how the image comes to contain the right code and the site the right settings in the first place. Immutability says the running site cannot drift from its declaration; it says nothing about how that declaration is arrived at. That is the subject of Dev site and promotion.