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

emptyDir volumes; discarded on pod restart.

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 in composer.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

uploads

PVC (Longhorn, RWO)

Uploaded media — the primary persistent state.

updraft

separate PVC (Longhorn, RWO)

Backup staging — must not share the uploads PVC (see UpdraftPlus (Premium)).

upgrade

emptyDir

Plugin/restore unpacking area; the app deletes and recreates it.

et-cache, cache, wflogs

emptyDir

Regenerable caches / firewall logs.

/tmp (root fs)

emptyDir

PHP temp + sessions, and Apache’s run/pid/lock dir.

Apache on Debian resolves its PID/run/lock directory from /etc/apache2/envvars, which uses : ${APACHE_RUN_DIR:=/var/run/apache2}. Under a read-only root filesystem it cannot create /var/run/apache2. Set the location to the writable /tmp as image ENV — because the := form keeps any value already present in the environment:

ENV APACHE_RUN_DIR=/tmp APACHE_PID_FILE=/tmp/apache2.pid APACHE_LOCK_DIR=/tmp

Editing envvars with sed does not work — the file has no export VAR= lines to match.

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 tcpSocket on 8080, not httpGet /. WordPress only knows it is being served over https from the ingress' X-Forwarded-Proto; an internal http probe to / gets a 301 redirect that kubelet follows to port 80, which fails — so the pod never becomes Ready. A TCP check confirms Apache is listening without triggering a redirect.

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:

  1. waits for the database;

  2. runs wp core install on first boot (guarded by wp core is-installed);

  3. activates the theme and the plugin set;

  4. applies store defaults (currency, country, skip the WooCommerce wizard);

  5. 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:

  1. add the final host (and www) to the ingress; let cert-manager issue the certificate;

  2. change WP_HOME / WP_SITEURL and redeploy;

  3. rewrite stored URLs with wp search-replace '<staging-host>' '<final-host>' (WordPress stores absolute URLs in content);

  4. 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.