Dev site and promotion
The problem immutability creates
Immutability buys security, reproducibility and
infrastructure-as-code, and it takes away the place where a site is normally built. With
DISALLOW_FILE_MODS set there is nowhere to trial a plugin, discover the right WooCommerce
setting, or lay out a page before it has to be correct in front of customers.
The answer is not to relax production. It is a second posture of the same chart and the same image, plus a repeatable way to turn what was discovered there back into the declarative artefacts production is built from.
Two postures, one chart
mode selects the posture. It is a runtime posture, not an environment.
mode: immutable |
mode: mutable |
|
|---|---|---|
Where |
Production |
The dev site — locally and in-cluster |
|
|
|
|
Baked into the image, read-only |
PVCs, seeded from the image on every start |
|
Read-only |
Read-only. Core is pinned by |
Declared options |
Re-applied on every start |
Applied only when the declared config itself changes |
Two of those rows are the design, not details.
Core stays pinned even in dev
Only plugins and themes become writable. Core does not. A core upgrade goes through
composer require roots/wordpress:^X and an image rebuild, which is reproducible and
reviewable; clicking Update in wp-admin would desynchronise composer.lock from the running
site with nothing to show for it. There is discovery value in installing a plugin to see
whether it fits. There is none in pressing an update button.
The mutable posture must not re-apply declared config on every start
In production, re-applying the declared options on every start is the point: the site cannot drift. Doing the same in dev would revert the very setting you went in to change, on the next pod restart, before it could be harvested.
So the mutable posture applies declared config only when the declared config itself has
changed. The chart hashes the theme, plugin list and options into DECLARED_CONFIG_HASH; the
bootstrap compares it to the hash recorded beside the baseline. Equal means "nothing was
promoted since last time" — leave the dev site alone. Different means a promotion landed —
re-apply, and re-baseline.
The seed overlay
A PVC mounted at wp-content/plugins hides whatever the image put there. So the mutable
image (--target runtime-dev) keeps a pristine copy outside the webroot at /opt/wp-seed,
and the bootstrap overlays it onto the PVCs on every start:
cp -r /opt/wp-seed/plugins/. web/app/plugins/
cp -r /opt/wp-seed/themes/. web/app/themes/
|
Not
cp -a, and no --preserve
This is not hypothetical: it is what Locally the same ownership mismatch means the bind-mounted trees must be made group-writable before the container can seed or install into them. |
Overlay, never mirror. The image wins per file, so a rebuilt image’s plugin upgrade lands; nothing is deleted, so a plugin installed through wp-admin survives a restart. The useful consequence: the difference between the PVC and the image is exactly what dev has and production does not — which is what the harvest reads.
|
A |
Namespaces, and how this scales to several sites
The namespace boundary is the posture, not the site. Each WordPress tenant is a release
inside it, exactly as event-dev holds several services rather than one namespace per service.
wordpress |
wordpress-dev |
|
|---|---|---|
Holds |
Every tenant’s production site |
A dev variant of whichever tenants currently need one |
Per site |
One Helm release, its own |
The same, plus its own basic-auth credential |
Set up once |
— |
Registry pull secret, RBAC, and whatever network policy the namespace carries |
So adding a second tenant does not add a namespace. It adds a release in wordpress, and —
only while someone is actually building or changing that site — a release in wordpress-dev.
A dev site is a tool, not a fixture
Nothing requires a standing dev variant per tenant. Most tenants will not have one most of the time. Create it for a build-out or a round of changes, harvest what it taught you, and delete the release.
Deleting the release does not delete its data: the PVCs carry
helm.sh/resource-policy: keep, deliberately, so that an ArgoCD prune or a mistaken sync
cannot destroy plugin work that has not been harvested yet. That protection is also why the
storage does not disappear on its own — clean it up explicitly when the site is genuinely
finished with:
kubectl -n wordpress-dev delete pvc \
<release>-uploads <release>-updraft <release>-plugins <release>-themes
The code PVCs are sized small (2Gi) precisely because they hold a plugin tree of a few hundred megabytes, and every dev site in the namespace pays for them.
Why not one namespace per tenant
It is a defensible alternative, and there is precedent for it in this estate. It buys
tenant-to-tenant isolation: today every site in wordpress shares a secret scope, so a single
RBAC grant covers all of them.
That is worth having if the tenants are genuinely separate parties. It is not what the
prod/dev split is for, and the two decisions are independent — production already makes this
trade, and the dev namespace simply mirrors whatever production does. If tenant isolation is
ever needed, both sides move together to wp-<tenant> / wp-<tenant>-dev, and nothing in this
design resists that.
What the prod/dev boundary buys is different, and is worth keeping either way: a dev site runs
a deliberately weaker posture — writable code, WP_DEBUG on, wp-admin installs allowed — and
after a content refresh it holds real customer data. Sharing a namespace with production would
mean one RBAC grant spanning both.
Three lanes
Code, configuration and content move by different mechanisms, in different directions, with different degrees of automation. Conflating them is the usual way a WordPress promotion goes wrong.
| Lane | Contents | Direction | Mechanism |
|---|---|---|---|
Code |
core, plugins, themes |
dev → prod |
Harvest → |
Config |
|
dev → prod |
Harvest → the chart’s |
Content |
posts, pages, layouts, media |
both, asymmetric |
UpdraftPlus Migrator (see plugins/updraftplus.adoc#content-lane) |
Code
Whatever is installed in the dev site is compared against three sources of truth:
composer.lock, packages/, and the chart’s activation list. Anything in dev and in none of
them was installed by hand and needs promoting. Each is then classified against wordpress.org
— public ones become a Composer requirement, private ones are staged out of the running dev
site as a tarball for packages/.
|
A private artefact staged this way came out of a site somebody was clicking around in. Check its provenance and licence before it is baked into a production image. The harvest flags it; it does not decide for you. |
Config
The bootstrap records a baseline — every option, dumped after the declared state was applied — when the dev site is deployed. The harvest diffs the current options against that baseline, which answers precisely "what did I change since this site was deployed?" rather than the far noisier "how does dev differ from production?".
Survivors pass two filters:
-
harvest/option-denylist.txt— noise (transients, cron, caches, update timestamps) and decisions you have already made. Appending to it is how the list becomes quiet over time. -
A secret filter that no file can switch off. It matches credential-shaped option names, and credential-shaped sub-keys inside serialized values — because the dangerous case is the one that does not look dangerous.
woocommerce_paygate_settingsis an innocuous name holding a merchant key.
The secret filter searches an option’s nested keys, recursively, not its text — searching the text would fire on any prose containing the word "key". It errs deliberately towards over-blocking: wrongly withholding a harmless setting costs one line in the denylist, while wrongly promoting a credential puts it in git history.
Credentials are never promoted through a git-tracked values file. They reach the site as
environment variables from the Secret, the way the EPA_* settings already do.
|
The denylist is checked before the secret filter. Both outcomes are "not promoted", so the
order cannot leak anything — it only decides which bucket a key is reported in. Labelling known
noise like |
Content
Content is the one lane with no declarative form worth building. It moves through UpdraftPlus Migrator, and the two directions are not symmetric — see the UpdraftPlus page for the rules and the reason a forward database restore must never happen.
After a production refresh, scrub
Pulling production content into dev copies real customers, real orders and the live payment and API settings. Until the site has been scrubbed it is a second copy of production’s personal data wired to production’s integrations.
The scrub de-indexes the site, disables every payment gateway, repoints the event payment
plugin away from production, resets user passwords and clears the inherited cron schedule. It
refuses to run against a site whose siteurl does not look like a dev host, because it is
destructive by design.
Basic-auth on the ingress and blog_public = 0 are the standing protections; the scrub is the
one that runs per refresh. Both are needed.
What the harvest deliberately does not do
-
It does not commit. The output is a triage file and a
git diff. Reviewing that diff is the promotion gate — there is no automatic path from a click in wp-admin to production. -
It does not delete. An option removed in dev is reported, never applied. Removing a key from the declared set does not remove it from production.
-
It does not rewrite nested values unattended. Scalars are edited in place between markers in the values file; structured values are proposed as a patch for a human to apply.
-
It does not promote credentials, gateway settings, or install identity (
siteurl,home,admin_email) — those are per-environment by nature.
Related
-
Provisioning a WordPress dev site — the one-time setup that has to happen before any of this works.
-
Immutable WordPress architecture — the production design this is the counterpart to.
-
Plugin & theme provisioning — where a harvested plugin ends up, and the writable-path rules a new plugin is measured against.
-
UpdraftPlus — the content lane and its licence model.