A CMS you cannot leave has not convinced you, it has trapped you. With RustyCMS the content is JSON5 whatever backend sits underneath — any state can be fetched as a ZIP, saved on a schedule, read as Git history. Moving hosts, promoting staging and restoring after an incident use one mechanism.
One ZIP, everything in it
curl -H "Authorization: Bearer $KEY" \
"https://cms.example.com/api/export?_environment=prod&scope=types,content&collections=post,page" \
-o rustycms-prod.zip
scope selects schemas, content and assets individually, collections narrows the content, _environment picks the space. The archive holds all three schema layers — repo defaults, subscribed type packs, per-space overrides — plus content, assets and their editorial sidecars. A manifest.json records version, timestamp, scope and filter. Importing into a fresh installation yields a working CMS without re-subscribing packs.
Import validates before it writes
POST /api/import accepts the ZIP as a multipart upload. Every entry is validated against the resolved schema; with onValidationError=skip failures are logged and skipped, with abort the import stops. overwrite=true replaces existing entries, without the flag they stay untouched. Assets are checked by extension, metadata travels in the sidecar.
Staging to prod in two commands
curl -u $KEY "https://cms/api/export?_environment=staging&scope=types,content" -o p.zip
curl -u $KEY -F "file=@p.zip" \
"https://cms/api/import?_environment=prod&overwrite=true&onValidationError=abort"
abort is the right choice for a promotion: better to halt than to half-import. scope=types moves only schema state, content untouched.
Backups run on their own
Set RUSTYCMS_BACKUP_DIR and you are done. A background job writes one ZIP per space at a fixed interval (RUSTYCMS_BACKUP_INTERVAL_HOURS, default 24), named rustycms-backup-<env>-<UTC timestamp>.zip and sortable by name. Older ones are pruned after each run (RUSTYCMS_BACKUP_RETENTION_COUNT, default 14). The archives are byte-identical to /api/export, so anything in a backup imports through the API. An rsync or restic alongside takes the copy off-site.
Git history without files
In file mode, RUSTYCMS_GIT_AUTOCOMMIT=true is enough: every write is followed by a commit, the repo is the history. In SQLite or Postgres mode there are no files to commit — hence the mirror. Set RUSTYCMS_GIT_MIRROR_DIR on the backup cadence the same export is unpacked into a Git working tree and committed, only when something changed. That gives git log, git diff and git blame over content that lives in rows. An existing repo keeps its remote, so a git push in cron fits next to it. Assets stay out by default (RUSTYCMS_GIT_MIRROR_ASSETS=on includes them), because a repo grows with every version of every binary and never shrinks.
What doesn’t happen
The mirror is a read copy: edits in the working tree are lost on the next run — writes go through the API. Backups land in a local directory, not in cloud storage; forwarding is your backup tooling’s job.