Add and remove plugins and themes the FrankenPress way — composer at build time, immutable image, declarative deploy
WordPress on FrankenPress is immutable at runtime. Code (WP core,
plugins, themes, custom files) is baked into the site image at build
time; the running container’s filesystem is read-only. The wp-admin
“Add new” / “Upload” / “Activate” buttons that install plugins and
themes from the dashboard are absent on purpose — anything they wrote
would land on ephemeral container disk and vanish on the next pod
restart, or replicate inconsistently across replicas.That trades convenience for two big wins: every site is reproducible
from a tagged commit, and the same image can be promoted from staging
to production with confidence.This page walks through the four common changes:
All four follow the same shape: edit composer.json on a branch, ship
a new image tag, point the cluster at it, and (for plugins/themes)
run a one-time wp command to flip the wp_options state in the
database. The flow assumes you’re using the standard FrankenPress
release flow — branches, tag-driven CI, GitOps for the cluster
config. Adapt the cluster step to your own deploy mechanism if you’re
not.
The commands below use mysite as the example name for your
site repo’s local directory — substitute whatever your fork is
called (e.g. my-blog, acme-marketing, etc.). Same for <ns> /
<release> in the cluster commands — your Kubernetes namespace
and Helm release name.
Use the Composer-Setup.exe installer
from getcomposer.org. Run the rest of the commands on this page in
Git Bash / WSL / PowerShell — adjust quoting as needed.
No Composer? Use the Docker image
If you can’t or don’t want to install Composer, the
official composer:2 Docker image
is a drop-in. Replace composer <args> with:
docker run --rm -v "$PWD:/app" -w /app composer:2 <args>
The rest of this guide assumes you have composer on $PATH.
The <slug> is the URL-slug from wordpress.org/plugins/<slug>/ or
wordpress.org/themes/<slug>/. Premium plugins / themes from
elsewhere need a private repository entry in composer.json —
out of scope for this page; see the
Composer + WordPress guide
from the Roots project.
We’ll add the Classic Editor
plugin (maintained by the WP core team — restores the pre-Gutenberg
editor for posts and pages). The flow is identical for any other
plugin from WordPress.org.
cd mysitecomposer require wpackagist-plugin/classic-editor:^1.6
This updates composer.json. The plugin lands in
web/app/plugins/classic-editor/ once Composer installs, but
you don’t need that locally — the next image build will install it.
composer.lock is in .gitignore by default in
site-template — every build resolves to the latest version
matching your constraint. Pin tightly (use ^1.6 not *) to
avoid surprise upgrades. Sites that want full reproducibility
can remove composer.lock from .gitignore and commit it.
3
Commit and open a PR
Only composer.json should have changed. Commit, push, open a PR:
Once CI is green and the PR is merged, tag a new release. The tag
is what triggers the image build:
# Pull the squash-merged commit into local maingit -C mysite checkout maingit -C mysite pull --ff-only# Tag and push (CI builds + pushes the image to GHCR)git -C mysite tag -a v0.1.6 -m "feat: add Classic Editor"git -C mysite push origin v0.1.6
Watch the build:
gh run watch --repo <owner>/mysite
5
Update your GitOps values
Bump image.tag in the values file your cluster pulls from. With
ArgoCD watching a Git repo:
# gitops/.../values.yamlsite: image: tag: 0.1.6 # was 0.1.5
Commit + push that. ArgoCD will sync on its next reconcile (a few
minutes by default) and roll the site Deployment.If you’re using helm upgrade directly instead of GitOps:
The plugin’s files are now in the running pod, but WordPress
doesn’t auto-activate plugins. Activation is stored in the
wp_options.active_plugins database row — DB state, not image
state. Activate it once with wp-cli:
Deactivate before removing the files. WordPress tolerates a
plugin disappearing from disk while still listed in
active_plugins, but it produces an admin notice on every load.
Cleaner to deactivate first.
The DB still remembers the plugin’s settings. Most plugins
don’t clean up their wp_options rows / custom tables on
deactivation; only on uninstall (which the lockdown also blocks
from the admin). Use wp plugin uninstall if you want a thorough
scrub.
Optional: also run wp plugin uninstall classic-editor if you
want the plugin’s DB state cleaned up. (uninstall is safe to
run before file removal — it just runs the plugin’s
register_uninstall_hook callback if defined.)
The active theme is stored in wp_options.template /
wp_options.stylesheet — DB state. You can switch back at any
time without rebuilding the image, as long as the target theme is
in the image.
4
Verify
kubectl -n <ns> exec deploy/<release>-site -- \ wp theme list --status=active --allow-root --path=/app/web/wp# expect: twentytwentythree active ...
Visit the public site — the look-and-feel reflects the new theme.
The big rule: you can’t remove the currently-active theme.
WordPress requires at least one valid theme in wp-content/themes/
that matches wp_options.template, or it errors out. Switch to a
different theme first.
`wp` returns 'This does not seem to be a WordPress installation'
Always pass --path=/app/web/wp and --allow-root to wp inside
the container. The default working directory is /app, but
WordPress core lives at /app/web/wp/ per the Bedrock layout.
Plugin activation fails with 'plugin file does not exist'
The image build didn’t include the plugin — usually because the
image rolled out is older than the one with your composer.json
change. Double-check the running pods’
image matches the tag you bumped to in GitOps.
Designer-built content (templates, global styles, navigation)
Site Editor output — block templates, template parts,
wp_global_styles, navigation menus, plus designer-uploaded
media and site-identity options — is promoted via the
designer flow: design locally on
docker-compose, capture with fp snapshot (host-side CLI that
wraps the mu-plugin’s wp fp snapshot and extracts the result),
commit web/imports/<slug>/, tag, deploy. Additive on apply
(existing UGC is never touched).
Premium / classic themes (ThemeForest, etc.)
Not supported by the designer flow. FrankenPress dropped premium-
theme adapters in mu-plugin v0.10.0 — classic-mode themes that
rely on Options Frameworks, post-install regen hooks, or theme-
bundled plugins collide with the immutable-image lockdown in
ways that took ~10 fix cycles per theme to work around. If you
have an existing private Composer registry (VCS / Satis /
Packagist Server), the theme code itself can still ship as a
Composer-installed dependency the same way wpackagist themes do
— see the
Roots Bedrock Composer guide.
The designer-side automation (snapshot/apply) only supports
FSE-mode themes.