Skip to main content

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.

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, 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:
  1. 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_options rows that persist across releases. Never let wp-admin write either side.
  2. 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.
  3. 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

1. Bake the theme into the image

Premium themes aren’t on Composer registries, so you commit the theme files directly. The .gitignore in site-template reserves the unhide pattern explicitly:
git -C mysite checkout main && git -C mysite pull --ff-only
git -C mysite checkout -b theme/the7

# IMPORTANT: unzip the *inner* theme zip — ThemeForest "complete"
# bundles also contain PSDs, licensing PDFs, and dev tools you
# don't want in the image.
unzip ~/Downloads/dt-the7_v.14.3.3.zip -d web/app/themes/

# Unhide the new directory so git will track it.
echo '!web/app/themes/dt-the7/' >> .gitignore

git add .gitignore web/app/themes/dt-the7/
Sanity-check the staged file count before committing — a .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:
echo "on disk:    $(find web/app/themes/dt-the7 -type f | wc -l)"
echo "staged:     $(git diff --cached --name-only -- web/app/themes/dt-the7 | wc -l)"
echo "ignored:    $(git ls-files --others --ignored --exclude-standard web/app/themes/dt-the7 | wc -l)"
# Expect: on disk == staged, ignored == 0
The default site-template .gitignore uses /vendor/ (anchored to the repo root). If your fork still has the unanchored vendor/ pattern, change it to /vendor/ first — otherwise it matches nested vendor directories inside premium themes and silently drops 50-200 files at commit time. The sanity check above catches this.
Then commit, PR, merge, tag, and let CI build the image.
git -C mysite commit -m "feat: add The7 theme v14.3.3"
git -C mysite push -u origin theme/the7
gh pr create
# → merge → tag vX.Y.Z → push → CI publishes ghcr.io/<org>/<site>:vX.Y.Z
Failure mode: the image builds and starts, but 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

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:
siteInstall:
  activeTheme: dt-the7
Or --set it directly:
helm upgrade <release> oci://ghcr.io/frankenpress/charts/site \
  --reuse-values --set siteInstall.activeTheme=dt-the7
--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

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 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', ...) then try { 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_head or 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:
siteInstall:
  activeTheme: dt-the7
  postDeployCommands:
    # Force-regen The7's dynamic LESS → CSS to S3.
    # delete_option clears the hash gate; the maybe_regenerate
    # call then runs the full compile.
    - 'eval "delete_option(\"the7_last_dynamic_stylesheets_hash\"); the7_maybe_regenerate_dynamic_css();"'
Renders 5 files into 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.
Unsure which post-deploy commands a given paid theme needs? Activate it locally with make up, then export the WP options that changed: wp option list --search='<theme_slug>_*'. Anything auto-set on first activation is a candidate for the postDeployCommands list.
4

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 web/app/mu-plugins/<site>-hardening/<site>-hardening.php in your site repo:
<?php
/*
 * Plugin Name: <site> hardening
 * Description: Disables The7 admin actions that conflict with the FrankenPress immutable-image model.
 * Version:     1.0.0
 */

namespace MySite\Hardening;

defined( 'ABSPATH' ) || exit;

const THEME_SLUG = 'dt-the7';

/**
 * 1. 403 the demo-import sub-page URL.
 */
add_action( 'load-toplevel_page_the7-dashboard', function () {
    if ( wp_get_theme()->get_template() !== THEME_SLUG ) return;
    if ( isset( $_GET['action'] ) && $_GET['action'] === 'demo_import' ) {
        wp_die(
            esc_html__( 'Demo import is disabled on FrankenPress (immutable image).', 'mysite' ),
            'Disabled by site policy',
            [ 'response' => 403, 'back_link' => true ]
        );
    }
}, 1 );

/**
 * 2. Pre-empt the two filesystem-mutating AJAX handlers.
 *
 * remove_action would need the exact [$instance, 'method'] reference
 * The7 used to register the hook (it binds against an internal
 * singleton). Pre-empt at priority 1 with wp_send_json_error
 * before the default-priority handler runs — robust against
 * The7 changing the binding shape across versions.
 */
add_action( 'wp_ajax_the7_import_demo_content', __NAMESPACE__ . '\\block_demo_import_ajax', 1 );
add_action( 'wp_ajax_the7_icons_manager_add_zipped_font', __NAMESPACE__ . '\\block_icon_upload_ajax', 1 );

function block_demo_import_ajax(): void {
    if ( wp_get_theme()->get_template() !== THEME_SLUG ) return;
    wp_send_json_error( [ 'error_msg' => 'Demo import disabled by site policy.' ], 403 );
}
function block_icon_upload_ajax(): void {
    if ( wp_get_theme()->get_template() !== THEME_SLUG ) return;
    wp_send_json_error( [ 'error_msg' => 'Icon-zip upload disabled by site policy.' ], 403 );
}

/**
 * 3. UX failsafe: tell designers *why* the demo-import button
 * does nothing on the dashboard — otherwise they file support
 * tickets on what looks like a vendor bug.
 */
add_action( 'admin_notices', function () {
    $screen = get_current_screen();
    if ( ! $screen || $screen->id !== 'toplevel_page_the7-dashboard' ) return;
    if ( wp_get_theme()->get_template() !== THEME_SLUG ) return;
    printf(
        '<div class="notice notice-info"><p>%s</p></div>',
        esc_html__( 'Pre-made Website import is disabled on this site (immutable-image deploy). To bring in a demo, replicate it locally and promote the resulting database.', 'mysite' )
    );
} );
Two important details:
  • 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_action with a class-name reference wouldn’t match. Pre-empting at priority 1 with wp_send_json_error is robust across The7 versions.
Don’t forget to unhide the new directory in .gitignore:
echo '!web/app/mu-plugins/<site>-hardening/' >> .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.
Failure mode: the mu-plugin is in the image but the hooks don’t fire — usually because bedrock-autoloader hasn’t re-discovered it after the file was added. Clear its cache once:
kubectl -n <ns> exec deploy/<release>-site -- \
  wp --path=/app/web/wp option delete bedrock_autoloader
Subsequent loads will pick the file up.
5

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 gitops-fp:
# gitops-fp/apps/applicationset.yaml — your tenant's matrix entry
      - site: mysite
        host: example.com
        imageRepo: <org>/mysite
        imageTagStg: v0.1.7   # bump first
        imageTagPrd: v0.1.6   # then promote: v0.1.6 → v0.1.7
        ...
Soak 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.
1

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:
cd mysite
make up
# In a separate shell, edit config/application.php → comment out
# DISALLOW_FILE_MODS for local use only. NEVER commit this change.
make wp ARGS="core install --url=http://localhost:8080 --title='MySite' --admin_user=admin --admin_email=admin@example.com --admin_password=admin --skip-email"
# Browser → wp-admin → activate dt-the7 → run demo import normally
2

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.
3

Export DB + media uploads

make wp ARGS="db export local-with-demo.sql"
# Local MinIO uploads → host filesystem (docker-compose volume mount):
docker compose cp minio:/data/uploads /tmp/local-uploads
4

Promote to staging

# Sync media to staging S3
aws s3 sync /tmp/local-uploads/ \
  s3://<staging-bucket>/uploads/ \
  --exclude "the7-css/*"   # keep staging-generated dynamic css

# Import the DB
kubectl -n <ns> exec -i deploy/<release>-site -- \
  wp --path=/app/web/wp db import - < local-with-demo.sql

# Rewrite URLs from local → staging
kubectl -n <ns> exec deploy/<release>-site -- \
  wp --path=/app/web/wp search-replace 'http://localhost:8080' 'https://staging.example.com'

# Re-run the dynamic CSS regen against the new DB state
kubectl -n <ns> exec deploy/<release>-site -- \
  wp --path=/app/web/wp eval \
    'delete_option("the7_last_dynamic_stylesheets_hash"); the7_maybe_regenerate_dynamic_css();'
You get the full pre-made demo, plus you’ve reviewed every page and post before they hit staging. Beats the broken in-admin flow.

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:
  1. Find the plugin’s redistributable zip (usually included with your ThemeForest purchase).
  2. 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.
  3. Add 'plugin activate <slug>' to siteInstall.postDeployCommands (chart >= 0.6.0) so it auto-activates on every release.
If you don’t actually need it, leave it un-installed — TGM will show a yellow nag in wp-admin but the theme works fine without.

Envato Market plugin / The7’s in-WP updater

These probe wp-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