Backup API
REST endpoints for creating, listing, inspecting, verifying, restoring, rotating, and deleting backup bundles. All routes live under/api/v1/admin/backups and act on the workspace the request names. They admit an OWNER or ADMIN of that workspace, or an instance administrator, who need not be a member of it (the admin floor: RequireWorkspaceOrInstanceAdmin); handlers re-check with canAdministerInstance. Every mutation writes one row to audit_log.
Routes are registered in internal/api/router_admin.go (domain split in May 2026); handlers live in internal/api/backup.go.
Every endpoint except
metrics requires authentication and a workspace — workspace_id in the query or X-Workspace-ID, an id or a slug — and OWNER or ADMIN on it, or an instance administrator. Without a workspace an instance administrator gets 400. The metrics endpoint needs no workspace and admits only instance administrators — see its authorisation warning.Authentication and response contract
Every request uses the workspace it names in the query or header; no endpoint accepts a workspace ID in its body. Requests without a body are marked explicitly below, and path-based operations takepath as a query parameter. Statuses:
successful JSON operations return 200 or 201, successful deletion returns
204, and invalid input, authentication, authorization, conflicts, and server
failures use the status codes listed in each operation’s error table.
Endpoints
Path-based endpoints (
inspect, verify, download, restore, delete) validate that the supplied path lives under the server’s default backups directory ($CREWSHIP_DATA_DIR/backups, or ~/.crewship/backups if unset) and belongs to the caller’s workspace. Symlinks are explicitly rejected during path validation — no escape via ln -s to read files outside the backup directory. Paths from other workspaces return 404 Not Found rather than 403, so the endpoint does not confirm existence of bundles outside the caller’s scope.Create and list
Create new bundles and enumerate the ones already on disk for the current workspace.POST /api/v1/admin/backups
Create a new backup bundle. Runs inline (seconds to minutes); no async job queue.Request
Response — 201 Created
scope_level is omitted when the bundle has no explicit level recorded in its
manifest. missing_container_crews names provisioned crews whose container was
absent from the daemon — those crews are backed up DB-rows-only, so the export
is incomplete rather than silently whole. The key is always present in this
response: for a complete bundle it is null (not []), meaning every
provisioned crew’s filesystem was collected. In the bundle’s own manifest the
field is omitted when empty.
New bundles are written at
format_version 3 (backup.FormatVersion, bumped v2→v3 on 2026-08-03 when the payload gained the crew/<slug> section carrying the crew and per-agent memory trees). Restore enforces an N-2 compatibility window: this binary reads format_version in [MinSupportedFormatVersion, FormatVersion] = [1, 3]. A bundle newer than the reader returns ErrFormatTooNew (400); older than the window returns ErrFormatTooOld (400), whose message names the exact crewship backup convert command that writes a readable copy (see Compatibility).Page projects
A workspace-scope bundle whosepage_project_* rows reference source
projects, compiled artifacts or Git checkpoints carries them in a
page-projects/ section of the sealed payload — see the
bundle layout. It is collected under the page
project store’s shared lease, so the rows and the files agree; a referenced
source or artifact missing on disk, or an instance without
CREWSHIP_PAGE_PROJECTS_PATH while the rows reference one, fails the create
with 500 rather than writing a bundle that cannot be restored. The section
is absent, not empty, when nothing references it.
Errors
Audit action:
backup.create.
GET /api/v1/admin/backups
List bundles from the default backups directory ($CREWSHIP_DATA_DIR/backups, or ~/.crewship/backups if unset) filtered to the caller’s workspace (bundles of other workspaces are hidden from the response).
Response — 200 OK
backup_catalog first (reconciled against disk on each call); a missing/empty catalog falls back to a filesystem scan. scope_level is omitted when the source manifest did not record it.
Locking and metrics
Inspect or force-release the per-workspace advisory lock, and read process-lifetime backup counters.GET /api/v1/admin/backups/status
Report the workspace’s advisory lock.Response — 200 OK
held: false omits the detail fields. The lock TTL is 1 hour (backup.DefaultLockTTL). Missing workspace context returns 400 (not 401).
DELETE /api/v1/admin/backups/status
Force-release the per-workspace lock regardless of owner. Intended as an emergency escape hatch when a crashed backup left a stale lock behind. Returns204 No Content.
Audit action: backup.unlock.
GET /api/v1/admin/backups/metrics
Return a point-in-time snapshot of process-lifetime counters for the backup subsystem — created / failed / restored totals, size bytes, approximate duration quantiles, and per-workspace lock-hold seconds. Counters reset on every restart; persistent reporting should readaudit_log instead.
Response — 200 OK
Errors
Inspect, verify, and self-test
Read a bundle’s manifest, recompute its checksum, or run an end-to-end canary without leaving a bundle on disk.POST /api/v1/admin/backups/self-test
Per-crew round-trip canary: collect → destroy a canary marker inside the crew container → restore → verify → cleanup. Used by the seed CLI and CI harness to validate the backup pipeline end-to-end without leaving a bundle on disk. Lightweight: no encryption, no DB dump. Gated by workspaceOWNER/ADMIN (canRole(role, "manage")), the same as the other backup admin endpoints — not by CREWSHIP_OWNER_EMAIL.
Request body:
Response:
200 OK — backup.SelfTestResult JSON (collect/restore/verify status and timing for each phase).
GET /api/v1/admin/backups/inspect
Return the plaintext MANIFEST for a bundle without decrypting the payload.Query
The request has no body. Response:200 OK with the manifest JSON.
Response — 200 OK
Returns the manifest JSON as produced by backup.Inspect (internal/backup/manifest.go).
GET /api/v1/admin/backups/verify
Recompute the sealed-payload SHA-256 and compare against the MANIFEST — integrity. Never decrypts an ENCRYPTED bundle, so this needs no passphrase and is safe to run unattended against a whole directory of nightly bundles. For an UNENCRYPTED bundle, also compares the payload’s actual per-table row counts againstmanifest.contents.table_row_counts (#2009) — completeness. This is what closes the gap that let a bundle which dumped zero rows for a table (a create-side scoping bug, or corruption) still report valid: true: that divergence now sets valid: false too, not only a checksum mismatch.
Query
The request has no body. Response:200 OK with the verification object.
Response — 200 OK
valid: false sets error to a human-readable reason (checksum mismatch, truncated payload, unreadable bundle, a payload/manifest row-count divergence, …).
completeness_checked reports whether the row-count comparison ran at all — false means not evaluated, never “confirmed complete” and never “confirmed incomplete”. It is false for an encrypted bundle (completeness_skip_reason explains and points at restore --dry-run, which decrypts and makes the same comparison — see below) and for a bundle written before #2009, which carries no recorded counts to compare against. table_row_count_mismatches is non-empty only when completeness_checked is true and a divergence was found; each entry is {table, recorded, actual}. scope_shortfalls repeats the manifest’s create-time contents.scope_reconciliation.shortfalls: each entry is {table, reachable, missing}, the workspace rows the schema tied to the workspace that the dump did not export. It is checked for encrypted bundles too, and a non-empty list makes valid false.
Retention, transfer, and restore
Apply retention policy, stream bundles off-box, restore into the current workspace, or delete bundles from disk.POST /api/v1/admin/backups/rotate
Apply retention policy to the caller’s workspace. Per-workspace only — another workspace’s bundles are never enumerated.Request
keep_last / keep_days must be positive (0 disables the rule). Negatives are rejected. keep_last is a floor: the newest keep_last bundles are always kept, however old. keep_days applies only beyond it (alone, the floor is one bundle). Bundles pinned through POST /api/v1/admin/instance/backups/bundles/pin are never deleted. Crew bundles are counted separately from workspace bundles.
Response — 200 OK
dry_run: true the paths are returned but the files stay on disk and no audit rows are written.
Audit action (per deleted bundle, non-dry-run only): backup.rotate.
GET /api/v1/admin/backups/download
Stream the raw bundle bytes over HTTP forscp-free transfer off-box.
The request has no body. Response: 200 OK with an application/zstd
attachment; 400 is returned for a missing or unsafe path, 404 for a
bundle outside the workspace or no longer on disk, and 500 for a file error.
Response headers:
Content-Type: application/zstdContent-Disposition: attachment; filename="<file>"Cache-Control: no-storeX-Content-Type-Options: nosniff
backup.download.
POST /api/v1/admin/backups/restore
Restore a bundle into the current workspace.Request
Response — 200 OK
capability_tokens_reminted is non-null only on a forked restore
(as_workspace / as_crew): per-table counts of the capability tokens that
were re-minted — invitations, port exposures, pipeline and page webhooks,
public links — so no secret that worked against the source works against the
fork. null on a plain restore, which re-keys nothing.
docker_phase_skipped: true when as_workspace / as_crew rewrote the slug — container state is not landed. dropped_crew_filesystems names the crews that lost it. Finish the restore by starting each of them (POST /api/v1/crews/{crewId}/container-start) and then calling this endpoint again with files_only: true and the new workspace in context. It must be container-start and not /provision: files_only writes each section by exec’ing into the crew’s container, so the container has to be running, and provision only builds the image.
Re-calling WITHOUT files_only after a rename does not work and is not the recovery: the forked workspace matches neither the bundle’s id nor its slug, so the tenant guard returns 403 — and its rows are already present under new ids anyway.
crews_count is what the bundle describes; crews_restored is how many crews actually received container state. The two differ whenever a crew carries no filesystem sections or has no provisioned container on this instance. A files_only call that would land nothing is rejected rather than returning 200 with the bundle’s count — an operation reporting success without having written anything is the failure mode this whole area exists to have stopped.
security_level_clamped counts the restored credentials whose security_level was not one of the defined tiers (L1–L4) and were therefore written at the strictest tier instead — every other writer of that column validates it, so restore does too. security_level_clamps is a bounded sample naming the affected credentials and the value the bundle carried; the full list of what happened is also written to the journal as credential.security_level.clamped. On a dry_run both fields report what would be clamped. Re-set each affected credential with PATCH /api/v1/credentials/{id} (security_level) or crewship credential update <name> --security-level N.
columns_dropped counts the values this restore discarded because the bundle named a column this instance’s schema does not have; dropped_columns names them as {table, column, rows}, bounded to 20 pairs while the count stays exact. On a dry_run both report what would be discarded, which is the point at which you can still do something about it.
Dropping unknown columns is deliberate — it is what lets a bundle from a newer Crewship restore into an older instance — but it is not always harmless, and rows_inserted cannot show the difference. A row whose remaining columns satisfy the table lands without those values. A row that needed a dropped column to satisfy a NOT NULL or a primary key does not land at all, and INSERT OR IGNORE reports neither the violation nor the loss. That is what a non-zero columns_dropped is warning about: the bundle was written against a different schema, and this restore is incomplete in a way nothing else in the response records. Check each table named before treating the restore as done. Restoring a bundle taken before a non-additive migration (one that re-keys or renames rather than adds) is the case that produces it.
issue_counters_migrated (#2034) is the one exception the restore repairs instead of merely reporting: a bundle taken before migration 20260820125000_issue_counters_prefix_scope (#1797) carries issue_counters rows keyed on crew_id, which the current table does not have a column for — without this, the row would land in columns_dropped and, because crew_id was part of the old primary key, never actually restore. Each row is resolved instead, by crew, to its workspace and effective prefix (from the bundle’s own crews table, or the target’s if the crew already exists there) and written under the current (workspace_id, prefix) key; two crews sharing an effective prefix collapse onto one row, keeping the higher next_number. A row whose crew cannot be resolved anywhere still falls through to columns_dropped — it is not guessed at. On a dry_run, issue_counters_migrated reports what would be migrated.
payload_row_count_mismatches and rows_inserted_shortfalls (#2009) are two distinct completeness comparisons, both [{table, recorded, actual}]:
payload_row_count_mismatchesis the same comparisonGET /api/v1/admin/backups/verifymakes for an unencrypted bundle, run here regardless of encryption — restore already has the decrypted payload for other reasons. A non-empty result means the bundle’s decrypted contents disagree with what its own manifest recorded; the bundle is not what it claims to be, independent of whatever the target does with it.rows_inserted_shortfallscompares what actually landed (RowsInsertedByTable) against the manifest’s recorded counts, per table. A table can come up short here even whenpayload_row_count_mismatchesis empty — a dropped key column (columns_dropped, above) or a primary-key collisionINSERT OR IGNOREswallowed are the usual causes, and each of those already has its own more specific report; this is the summary that catches whatever isn’t covered by a more specific one.issue_countersis discounted for the rows a pre-#1797 migration legitimately collapsed (issue_counters_migrated, above) before this comparison runs, so a genuine shortfall on that table still surfaces. Empty on adry_run— nothing was inserted to compare.
journal_entries_resigned and journal_checkpoints_resigned (#2226) are non-zero only on a forked restore (as_workspace / as_crew). A fork regenerates the ids the journal hash chain commits to, so the chain is re-signed under a new genesis and no longer links back to the source’s history; the two counts say how many entries and compaction checkpoints were re-signed, and the same fact is written into the fork’s own journal as a backup.chain_resigned entry. Both are 0 on a plain restore, which remaps nothing — a clean verify on the fork is integrity of the fork’s chain, not provenance from the source. See Backup → Forked restores.
Neither field causes the restore to fail or roll back; both are reported so the operator can decide, the same way columns_dropped is.
Errors
Audit action:
backup.restore (or backup.restore.dry_run for dry_run: true).
DELETE /api/v1/admin/backups
Delete a bundle from disk. Returns204 No Content.
The request has no body. Response: 204 No Content; 400 indicates a
missing or unsafe path, 404 indicates a bundle outside the workspace, and
500 indicates a delete failure.
Query
Audit action:
backup.delete.
Error mapping
internal/api/backup.go:statusForBackupError maps sentinel errors to HTTP status using errors.Is, so the backup package can rework error wording without breaking HTTP contracts:
See also
- Backup & Restore guide — end-to-end walkthrough.
- Security → Audit log — audit row layout for
backup.*events.