Sep 05, 2023 ·
GitOps-Style Wagtail Content Transport
Content drift is easy to dismiss until production has the approved copy, staging has yesterday's blocks, and a developer's database has the only working page structure. At that point, "just copy it in the CMS" becomes deployment work with no review trail.
Content drift is easy to dismiss until production has the approved copy, staging has yesterday's blocks, and a developer's database has the only working page structure. At that point, "just copy it in the CMS" becomes deployment work with no review trail.
This use case came from a Wagtail site where pages, snippets, settings, and media references needed to move between environments without relying on manual admin work. The design exported editorial state into portable JSON, replaced local page and image IDs with stable references, and replayed that file through idempotent Django management commands.
_This is what I currently use with this very website, because as a developer, I live in the IDE and write markdown, alot more than I write a blog_
This is content GitOps, not infrastructure reconciliation. That caveat keeps the design honest. The pattern borrows the useful GitOps habit of versioning desired state, but it does not claim a controller is continuously reconciling infrastructure.
Version content as state
OpenGitOps defines GitOps around declarative, versioned desired state that is pulled and reconciled automatically. The Wagtail approach here borrowed the parts that made sense for content: put the desired editorial state in a versioned file, review it like code, and replay it into another environment through a repeatable command.
It did not try to make every operational setting declarative. It did not reconcile servers, databases, or queues. It moved content state. That narrower boundary is why the approach was practical.
The exported JSON had a top-level version, export timestamp, settings, snippets, and pages. Settings carried editorial strings used by the site. Snippets carried reusable content entries identified by natural keys. Pages carried structured page data, including raw StreamField content where needed.
That structure is valuable because it makes editorial state inspectable. A reviewer can see whether a page changed, whether a reusable content entry was added, or whether a label changed. Manual CMS copying gives none of that unless the team separately documents every click.
Transport raw StreamField data
Wagtail StreamField content is stored as JSON-like data, and Wagtail's docs describe raw stream data as a supported surface for data migrations. The export command used that reality directly. It materialized raw StreamField data into plain JSON-compatible values before applying reference replacement.
That materialization step matters. Lazy or framework-specific views are useful inside Wagtail, but a content manifest must be portable JSON. If the export writes values that cannot be serialized or replayed, the manifest stops being a release artifact and becomes a fragile database snapshot.
Raw StreamField transport is powerful, but it is not proof that every block type round-trips without custom handling. The transport helper knew about specific page reference keys and image reference keys. Blocks that used those key names benefited automatically. Blocks that stored references under different names needed explicit transport rules.
That is a healthy constraint. Content transport should be boring and explicit. When a new block type carries an environment-specific ID, the transport layer should learn that reference shape during review, not after import warnings in the target environment.
Replace local IDs with portable references
The shared transport helper was the center of the design. During export, it walked dictionaries and lists recursively. When it found a page reference key with an integer value, it replaced the local page ID with a marker containing the page slug. When it found an image reference key with an integer value, it replaced the local image ID with a marker containing the image title. Everything else was processed recursively or returned unchanged.
During import, the helper performed the inverse operation. A page marker resolved through a page lookup. An image marker resolved through an image lookup. Missing references became warnings rather than silent assumptions.
That ID replacement is the difference between a database dump and a portable content manifest. Wagtail page IDs and image IDs are environment-local. Slugs and managed image titles are not perfect global identifiers, but they are stable enough to review, transport, and resolve when the content model controls the reference shape.
The design also kept secrets out of scope. The export command treated the content file as editorial state only. That constraint matters because "export the CMS" can easily become "commit production configuration by accident" if the boundary is vague.
Import in two phases
The import command was built around repeatability. It checked that the content file existed, validated the manifest version, found the default Wagtail site, verified the expected root page type, imported settings and snippets, ensured known media existed, then processed pages.
The page work happened in two phases. Phase one ensured every page existed so slug references could resolve. It created missing child pages in the expected places and left new pages as drafts. Phase two applied content, streams, child rows, and publish behavior.
That order prevents a common content transport failure. If a page block links to another page that has not been created yet, a one-pass import either fails or drops the reference. A two-phase import creates the reference targets first, then resolves block content against the now-known page set.
The command also used idempotent update patterns where it could. Reusable content entries updated or created by natural key. Page children updated or created by stable local identity. Existing pages were looked up by slug instead of blindly recreated.
Idempotent does not mean risk-free. Draft imports can overwrite editorial intent if the content model changed or if the reviewed JSON is stale. The practical safety property is narrower: repeated imports should update the intended objects rather than create duplicates.
Seed media for clean environments
Portable image markers are only useful if a fresh environment can resolve them. The import path promoted known local assets into Wagtail media when matching images were missing. Those assets had stable titles and filenames maintained with the content manifest.
That small step made content transport usable on a clean database. Without it, a manifest could contain an image marker and still fail to render because the target Wagtail media library had no matching image. By pairing known assets with the content manifest, the import could depend on reviewable media instead of manual uploads.
The design stayed conservative. It promoted known assets. It did not scrape arbitrary remote media, did not export secrets, and did not try to infer missing files. If an expected image could not be resolved, it recorded a warning.
For Wagtail sites with more complex media, the same pattern can grow. Give transportable media a stable natural key, make the binary asset reviewable or otherwise reproducible, and fail visibly when it cannot be resolved. Silent media loss is worse than a loud import warning.
Release path
Use this pattern when content changes need the same review path as code. Export from the environment where the editorial state is approved. Review the JSON diff. Confirm that new StreamField blocks use transport-aware reference keys. Commit the manifest and known media together. Import into the target environment as drafts unless the release process explicitly publishes.
Before import, check three things. First, the target site root must be the expected page type. The command should abort rather than apply content to the wrong tree. Second, the content version must match the importer. A version gate gives future format changes a place to fail early. Third, review warnings after import. Missing page or image references are content defects, not harmless noise.
After import, use Wagtail admin review as the editorial checkpoint. The command can transport content, but it should not replace approval. A replayable import gets the state into the CMS. Editorial judgment still decides what should go live.
When to use this pattern
Use this pattern when Wagtail content drifts between environments, when page changes need reviewable diffs, or when clean databases need repeatable content setup. Start with one page family and one snippet type. Make the manifest small enough to review. Add reference handling only for the block fields that exist. Then repeat the import twice in a disposable database and confirm it updates the same objects rather than multiplying them.
Use this if you are a developer who wants to keep content in version control, or if you are a content editor who wants to review changes before they go live. Use this if you want to avoid manual CMS clicks and have a repeatable, auditable process for moving content between environments.
Avoid this pattern if the content model is changing faster than the manifest can be reviewed, if media cannot be reproduced or resolved, or if the team expects content transport to reconcile infrastructure. Content GitOps is not infrastructure reconciliation.
If your next release depends on CMS content being correct across environments, bring one page family and its reference fields. I can help turn it into a portable, repeatable content manifest before the release depends on manual admin clicks.