WordPress was built around click-ops: wp-admin assumes a writable filesystem, in-place plugin/theme installs, and demo-content importers that download zips at runtime. FrankenPress inverts that — the site image is immutable,Documentation Index
Fetch the complete documentation index at: https://docs.frankenpress.com/llms.txt
Use this file to discover all available pages before exploring further.
DISALLOW_FILE_MODS=true, and
wp-content/{plugins,themes}/ are read-only inside the running
container.
For free WordPress.org themes that’s invisible: composer require wpackagist-theme/<slug> and the existing
customizing flow handles everything. For paid
themes the click-ops collision is unavoidable. Most premium themes
ship one or more admin-side actions that try to write to wp-content
at runtime — Envato’s in-WP updater, demo-content importers, plugin
bundles installed via TGM. They all fail, often halfway, with
cryptic “Failed to access the file system” errors.
This page captures the FrankenPress pattern for shipping a paid
theme cleanly: bake the files into the image, auto-activate via the
chart, run any theme-specific post-deploy commands automatically,
harden the wp-admin surface so designers don’t accidentally fire
the click-ops actions, and promote with a tag bump. Worked example
throughout: The7 v14.3.3 (ThemeForest), as deployed for the
EightOEight tenant.
Steps 2 and 3 require chart
>= 0.6.0. Steps 1, 4,
and 5 apply to any chart version.The mental model
Three rules, applied to every paid-theme decision:- Theme code lives in the image, theme config lives in the DB.
Files (PHP, CSS, JS, fonts, vendor libs) are committed to the
site repo and baked at build time. Activation, options, demo
content selection — all of those are
wp_optionsrows that persist across releases. Never let wp-admin write either side. - Anything click-ops that wants to download a zip will fail. Demo importers, premium plugin bundlers, in-WP updaters all assume a writable filesystem. The lockdown blocks them on purpose. You either move the work to build time (commit the bytes) or you don’t do it.
- Single theme per site. Carry one paid theme + the WP default as a fallback. Multiple paid themes per site multiplies the click-ops surface to harden, the assets shipped in the image, and the post-deploy machinery that has to stay idempotent.
The recipe
1. Bake the theme into the image
Premium themes aren’t on Composer registries, so you commit the
theme files directly. The Sanity-check the staged file count before committing — a
Then commit, PR, merge, tag, and let CI build the image.Failure mode: the image builds and starts, but
.gitignore in
site-template reserves the unhide
pattern explicitly:.gitignore rule silently dropping files (e.g. an unanchored
vendor/ matching nested inc/vendor/ directories) is a class
of failure that only surfaces when the theme tries to load a
missing class at runtime:wp theme activate dt-the7 returns “PHP Fatal error: Class ‘X’ not
found” — usually a missing nested vendor dir. Re-run the
sanity-check above, fix .gitignore, recommit, retag.2. Auto-activate via the chart
siteInstall.activeTheme (chart >= 0.6.0) tells the
post-install + post-upgrade Helm hook to run
wp --skip-themes theme activate <slug> after wp core install
/ update-db. Idempotent — re-firing on every release is a
no-op when the theme is already active.For a chart consumed via GitOps (Argo CD watching a values
file), declare it once in your gitops values:--set it directly:--skip-themes matters: it lets the activate run cleanly even
when the currently active theme has a runtime error (the
recovery path after a botched paid-theme release). Without it,
a broken active theme would PHP-fatal during WP-CLI bootstrap
and the activate command would never reach wp_options.Failure mode: hook Job exits non-zero with Error: Could not find theme '<slug>'. The image rolling on the cluster doesn’t
contain the theme yet — the GitOps tag bump is ahead of the
image build. Wait for the image to publish, then re-trigger the
sync.3. Run theme-specific post-deploy commands
Many paid themes generate dynamic assets on first activation —
LESS → CSS compilation, font subsetting, demo image
pre-resizing, etc. These typically hook off Renders 5 files into
wp_head or a
similar request-time action and hash-gate themselves so the
work doesn’t repeat on every page load. That hash-gating bites
in two ways on FrankenPress:- The hash is updated before the work runs (in The7’s case,
update_option('the7_last_dynamic_stylesheets_hash', ...)thentry { presscore_regenerate_dynamic_css(...); } catch (...) {}). A prior release that PHP-fataled mid-regen leaves the hash marked “done” — a clean later release won’t re-fire without a forced nudge. - We bootstrap the WP install via wp-cli in a Helm hook, not via
a real HTTP request. Some themes’ hooks only fire on
wp_heador admin AJAX actions, not on every WP load.
siteInstall.postDeployCommands (chart >= 0.6.0) is a list
of raw wp-cli sub-commands run in declared order after
activeTheme. Each entry is passed verbatim to
wp --path=/app/web/wp <entry>.For The7:wp-content/uploads/the7-css/ (custom.css,
css-vars.css, media.css, mega-menu.css, admin-custom.css) on
every release.Failure mode: missing CSS in the rendered front page,
browser devtools shows 403s on
<bucket>.s3.amazonaws.com/uploads/the7-css/*.css. S3 returns
403, not 404, on missing objects when the bucket isn’t
list-public — so a “missing dynamic CSS” failure looks like a
permissions error in browser devtools. Check the bucket
contents before chasing IAM.4. Harden wp-admin against click-ops
Premium themes ship admin-side actions that assume a writable
filesystem. The lockdown blocks them, but typically halfway
through, after partial work and with a cryptic error message.
Designers who hit this file support tickets on what looks like a
vendor bug.Ship a small mu-plugin alongside the theme that surgically
blocks the dangerous admin actions and replaces them with a
clear “disabled by site policy” message. The7’s worst
offender is its “Pre-made Website Templates” demo importer,
which downloads plugin zips + demo content. Block the URL, the
AJAX action, and the icon-zip uploader with the same pattern.Drop this at Two important details:Failure mode: the mu-plugin is in the image but the hooks
don’t fire — usually because Subsequent loads will pick the file up.
web/app/mu-plugins/<site>-hardening/<site>-hardening.php
in your site repo:- Mu-plugin, not regular plugin. Mu-plugins always load and can’t be deactivated from wp-admin — exactly the property you want for guard-rails.
- Pre-empt, not
remove_action. The7 binds its AJAX handlers against an internal singleton ([$this->admin, 'method']).remove_actionwith a class-name reference wouldn’t match. Pre-empting at priority 1 withwp_send_json_erroris robust across The7 versions.
.gitignore:00-stack.php (shipped with site-template) boots
roots/bedrock-autoloader, which auto-discovers
mu-plugins/<dir>/<dir>.php. No glue code needed.Adapting to a different paid theme. The hooks above target
The7-specific menu slugs and AJAX action names. For another
theme, find the equivalents by grepping the theme source for
add_menu_page(, add_submenu_page(, and add_action( 'wp_ajax_.
The pattern (load-page guard + AJAX pre-empt + admin notice)
transfers; only the names change.bedrock-autoloader hasn’t
re-discovered it after the file was added. Clear its cache once:5. Promote staging → production
With steps 1-4 in place, promotion is just a tag bump in
your GitOps values. The staging site image and the production
site image are byte-identical; only the chart values differ
(DB endpoint, S3 bucket, hostname).For a chart consumed via the standard ApplicationSet matrix
pattern shipped with Soak
gitops-fp:imageTagStg for as long as your release process requires,
then bump imageTagPrd to match in a separate commit. ArgoCD
reconciles, the production pod rolls, the post-install Helm
hook re-fires siteInstall.activeTheme and
siteInstall.postDeployCommands automatically. No manual
kubectl exec to remember.Failure mode: production pod rolls but front page is missing
CSS / images. Same root cause as step 3 — re-run the
the7_maybe_regenerate_dynamic_css command manually as a
one-off, then file a follow-up to fix whatever caused the
automated regen to silently no-op (almost always: hash already
matched, but the previous regen never produced files).What you can’t do — and what to do instead
The7’s “Pre-made Website Templates” demo importer
Step 4 blocks this with a clear error, but the use case is legitimate: designers want a starting layout to customize. The right pattern is build the layout offline, promote the result.Build locally with the full stack (no lockdown)
The local docker-compose stack shipped in
site-template runs
the same site image but with WP_ENV=development. The lockdown
constants are still hard-coded, but you can disable them
locally for the duration of the import:Customize the imported demo to taste
Pages, posts, menus, theme options, customizer settings — all
of it lives in the database, not on disk. Iterate freely.
The7’s bundled premium plugins (Slider Revolution, WPBakery, etc.)
The7’s TGM Plugin Activation tries to install these on demand at runtime. Same lockdown collision. If you actually need a bundled premium plugin:- Find the plugin’s redistributable zip (usually included with your ThemeForest purchase).
- Apply the same commit-direct pattern to it that you applied to
the theme — unzip into
web/app/plugins/<slug>/, unhide in.gitignore, commit, tag, deploy. - Add
'plugin activate <slug>'tositeInstall.postDeployCommands(chart>= 0.6.0) so it auto-activates on every release.
Envato Market plugin / The7’s in-WP updater
These probewp-admin/themes.php?action=upgrade-theme and similar
WP core endpoints, all of which are gated by DISALLOW_FILE_MODS.
Don’t install them. They fail noisily and add zero value —
theme updates on FrankenPress are always: download new zip → commit
to repo → tag → deploy. See customizing.mdx for the update
walkthrough.
Companion code
A reference implementation of this entire recipe lives in the EightOEight tenant’s site repo (private, but the mu-plugin source above is identical to what’s in production). The pattern is deliberately copy-paste — adapt the namespace, the THEME_SLUG constant, and the hook names for whichever paid theme your tenant chose.See also
- Customizing your site — the wpackagist flow for free WP.org plugins and themes
- Components → site-template — the Bedrock-shaped repo that backs every FrankenPress site
- Components → charts — the
siteInstall.activeThemeandsiteInstall.postDeployCommandsvalues used in steps 2 and 3