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

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.

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

  1. Determine the source: curl the wordpress.org API for the slug.

  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.