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)

SurfaceCaptured how
wp_template, wp_template_part, wp_global_styles, wp_navigationtemplates.json sidecar, upsert by post_name + post_type on apply (designer iteration on existing rows propagates)
Site-identity options (blogname, blogdescription, show_on_front, page_on_front, permalink_structure, etc.)options.json sidecar, update_option on apply
theme_mods_<active-stylesheet>Same options.json sidecar
Designer-asset attachments (logos, header/footer imagery)attachments.json + uploads/<rel-path>/... binaries — see How attachments are discovered below

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:
brew install frankenpress/tap/fp
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

git clone git@github.com:<your-org>/<your-site>.git
cd <your-site>
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 snapshot
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>/:
FileContains
manifest.{yaml,json}fp.snapshot/v4 schema, ID, source URL, source theme, scope, content hashes
templates.jsonOwned-CPT entries (templates, template parts, global styles, navigation)
options.jsonScoped wp_options + theme_mods
attachments.jsonDesigner-asset attachment posts (option-ref + block-ref discovery)
uploads/<rel-path>/...Binary files for the captured attachments
content.xml.gzWXR — typically empty for FSE sites (page/post are out of scope)
uploads-manifest.txtsha256 + size audit log of the entire uploads dir
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

git checkout -b feat/snapshot-<short-description>
git add web/imports/<slug>/
git commit -m "Snapshot: <description>"
git push -u origin feat/snapshot-<short-description>
# → PR → review → merge → git tag vX.Y.Z → CI builds + pushes
#   ghcr.io/<your-org>/<your-site>:vX.Y.Z
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 optionsupdate_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:
Row typeBehaviour
Owned-CPT (templates, template parts, global styles, navigation)UPSERT by slug — designer edits propagate cleanly
Options + theme_modsUPSERT by key — values overwrite
AttachmentsUPSERT by _wp_attached_file — same source file = same target post; new files create new posts
Pages, posts, comments, users, etc.Never touched — out of scope entirely
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:image {"id":42,"url":"http://localhost:8080/app/uploads/..."} -->
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:
kubectl -n <namespace> exec deploy/site -- wp --allow-root --path=/app/web/wp \
  option delete fp_snapshot_applied_ref fp_snapshot_applied_sha256
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

RepoRole in the designer flow
fpHost-side CLI. fp snapshot wraps the mu-plugin’s wp fp snapshot + the post-capture docker cp extraction, with interactive slug + note prompts. Installed via the homebrew-tap.
mu-pluginShips the Fse adapter + wp fp snapshot + wp fp apply. v0.12.0+ ships the design/content scope split + designer-asset attachment shipping (option-ref + block-scan). v0.12.2+ includes the apply-side uploads URL retarget.
site-templateWhat you forked from. web/imports/.gitkeep is the placeholder for committed snapshots.
chartsInstall Job picks the snapshot under web/imports/ with the highest manifest.created and applies it on every helm upgrade. v0.10.0+ provides RW root FS on the install Job for transient WP-Importer install; v0.12.0+ moves from “fail on >1 snapshot” to “pick latest by created”.