Skip to main content
FrankenPress sites separate design (what a designer builds) from content (what an editor publishes). Design state ships through git
  • image releases. Content stays live on the production site.
This page walks the designer half of that line: edit FSE block templates locally in Site Editor, capture into a snapshot, promote through git the same way code changes are.
This page documents the default DB-row mode — design state is captured as wp_template / wp_template_part / wp_global_styles DB rows in the snapshot’s templates.json sidecar and upserted on apply. An opt-in alternative — theme-files mode — saves design state to theme files in the site repo instead. Same goal, different storage shape. Pick one per tenant.

What’s in scope (ships in the snapshot)

What’s NOT in scope (never ships, lives only on the live site)

  • page — pages created/edited live by editors
  • post — posts created/edited live by editors
  • attachment rows that aren’t referenced by design — editor-uploaded media stays put
  • comment / user / wc_order / activity log — structurally outside any adapter’s scope; can never enter or be modified by an apply
This is a deliberate architectural line. A designer building the site locally and an editor managing content on production should never overwrite each other’s work. Snapshots ship design; content stays live.

How attachments are discovered

The capturer ships an attachment only if it’s referenced by design state. Two reference paths, both walked at capture time:
  1. Option references — for each option declared in option_keys_attachment_refs (the bundled Fse adapter declares site_logo, site_icon, custom_logo), the option’s value is read as an attachment ID and that attachment is captured.
  2. Block references — every captured wp_template, wp_template_part, wp_global_styles, and wp_navigation’s post_content is parsed via parse_blocks(). Every integer attr value is collected; each one that resolves to an attachment post is captured.
For each captured attachment:
  • The post fields + key postmeta (_wp_attached_file, _wp_attachment_metadata) go into attachments.json keyed by relative file path
  • Every binary file (the original + every WP-generated size variant) is bundled into snapshot/uploads/<relative-path>
Attachments uploaded locally but not referenced by any design state are skipped silently — they’re treated as content the editor will manage live.

Designer-as-admin

Designers operate as full WordPress admins on the local docker-compose stack. The safety boundary is the immutable image + in-cluster lockdown, not WP roles. In-cluster, DISALLOW_FILE_EDIT and DISALLOW_FILE_MODS block theme edits and plugin installs for everyone, admins included. Locally, designers have full latitude. The controlled promotion path is the PR + image-build + gitops bump, not a WordPress capability check.

The workflow

Prerequisite — install fp once. The capture step uses the host-side fp CLI, which wraps the mu-plugin’s wp fp snapshot and extracts the result. Install via the FrankenPress Homebrew tap:
No-brew fallbacks: go install github.com/frankenpress/fp/cmd/fp@latest, or grab a binary from Releases. Verify with fp version.
1

Onboard with fp init

One command does it all: scaffolds .env, runs composer install via docker, brings the stack up (docker compose up -d --wait), installs WordPress, and applies the most recent committed snapshot (if any) so the designer’s previous work is restored — assets and all.Defaults: admin admin / admin pass admin, URL http://localhost:8080, title “FrankenPress site”. Override via [init] in frankenpress.toml.Re-run fp init after docker compose down -v to recover from a wiped local stack. Same command, same result. Idempotent — every step no-ops if the state is already correct.Lower-level alternative: if you’d rather drive it manually, make setup && make up && make wp ARGS="core install ..." is the equivalent sequence.
2

Design in Site Editor

Log in at http://localhost:8080/wp/wp-admin/. Open Appearance → Editor. Edit templates, template parts, global styles, and navigation. Upload media to use as site logos, header / footer images, or inline block images — anything referenced by your design will be captured.Settings to typically also touch:
  • Settings → General: Site Title, Tagline
  • Settings → Reading: Front-page configuration
  • Site Editor → Styles: Site logo, accent colours, typography
3

Capture the snapshot

From the site repo root (or any subdirectory — fp walks up to find frankenpress.toml or composer.json):
fp prompts twice:
  • slug — defaults to a UTC timestamp (YYYY-MM-DDTHH-MM-SSZ), filename-safe and lex-sortable so ls web/imports/ is naturally chronological. Enter accepts the timestamp; type a name to attach a milestone marker (e.g. pre-2026-rebrand) instead.
  • note — opens $EDITOR if set + interactive; otherwise reads a single line from stdin. Empty notes are allowed.
Both can be supplied as flags (--slug <s> / --note <s> / --note-file <path>) to skip prompting. --quick skips everything and does not update .fp/state.json — useful for ad-hoc / scripted captures.Writes web/imports/<slug>/:Re-running with the same explicit --slug=<name> pre-cleans the host target then re-captures cleanly (the iterate-on-a-milestone path). The default timestamp slug is fresh on every run, so accidental overwrite isn’t possible unless two captures land in the same UTC second — in that case fp refuses and asks you to wait a moment, rather than silently wiping the prior dir. If the target directory has uncommitted git changes, fp will ask before overwriting — pass --quick to bypass that guard.Behind the scenes fp shells docker compose exec site wp fp snapshot ... and docker cp to extract; if the stack isn’t running it prints a make up hint and exits.
4

Commit and tag

No git rm of the previous snapshot dir — older web/imports/<timestamp>/ directories are intentional history. The chart picks the snapshot with the highest manifest.created at deploy time, so accumulating dirs is fine.
5

Deploy

Bump imageTag in your GitOps overlay (or helm upgrade --set image.tag=vX.Y.Z for vanilla Helm). The chart’s install Job (charts ≥ v0.12.0) picks the snapshot with the highest manifest.created and runs wp fp apply --snapshot-dir=<that one> on every helm upgrade. Idempotent.

What apply does

The chart’s install Job (charts ≥ v0.12.0) scans every subdirectory of /app/web/imports/ whose manifest.json exists, picks the one with the highest created UTC timestamp, and runs wp fp apply against it. Older snapshot dirs are skipped (logged as “skipped — older”), not deleted — they stay baked in the image as history. Per applied snapshot, in order:
  1. Schema check — reject if not fp.snapshot/v4.
  2. Idempotency check — compare manifest.id and wxr_sha256 against the fp_snapshot_applied_ref / _sha256 options. Match → skip.
  3. WXR import — additive INSERT-only. For Fse-adapter sites this is a no-op (post_types_additive is empty).
  4. Apply attachments — upsert each attachments.json entry by _wp_attached_file (the stable cross-environment key). Copy each snapshot/uploads/<rel> to wp_upload_dir()/<rel>. With S3UploadsBootstrap active, that’s an s3:// stream wrapper write — files land directly in S3. Returns a captured_id → local_id remap.
  5. Apply owned-posts — upsert each templates.json entry by post_name + post_type. Before upsert, rewrite "id":<captured> JSON block-attrs and wp-image-<captured> CSS classes in post_content to the local IDs from step 4’s remap.
  6. Apply options — update_option for each. For options listed in option_keys_attachment_refs, replace the captured ID value with the local ID from step 4’s remap. theme_mods_<stylesheet> written directly.
  7. URL retarget, two passes:
    • <source>/app/uploads → wp_get_upload_dir()['baseurl'] (rewrites block-attr url strings + <img src> URLs to point at the S3 bucket)
    • <source> → <target_url> (catches non-upload host references)
  8. Adapter post_apply() — Fse sweeps wp_global_styles orphans whose stylesheet metadata doesn’t match the current get_stylesheet() (theme-switch cleanup).
  9. Markers stamped — subsequent re-applies short-circuit at step 2.

Iteration semantics

Different rows have different update behaviour on apply: So designer iteration on an existing template (edit text, change a color, swap the footer image) flows through every subsequent deploy. Editor-managed content (a customer adds a blog post on production) is never touched by the designer’s pipeline.

Safety properties

  • No DROPs of UGC. User-generated content (orders, comments, user accounts, custom CPTs not in scope) is structurally inaccessible — it’s not in any adapter’s scope, so no codepath in the apply can reach it.
  • wp_global_styles orphan cleanup is scoped. The Fse adapter deletes only wp_global_styles rows for stylesheets other than the current — that’s snapshot-managed metadata, not UGC.
  • Designer-asset attachments are discovered by reference. Uploads not referenced by site_logo, site_icon, custom_logo, or inline block markup stay put.

Common questions

Their edit lands in the production DB’s wp_template row. The next designer push re-upserts that template from the snapshot, overwriting the customer edit. Templates are designer state by convention — admins editing them on production should expect their changes to be reverted on the next designer release.Pages, posts, customer-uploaded media — those are content; the designer pipeline doesn’t touch them.
Technically yes (wp fp snapshot runs anywhere WP-CLI runs), but production captures will pick up customer-edited templates + customer-uploaded media you may not want in the image. Local capture from a designer-controlled stack is the canonical flow.
The shipping Fse adapter detects via wp_is_block_theme(); classic themes won’t trigger it. wp fp snapshot errors out with “no snapshot adapter detected.” A site that genuinely needs a classic-theme adapter can register one against the existing AdapterInterface — but the slim mu-plugin baseline ships only Fse, and adding a second registered adapter requires explicit user approval per the mu-plugin slim-by-default contract.
The capturer only ships attachments referenced by design state — site logos (via site_logo / site_icon / custom_logo options) and inline wp:image block references inside captured templates (an image block carries an "id":N attribute pointing at the attachment). Uploads sitting in the Media Library that aren’t referenced anywhere are treated as content (editor-managed) and stay local.To ship a designer asset: either set it as a logo, or place it via Site Editor inside a template, template part, or global style. Both reference paths are captured automatically.
Block markup captures the image URL as a literal string in the block attrs, e.g.:
wp_get_attachment_url() filters don’t run on literal strings at render time. So the apply-side URL retarget rewrites the captured uploads prefix to wp_get_upload_dir()['baseurl'] BEFORE the host retarget, ensuring img src lands pointing directly at the S3 bucket (or your configured CDN URL via FP_S3_BUCKET_URL).
The forward path is image-versioned. Roll back by deploying an earlier image tag whose web/imports/ didn’t contain the snapshot. The snapshot-imported posts will still exist in the DB (additive — apply doesn’t delete on rollback), but no apply runs against the missing snapshot directory so no further changes land. For a full content rollback, restore from your MariaDB backup.
On the target environment:
The next ArgoCD reconcile / install Job run will see no markers and run a full apply. Useful when you’ve changed the apply behaviour (e.g. a mu-plugin upgrade) without changing the snapshot itself.

Companion repos