Plugin & theme provisioning

Because the image is immutable (DISALLOW_FILE_MODS), nothing is installed or updated from the WordPress admin. Every plugin and theme is put into the image at build time, and a plugin version is changed by rebuilding the image. This page covers the two provisioning sources, licensing, and — the part that catches every plugin — how a plugin that needs to write to disk is accommodated under a read-only root filesystem.

Two sources

Source How

Public (on wordpress.org)

Declared as a Composer dependency via the WPackagist mirror and installed in Stage 1 of the build. Versions are pinned in composer.lock.

Private / commercial / third-party (not on wordpress.org)

Supplied as an artefact (zip or tarball) and baked into the image in Stage 2 with a COPY + extract. Licensed artefacts are kept out of git and injected at build time.

Public plugins (Composer)

Add the plugin to composer.json under the WPackagist repository and pin it via composer.lock:

"repositories": [
  { "type": "composer", "url": "https://wpackagist.org", "only": ["wpackagist-plugin/*", "wpackagist-theme/*"] }
],
"require": {
  "wpackagist-plugin/<slug>": "*"
}

The <slug> is the plugin’s wordpress.org slug, which equals its wp-content/plugins/ directory name. Confirm a plugin is actually hosted on wordpress.org before assuming it is public — a 404 here means it is a private/third-party plugin and must be baked instead:

curl -s -o /dev/null -w "%{http_code}" https://api.wordpress.org/plugins/info/1.0/<slug>.json
Themes use a different endpoint

info/1.0/<slug>.json is a plugins URL. There is no theme equivalent — it returns 404 for every theme, public or not, so using it for themes classifies all of them as private and sends you off to bake an artefact for a theme that Composer could have installed. Themes are queried through the 1.1 action endpoint:

curl -s -o /dev/null -w "%{http_code}" \
  'https://api.wordpress.org/themes/info/1.1/?action=theme_information&request[slug]=<slug>'

Both answer 404 for an unknown slug. Treat a network failure as "unknown", never as "private": silently baking an artefact for a public plugin because the API was unreachable produces an image that is harder to update for no reason.

Private, commercial and third-party artefacts

These are not on WPackagist. The artefact is placed in a build-only, git-ignored directory and baked in:

# theme tarball whose root is wp-content/themes/<Name>
tar -xzf packages/<theme>.tar.gz --strip-components=2 -C web/app/themes wp-content/themes/<Name>
# premium plugin zip whose root is the plugin folder
unzip -q packages/<plugin>.zip -d web/app/plugins
# GPL plugin distributed only from a vendor git repo
git clone --depth 1 --branch <tag> <repo> /tmp/src && cp -r /tmp/src/<subdir> web/app/plugins/

Because the image build is where licensed artefacts are handled, the image is built in a trusted environment (not in shared CI, where large licensed zips cannot be stored as secrets). Only the Helm chart is published from CI.

Licensing

Commercial plugins are activated one of two ways, and neither puts a licence file on the read-only filesystem:

  • Online connect (e.g. UpdraftPlus, Divi) — the plugin code is baked in; activation is a one-time login/connect in the admin that registers the site against the account and stores a token in the database. Because it lives in the DB it survives every pod restart; you do it once after first deploy.

  • Licence key — a key entered in the admin, or injected as an environment variable from the Secret and applied during bootstrap.

Under the immutable model the plugin’s own auto-updater is irrelevant — updates come from rebuilding the image with a newer artefact, not from the admin.

A dev site is a second activation

An online-connect plugin activated on a dev site consumes a licence slot exactly as production does. Before standing one up, check whether the vendor sells the specific capability you need separately: UpdraftPlus' Migrator add-on is licensed apart from Premium and does not take a Premium slot, which is what makes the content lane affordable. Where no such split exists, prefer leaving the plugin inactive in dev over buying a slot for a site nobody visits.

Writable paths under a read-only root filesystem

This is the rule that every plugin is measured against. The root filesystem is read-only, so a plugin that writes anywhere under wp-content will fail unless that exact path is a mounted, writable volume. Choosing the wrong kind of volume causes subtler failures, so the decision matters:

The path is… Use Because

Persistent state the plugin owns as a self-contained store (e.g. backups)

its own PVC

It must survive restarts and must not be entangled with other data. Never make it a subPath of another data PVC (see the trap below).

Persistent, and genuinely part of the media tree

the uploads PVC (optionally a subPath)

It belongs with uploads and is not something the app deletes wholesale.

Transient — a cache, or a working dir the app deletes and recreates

an emptyDir

No need to persist, and an emptyDir mount point cannot be unlinked by the app (see the trap below).

Two traps that cost real debugging time

A subPath the app deletes goes stale. If a plugin rmdir`s a directory that is mounted as a PVC `subPath (WordPress does this to wp-content/upgrade during a restore), the bind mount is left pointing at an unlinked inode — the directory shows a link count of 0 and every write fails, even though permissions look correct. Mount such directories as an emptyDir: a mount point cannot be unlinked.

Never nest a plugin’s store inside another volume’s tree. Mounting a backup directory as a subPath at the root of the uploads PVC makes it appear as wp-content/uploads/<store> — i.e. inside the media tree. A media restore then tries to move/delete the plugin’s own store, and the backup archive itself contains the store recursively. Give such a store its own PVC so it is a true sibling of uploads, not a child of it.

Adding a plugin — checklist

Where a plugin is chosen and where it is declared are different places. On a site with a dev posture, try it there first: install it from wp-admin, confirm it does what you need and that it behaves under the writable-path rules above, and only then promote it. The harvest performs steps 1-3 for you — it detects anything installed in dev and not declared in the repo, classifies it against wordpress.org, and stages a private artefact out of the running site. Steps 4-6 remain judgement calls.

  1. Determine the source: curl the wordpress.org API for the slug (plugins and themes use different endpoints — see the warning above).

  2. Public → add wpackagist-plugin/<slug> to composer.json, run composer update to pin it. Private → place the artefact in the build-only packages/ dir and add the COPY/extract step to the Dockerfile.

  3. Add the slug to the bootstrap activation list.

  4. Identify any paths the plugin writes to under wp-content; add a writable volume of the correct type (per the table above) to the Helm chart’s Deployment.

  5. If it needs a licence, decide connect-vs-key and where the secret lives.

  6. Add a page to the plugin catalogue recording the above.