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.
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
php:8.3-apache. It installs the PHP extensions WordPress needs (mysqli gd zip exif intl opcache), 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.
|
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 |
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 |
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.
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.
Staging and 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
www) to the ingress; let cert-manager issue the certificate; -
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.
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.