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

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.

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.

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

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.