-
Notifications
You must be signed in to change notification settings - Fork 0
Backing up and Restoring
On-demand volume backup and in-place restore for pod instances managed by podman-api. Introduced in #66 (OSS primitive).
A backup captures every Podman volume attached to one instance at the same stopped moment — a consistent, SQLite-safe snapshot:
- The instance is stopped.
- Each volume is exported as a plain uncompressed tar (
podman volume export), streamed through the API server, and written to a local artifact file. - A sha256 manifest (path → hash + size for every file in the tar) is built from the same bytes in one pass and stored in the state DB alongside the backup record.
- The instance is restarted — only if it was running before the backup began. A deliberately-stopped instance stays stopped after the backup completes or fails.
Downtime note. The instance is unavailable for the duration of the export (step 2). Export time is proportional to the total volume data. Live/zero- downtime backup is out of scope for the OSS tier.
Race warning. Starting the instance manually while a backup job is running can capture a live (possibly inconsistent) volume export — let backup jobs finish before issuing lifecycle actions.
Parameters, secrets, and domains are not captured — the backup is volumes only. The container image reference at backup time is recorded as an informational hint. Restore re-applies the instance's current spec (whatever parameters and secrets are in the state store at restore time).
Artifact files are written to the local filesystem of the API server under
-backup-dir (flag; default <state-db dir>/backups).
Layout:
<backup-dir>/<host>/<template>/<slug>/<backup-id>/<volume-name>.tar
Example:
/var/lib/podman-api/backups/prod-1/postgres/my-db/bk_01J4XY.../data.tar
Each .tar file is written via a temp file (os.CreateTemp) and renamed
atomically into place only when the write is clean (Commit). A partial write
is never visible as a complete backup. A process crash during export leaves a
.tmp-* file in the backup's directory; it is cleaned up automatically when
the backup is deleted (DeleteAll walks the whole directory prefix).
sha256 manifests are stored in the state DB (the backups table, per-volume
JSON field), not in the artifact files. This means:
- A hand-deleted artifact file does not corrupt the DB record — the backup
transitions to
backup_not_restorableon the next restore attempt. - Restore verifies the imported data against the stored manifest, which it does not trust the artifact to supply.
- podman-api with
-state-dbset (always on in practice — the template catalog requires it). - Podman >= 5.6.0 on every managed host (
podman volume export/importwas stabilised there; the daemon enforces this as a boot-time preflight, per #85). - Disk space on the API server host: roughly equal to the total uncompressed volume data per backup, multiplied by however many backups you keep. No automatic pruning — operator owns the disk.
All requests require a bearer token with the appropriate scope.
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
https://podman-api.example.com/hosts/prod-1/instances/postgres/my-db/backupScope: instances:write
The backup is enqueued as an async job. The response carries both the job ID (for polling) and the backup ID (available immediately, before the job runs):
{
"job_id": "01J4XY...",
"backup_id": "bk_01J4XY..."
}Poll for completion:
curl -s -H "Authorization: Bearer $TOKEN" \
https://podman-api.example.com/jobs/01J4XY...A completed job looks like:
{
"id": "01J4XY...",
"kind": "backup",
"state": "succeeded",
"steps": [
{"name": "load", "detail": "prod-1/postgres/my-db"},
{"name": "stop", "detail": "prod-1"},
{"name": "export-volume","detail": "data"},
{"name": "restart", "detail": "prod-1"},
{"name": "complete", "detail": "bk_01J4XY..."}
]
}curl -s -H "Authorization: Bearer $TOKEN" \
"https://podman-api.example.com/hosts/prod-1/instances/postgres/my-db/backups?limit=10"Scope: instances:read
Returns newest-first. ?limit= is the only pagination parameter; absent or
<=0 defaults to 100, clamped at 1000 maximum:
{
"backups": [
{
"id": "bk_01J4XY...",
"host": "prod-1",
"template": "postgres",
"slug": "my-db",
"state": "complete",
"image": "docker.io/library/postgres:16",
"volumes": [{"name": "data", "size_bytes": 52428800}],
"created": "2026-06-06T10:00:00Z",
"finished": "2026-06-06T10:01:23Z"
}
]
}state is one of creating, complete, or failed. Only complete backups
are restorable.
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
https://podman-api.example.com/backups/bk_01J4XY.../restoreScope: instances:write
The restore is enqueued as an async job:
{"job_id": "01J4ZZ..."}Poll GET /jobs/01J4ZZ... for progress. A successful restore job steps
through: load → teardown → restore-volume (one step per volume) →
apply → verify.
The endpoint validates synchronously before enqueuing:
- The backup exists and is in
completestate. - The backup's host is known and not draining (a 423 is returned if it is).
- The instance spec exists in the state store.
A draining host is refused synchronously (before teardown) so the job cannot be left in a half-restored state on a host that is being evacuated.
curl -s -X DELETE \
-H "Authorization: Bearer $TOKEN" \
https://podman-api.example.com/backups/bk_01J4XY...Scope: instances:write
Synchronously removes the artifact files (the whole <backup-id>/ directory),
then the DB row. Returns 204 on success.
Returns 409 backup_busy if a backup job or a restore job targeting this
backup is currently queued, running, or reconciling.
The instance detail page (/ui/hosts/{host}/instances/{template}/{slug})
shows a BACKUPS section with:
- Back up now button — triggers the confirmation dialog ("The instance is stopped for the duration of the backup"), then enqueues a backup job and displays a notice banner with the job ID.
- A list of existing backups (ID, timestamp, state, image hint, per-volume
size).
-
Restore button (only on
completerows) — triggers the confirmation dialog ("This stops the instance and OVERWRITES its current data"), then enqueues a restore job and re-renders the page with a notice. - Delete button — removes the backup after a browser confirm prompt.
-
Restore button (only on
All UI actions are HTMX requests to /ui/... endpoints that mirror the API
behaviour (same validation, same job enqueue).
Restore is in-place and destructive:
- The instance is stopped.
- The pod and all its volumes are torn down (
DeletewithPruneVolumes). Per-instance and host-scoped secrets are kept —Applyre-pushes them from the stored spec. - Each volume is recreated and imported from the backup's artifact file.
- The content of every restored volume is verified against the sha256 manifest stored in the DB. Verification always runs (unlike migrate, where it can be disabled). A mismatch fails the job before declaring success.
-
Applyre-runs the current spec (current parameters, secrets, domains) to recreate containers and start the instance. - The job waits until every container is
Runningand every declared healthcheck reportshealthybefore succeeding.
There is no rollback. A failure after step 2 (teardown) leaves the
instance down with volumes partially restored. The spec row is
preserved — the restore can be retried by submitting another POST /backups/{id}/restore request. The job error names the failed step so you
know which volume or which apply phase to investigate.
The instance is left down (not auto-restarted) on failure. This is intentional: an import error or verify mismatch means the data is suspect; bringing the instance up against suspect data would hide the problem.
DELETE /backups/{id} returns 409 backup_busy if any of the following
active jobs targets the same backup ID:
- A
backupjob (the backup is still being written) - A
restorejob (a restore is in progress from this backup)
The gate is job-based, not row-state-based. A crashed daemon can leave a
creating row with no live job, and that row must remain deletable. After the
next boot the row is failed by the boot reconciler and no active job references
it, so DELETE proceeds normally.
The job runner marks in-flight jobs failed at boot. The backup kind has
a reconciler (ReconcileBackup) that runs at boot for any creating-state
backup row:
- Marks the row
failed(CAS — if the row is alreadycomplete, work finished and only the job's terminal write was lost, so no cleanup is needed). - Calls
DeleteAllon the backup's artifact prefix to remove any partial.taror.tmp-*files. - Attempts to restart the instance (unconditionally — post-crash the prior run-state is unknowable; reconcile errs toward availability). A deliberately-stopped instance interrupted mid-backup may come back running after a daemon crash.
- If the host is no longer in the config, the backup is still marked failed and partial blobs cleaned; the restart is skipped and the reconciler resolves terminal (no retry loop).
The restore kind does not have an automated reconciler. The boot runner
marks the job failed. The operator re-runs the restore by submitting a new
POST /backups/{id}/restore. The blob is unmodified and the spec was
preserved, so the retry is safe and idempotent.
The following are not implemented in this release and are planned for the commercial tier (#107) or future slices:
- Scheduled backups — no keep-last-N, no cron-triggered backups. Trigger via API or UI only.
- Retention policy — no automatic pruning. Operator manages disk.
-
Offsite / S3 targets — artifacts are local to the API server filesystem
only. The
BlobStoreinterface is the seam where an S3/offsite backend slots in (#107). - PITR / Litestream-grade replication — no continuous or incremental backup. Each backup is a full stop-and-export snapshot.
- Restore to a different host — restore requires the instance on its original host. DR restore-to-another-host is a possible follow-on.