- image releases. Content stays live on the production site.
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)
| Surface | Captured how |
|---|---|
wp_template, wp_template_part, wp_global_styles, wp_navigation | templates.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 editorspost— posts created/edited live by editorsattachmentrows that aren’t referenced by design — editor-uploaded media stays putcomment/user/wc_order/ activity log — structurally outside any adapter’s scope; can never enter or be modified by an apply
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:- Option references — for each option declared in
option_keys_attachment_refs(the bundledFseadapter declaressite_logo,site_icon,custom_logo), the option’s value is read as an attachment ID and that attachment is captured. - Block references — every captured
wp_template,wp_template_part,wp_global_styles, andwp_navigation’spost_contentis parsed viaparse_blocks(). Every integer attr value is collected; each one that resolves to anattachmentpost is captured.
- The post fields + key postmeta (
_wp_attached_file,_wp_attachment_metadata) go intoattachments.jsonkeyed by relative file path - Every binary file (the original + every WP-generated size variant)
is bundled into
snapshot/uploads/<relative-path>
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 No-brew fallbacks:
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:go install github.com/frankenpress/fp/cmd/fp@latest,
or grab a binary from Releases.
Verify with fp version.Onboard with fp init
.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.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
Capture the snapshot
From the site repo root (or any subdirectory —
Re-running with the same explicit
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 sols 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
$EDITORif set + interactive; otherwise reads a single line from stdin. Empty notes are allowed.
--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>/:| File | Contains |
|---|---|
manifest.{yaml,json} | fp.snapshot/v4 schema, ID, source URL, source theme, scope, content hashes |
templates.json | Owned-CPT entries (templates, template parts, global styles, navigation) |
options.json | Scoped wp_options + theme_mods |
attachments.json | Designer-asset attachment posts (option-ref + block-ref discovery) |
uploads/<rel-path>/... | Binary files for the captured attachments |
content.xml.gz | WXR — typically empty for FSE sites (page/post are out of scope) |
uploads-manifest.txt | sha256 + size audit log of the entire uploads dir |
--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.Commit and tag
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.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:
- Schema check — reject if not
fp.snapshot/v4. - Idempotency check — compare
manifest.idandwxr_sha256against thefp_snapshot_applied_ref/_sha256options. Match → skip. - WXR import — additive INSERT-only. For Fse-adapter sites this is a no-op (post_types_additive is empty).
- Apply attachments — upsert each
attachments.jsonentry by_wp_attached_file(the stable cross-environment key). Copy eachsnapshot/uploads/<rel>towp_upload_dir()/<rel>. WithS3UploadsBootstrapactive, that’s ans3://stream wrapper write — files land directly in S3. Returns acaptured_id → local_idremap. - Apply owned-posts — upsert each
templates.jsonentry bypost_name + post_type. Before upsert, rewrite"id":<captured>JSON block-attrs andwp-image-<captured>CSS classes inpost_contentto the local IDs from step 4’s remap. - Apply options —
update_optionfor each. For options listed inoption_keys_attachment_refs, replace the captured ID value with the local ID from step 4’s remap.theme_mods_<stylesheet>written directly. - URL retarget, two passes:
<source>/app/uploads → wp_get_upload_dir()['baseurl'](rewrites block-attrurlstrings +<img src>URLs to point at the S3 bucket)<source> → <target_url>(catches non-upload host references)
- Adapter
post_apply()—Fsesweepswp_global_stylesorphans whose stylesheet metadata doesn’t match the currentget_stylesheet()(theme-switch cleanup). - Markers stamped — subsequent re-applies short-circuit at step 2.
Iteration semantics
Different rows have different update behaviour on apply:| Row type | Behaviour |
|---|---|
| Owned-CPT (templates, template parts, global styles, navigation) | UPSERT by slug — designer edits propagate cleanly |
| Options + theme_mods | UPSERT by key — values overwrite |
| Attachments | UPSERT 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 |
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_stylesorphan cleanup is scoped. The Fse adapter deletes onlywp_global_stylesrows 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
What about a customer admin who edits a template via Site Editor on production?
What about a customer admin who edits a template via Site Editor on production?
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.Can I capture a snapshot from production?
Can I capture a snapshot from production?
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.What about classic (non-FSE) themes?
What about classic (non-FSE) themes?
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.Why are uploaded images in my Media Library NOT shipping?
Why are uploaded images in my Media Library NOT shipping?
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.What does the URL retarget actually do for images?
What does the URL retarget actually do for images?
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).How do I roll back a snapshot apply?
How do I roll back a snapshot apply?
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.How do I reset markers to force re-apply?
How do I reset markers to force re-apply?
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
| Repo | Role in the designer flow |
|---|---|
fp | Host-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-plugin | Ships 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-template | What you forked from. web/imports/.gitkeep is the placeholder for committed snapshots. |
charts | Install 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”. |