Flow: backup and restore
Step-by-step trace of shc backup create and shc backup restore.
Source of truth:
modules/backup/service.go (CreateBackup — the restic pipeline),
modules/backup/capture.go + modules/backup/orchestrator.go,
and modules/backup/restore.go.
Architecture overview: backup module.
Two backup destinations are supported, chosen by the caller:
- Restic repo — default. Content-addressed, deduplicated, encrypted.
Data lives in the repo at
config.backup.restic.repo_urland only aBackupCacheEntryis kept locally. Used for scheduled / retention- managed backups. - Local
.bakarchive — explicit--output <path>. A single self-contained file on disk. Used forexport/importstyle workflows, for clone/move, and for hand-off to an offline tenant.
Both paths share the same capture pipeline (steps 1-9 below) and diverge at step 10 on how the staging directory is finalized.
Inputs
Section titled “Inputs”func (s *BackupService) Create(stack *DeploymentModel, request BackupCreate, output string) (*BackupModel, error)request.Environment— which environment of the stack.request.RunJobs— whether to run thepre_backup/post_backupbackup config jobs (typicallymysqldump,pg_dump, etc).request.CompressionLeveloutput— if set, useCreateLocalBackupinstead of restic.
Shared capture pipeline (restic flow; local flow mirrors it)
Section titled “Shared capture pipeline (restic flow; local flow mirrors it)”1. Concurrency guard (lines 49-51)
Section titled “1. Concurrency guard (lines 49-51)”getRunningBackup(stack.Name, tenant) — if another backup is already
in flight for this stack, return X400402BKPRUN. Prevents double-snapshots.
2. Create BackupModel row with status=RUNNING (lines 53-71)
Section titled “2. Create BackupModel row with status=RUNNING (lines 53-71)”A backup_id is minted. The DB row carries status and started_at
from the moment the backup starts — so the UI can show “backup in
progress” even before data starts flowing.
3. Orphan-tracking variables (lines 78-79)
Section titled “3. Orphan-tracking variables (lines 78-79)”createdSnapshotID and createdSnapshotRepo start as empty strings.
They’re set immediately after the restic CreateSnapshot succeeds so
the error handler (step 11) can forget an orphan snapshot if a later
step fails.
4. Open / create the restic repo (lines 82-84)
Section titled “4. Open / create the restic repo (lines 82-84)”restic.GetRepoPath(stack, env) resolves the per-stack repo path;
EnsureInitialized(repo) is idempotent.
5. Create staging directory (lines 86-90)
Section titled “5. Create staging directory (lines 86-90)”os.MkdirTemp(paths.StagingDir, ...). All captured data
lands here first; restic snapshots this directory. Cleaned in the
defer block regardless of success.
6. Load backup config + run pre_backup hook (lines 94-99)
Section titled “6. Load backup config + run pre_backup hook (lines 94-99)”loadBackupConfig(stack) reads the stack’s backup.yaml (if any)
and gives us Include / Exclude / Converters / Jobs /
ConverterMounts. The pre_backup user hook runs; if
request.RunJobs=true, the Jobs.PreBackup list runs too.
7. Rsync volumes into staging (lines 107-133)
Section titled “7. Rsync volumes into staging (lines 107-133)”snapshotVolumes(...) walks the stack’s Docker volumes on the
owning node and copies them into staging/volumes/mounts/<vol>/. It
respects Include/Exclude globs from the backup config. For
volumes with snapshot-capable filesystems (Btrfs, ZFS, LVM — see
snapshot.go), backupVolumesWithStop uses a filesystem
snapshot to avoid having to stop the stack.
Note: converter-managed mounts are NOT excluded from rsync here. Converters need the data in staging to work on; we delete the raw copy in step 8 after the converter has produced its own output.
8. Run converters (lines 141-207)
Section titled “8. Run converters (lines 141-207)”Converters are user-declared “dump producers” for e.g. databases: an
image that runs pg_dump / mysqldump / mongodump against a volume
and emits a logical backup. Each converter:
- Runs in a container (
converterService.Run) against the rsync’d snapshot, not the live data — so no locking required. - If successful, its output is copied to
staging/converters/<name>/and aBackupArchiveInfoentry is added toconverterArchiveswithKind=ARTIFACT. - Its raw volume data is deleted from
staging/volumes/mounts/(step 202-207) — the backup stores only the converter’s logical dump, not the raw InnoDB / WiredTiger / etc. files. - If the converter fails, the raw data is kept (safer) and the backup continues with a log message.
9. Capture recovery metadata (lines 210-291)
Section titled “9. Capture recovery metadata (lines 210-291)”Six pieces of metadata go into the manifest so restore can reconstruct the stack, not just the data:
| Captured | From | Needed because |
|---|---|---|
integrations | captureIntegrations | Restore re-wires plugs/sockets on a fresh stack |
port_mappings | capturePortMappings | Restore reserves the same ingress ports |
host_mappings | captureHostMappings | Restore recreates DNS hosts |
stack_record | createStackRecord | Rebuild the DeploymentModel row |
deployment_vars | deployment_dir/vars.yaml | Re-render compose with the same variables |
vault_bundle | exportVaultSecrets | Re-import secrets under the new stack’s namespace |
WriteManifest and the fullManifest JSON both land in
staging/.shc/ so restic snapshots them along with the data.
10. Commit to durable storage
Section titled “10. Commit to durable storage”10a. Restic path (lines 303-327)
Section titled “10a. Restic path (lines 303-327)”restic.CreateSnapshot(repo, []string{stagingDir}, tags)
creates the actual restic snapshot. On return, createdSnapshotID
is set — the orphan guard is now armed.
A BackupCacheEntry row is added to the local cache so the cluster
can list backups without roundtripping restic; then the BackupModel
row is updated to Status=COMPLETED with size, location
(restic:<repo>:<snapshot_id>), and the full BackupManifest JSON.
10b. Local .bak path (local_flow.go)
Section titled “10b. Local .bak path (local_flow.go)”Instead of calling restic, CreateLocalBackup tars the staging dir
into the --output path (optionally compressed per
CompressionLevel). The BackupModel.Location records the archive’s
filesystem path, and no cache entry is created (local archives are
self-describing).
11. Post-backup (lines 363-375)
Section titled “11. Post-backup (lines 363-375)”post_backupuser hook.- If
request.RunJobs=true, theJobs.PostBackuplist runs. V200401BKPCMPevent emitted.applyRetentionAfterBackup(tenant, stack)runs retention pruning (seeretentionmodule).
12. Cleanup (lines 379-384)
Section titled “12. Cleanup (lines 379-384)”The defer block removes the staging directory regardless of
success or failure.
Error path (lines 386-429)
Section titled “Error path (lines 386-429)”If any step from 4-11 returns an error:
- Forget orphan restic snapshot — if
createdSnapshotIDwas set (step 10a succeeded before a later step failed),restic.ForgetSnapshotis called. Failures here are logged at debug level only — the original error is what the caller will see. - Remove orphan cache entry —
cache.Remove(backupID)is a no-op when absent, so it’s safe to call unconditionally. - Mark
BackupModelFAILED — status,ErrorMessage,CompletedAt. A commit failure here is logged and swallowed. - Emit
V400400BKPFALevent. - Return the original error.
Two properties worth noting:
- Orphan safety: if the process crashes between
CreateSnapshotand the DB commit, the next backup of this stack will skip the orphan (restic won’t see it tagged with anything useful), but it will still cost repo space until pruned. The explicit forget handles the common case where only the Go side failed. - Staging cleanup is always attempted:
os.RemoveAll(stagingDir)is in adeferblock, so it runs in all cases.
Restore flow
Section titled “Restore flow”RestoreService in restore.go
is the inverse, with the twist that restore always targets a
possibly-different stack: you can restore backup A into a stack
named B to duplicate, rename, or migrate between nodes.
High-level stages (see type documentation for the full API):
- Extract — pull the backup out of restic (or untar the
.bak) into a working directory. The.shc/manifest.jsonis read first. - Plan — compute the target paths, new vault namespace, new Docker project name, new port/host reservations. Conflicts return errors before any data is written.
- Install the target stack — reuses the install pipeline (see
install.md) withvariablestaken from the manifest. This is what creates theDeploymentModelrow, ingress reservations, and runspre_install. - Restore volumes — rsync from the extracted staging dir into the target stack’s volumes. Converter-managed mounts are handled specially: the converter’s restore direction (declared in the converter config) is invoked to apply the logical dump back into the live database.
- Restore vault — re-import the manifest’s
VaultSecretsinto the target stack’s vault namespace. - Restore integrations — re-create
IntegrationModelrows targeting the new stack id (remapped perrestore_mappings.go). - Start — start the stack via
compose up.
Each step is transactional at the step level; a failure at step N
leaves steps 1..N-1 in place and raises with enough context to retry
or to shc uninstall <target> and start over.
Events
Section titled “Events”| Event | When |
|---|---|
V200401BKPCMP | Backup succeeded |
V400400BKPFAL | Backup failed |
V200402RESCMP | Restore succeeded |
V400401RESFAL | Restore failed |
X400402BKPRUN | Concurrent backup blocked |
Full reference: errors.md.
See also
Section titled “See also”- Architecture: backup module
- Architecture: storage module — snapshot capability detection
- Architecture: vault module — how secrets are exported and re-imported
clone-move.md— uses backup+restore as the transport