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.

Mutability is a property of the posture, not of the design. The table above describes mode: immutable, which is what production runs. The same chart and image lineage also run a mode: mutable posture in which the plugin and theme trees become writable — that is the dev site, and it exists so the immutable layer can be built somewhere before it has to be correct in production. Core stays read-only in both. See Dev site and promotion.

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 the official php:<version>-apache image, pinned to an exact patch release (for example php:8.5.12-apache). It installs the PHP extensions WordPress needs (mysqli gd zip exif intl, plus opcache before 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 --pull

A floating tag such as php:8.5-apache, built on a machine that has built before, reuses the cached base: a "rebuild" to pick up a PHP security release ships the old PHP. This happened with the PHP core flaws CVE-2026-109618/109619 (unauthenticated remote code execution, worst under mod_php, which this image is). Name the patched release in FROM, pass --pull in the build script, and confirm with php -v inside the running pod after every image bump.

php.ini also sets session.upload_progress.enabled = 0 (the unauthenticated path into CVE-2026-109619) and, on PHP 8.5, fatal_error_backtraces = 0, which 8.5 turns on by default.

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-docker image pull secret, set as imagePullSecrets in 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

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.

A plugin’s own directory is never writable — send diagnostics to /tmp

The table above lists paths under wp-content that are mounted. A plugin directory is not among them: plugin code is baked into the image, so anything a plugin tries to write beside its own source fails. This is easy to miss because such writes are usually diagnostic, and the code that performs them is often only exercised in production.

The event payment plugin hit exactly this. Its generated API client was configured with Guzzle debug tracing enabled unconditionally, writing to debug.log inside the plugin directory. The generated client opens that file before issuing the request:

if ($this->config->getDebug()) {
    $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
    if (!$options[RequestOptions::DEBUG]) {
        throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
    }
}

Under the read-only root filesystem the fopen always failed, so every outbound call to admin-service raised RuntimeException and no HTTP request was ever sent. Because the caller caught only ApiException, the failure was silent: order payments completed in WooCommerce and were never reported back, leaving them unpaid in admin-service.

Two rules follow, and both are now applied in the plugin:

  • Diagnostic tracing is off unless explicitly enabled — the client’s debug flag is driven by the plugin’s own Enable Logging setting (epa_enable_logging) rather than hard-coded on.

  • When it is enabled, the trace file is written under /tmp, which is already an emptyDir in the baseline table above. /tmp is the right home for this: the trace is transient, is wanted only while diagnosing, and must never be mistaken for durable state.

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 Throwable around the call, not just the API’s own exception type.

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.

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:

  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.

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

php:8.3-apache ships no MTA, and sendmail_path points at a binary that is not there, so wp_mail() fails and returns false with nothing logged. Every dependent feature — WooCommerce order mail, password resets, form notifications, UpdraftPlus backup reports — silently does nothing until an SMTP plugin supplies a transport. See FluentSMTP, which also covers why the SMTP credentials are wp-config constants rather than database settings.

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.

  1. Source repository cloned from an existing site repository, private on GitHub.

  2. Docker Hub repository created private before the first push (see Registry: the image is private).

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

  4. Pull secret christhonie-docker present in the target namespace; ArgoCD holds a credential for the private chart.

  5. Database: a dedicated schema and user on the managed MySQL (see Database). Never a schema another application uses.

  6. Secrets: the site’s scripts/create-secret.sh generates the site Secret (database password, salts, admin login) and the basic-auth Secret, and prints the grant.

  7. DNS for the staging host resolves to the ingress before the first sync (see Ingress, TLS and DNS).

  8. Staging gate: the whole staging host behind basic auth, with no unauthenticatedPaths unless 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. Add WP_NOINDEX: "true".

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

  10. 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. Add ip4: entries (a range such as ip4:197.189.214.88/29 is valid) only if the cluster delivers directly. Remove the old host’s ip4: entry once it is decommissioned.

  11. ArgoCD Application in idl-xnl-jhb-rc01/argocd/, merged only after the chart and image are published.

  12. Content: for a site moving from another host, an UpdraftPlus restore of the database and uploads only. Declare as few bootstrap.options as possible on such a site: the immutable posture re-applies them on every start, over the restored settings.

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

  1. add the final host and its www alias to ingress.hosts, as separate entries; let cert-manager issue a certificate for each (see Every name the domain answers on needs an ingress host);

  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;

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