What runs when
Dockup is a long running container. It discovers labelled volumes and containers, resolves their policy and schedules one cron entry per source and schedule. A periodic resync (default every minute) plus Docker events keep the plan current.
$ dockup plan # resolved sources, schedules, next runs, issues
$ dockup status # last run, next run, size, issues; works without DockerManual backups
$ dockup backup # every valid source, once
$ dockup backup --source volume:appdata
$ dockup backup --dry-run # show the keys a run would createManual backups run next to the daemon. A per-source lock in the state volume makes the second caller fail with source is already being backed up instead of archiving the same volume twice.
Listing archives
$ dockup list --source volume:appdata
$ dockup list --json | jq '.[] | {key, size, time}'list reads the targets, so it needs the same credentials as a backup.
Restoring
$ dockup restore --source volume:appdata --dry-run # list the contents
$ dockup restore --source volume:appdata # refuses a non-empty volume
$ dockup restore --source volume:appdata --force # overwrite
$ dockup restore --source volume:appdata --archive dockup/nas-01/volume/appdata/daily/appdata_20261101T032300Z.tar.zst.ageWhat happens:
- The newest archive is selected (
--archive latest, the default) from the first target of the source, or from--target. - It is downloaded, decrypted (if
.age) and decompressed in the controller. - A helper container mounts the volume or bind path read-write and untars the stream into it.
- Unless
--forceis passed, the helper refuses to write into a directory that already has content (exit code 3 → "pass--forceto overwrite").
A restore does not stop the workload that uses the volume. Stop it first if it would fight over the data:
$ docker compose stop app
$ docker compose run --rm dockup restore --source volume:appdata --force
$ docker compose start appRestores are streaming and idempotent: nothing is written until the archive has been opened successfully, and a failed restore leaves the partial files for you to inspect rather than silently succeeding.
What is restored
- file contents, modes, modification times;
- ownership, when Dockup runs as root (it does by default);
- directories and symlinks; hard links are recreated when the archive contains them;
- extended attributes and ACLs are not preserved, and sockets/fifos/devices are never archived (they are skipped with a warning during backup).
Retention
Retention runs after every successful upload, per target:
volumes:
appdata:
labels:
dockup.keep-last: "7"
dockup.keep-daily: "7"
dockup.keep-weekly: "4"
dockup.keep-monthly: "12"
dockup.max-age: "400d"- The newest archive is never pruned, even when
max-agesays it is too old. - A source with no retention settings keeps everything.
- Pruning is skipped when an upload failed, so a broken target cannot delete the archives that still exist.
dockup prune --dry-runshows what would be deleted;dockup prunedoes it without backing up.
Monitoring
| Endpoint | Meaning |
|---|---|
/healthz | Liveness. |
/readyz | Docker has been read successfully. |
/metrics | Prometheus exposition, including dockup_source_last_success_timestamp_seconds. |
A useful alert is "no successful backup for a source in more than 26 hours":
time() - dockup_source_last_success_timestamp_seconds > 26 * 3600The container also ships a HEALTHCHECK that calls dockup healthcheck, which queries /readyz from inside the distroless image.
Notifications
notify:
on-success: false
webhook:
url: https://example.com/dockup
healthcheck:
success: https://hc-ping.com/<uuid>
fail: https://hc-ping.com/<uuid>/fail
telegram:
token-file: /run/secrets/telegram-token
chat-id: "123456789"Failures are always delivered; successes only with on-success. Every channel is independent, and a broken notifier never fails a backup.
Disaster recovery
The state volume only holds history, metrics and locks. Archives are self-describing:
<prefix>/<host>/volume/<volume>/<schedule>/<name>_<timestamp>.tar[.zst|.gz][.age]To recover on a fresh host without Dockup:
# local target
$ zstd -dc appdata_20261101T032300Z.tar.zst | docker run --rm -i -v appdata:/data busybox tar xf - -C /data
# encrypted archive
$ age -d -i age-identity.txt appdata_20261101T032300Z.tar.zst.age | zstd -dc | docker run --rm -i -v appdata:/data busybox tar xf - -C /data
# S3
$ aws s3 cp s3://bucket/dockup/nas-01/volume/appdata/daily/ -r .Troubleshooting
| Symptom | Check |
|---|---|
source is already being backed up | A scheduled or manual run is in flight; dockup status --history 5 shows it. |
volume declares dockup labels but is not enabled | Add dockup.enable=true. |
target "x" is not configured | dockup config targets. |
another dockup instance is already running | Only one agent per host is supported; stop the other container. |
cannot determine own image | Set helper-image when running outside a container. |
A run fails at prune | The archive was uploaded; the target listing or delete failed. Check credentials and lifecycle rules. |
refusing to restore into a non-empty volume | Pass --force if overwriting is intended. |
Testing
Two layers, both required before a release:
| Layer | Where | What it covers |
|---|---|---|
| Unit | go test ./internal/... | label parsing, planning, retention selection, schedule resolution, config, redaction, tar writing/extraction, compression, age, key layout, target adapters against gofakes3, retry policy, notifications, state store, metrics, CLI |
| Integration | go test -tags integration ./test/integration/... | the real image against a real Docker daemon and a real S3 server |
The integration suite builds (or uses DOCKUP_TEST_IMAGE) the actual container, starts a real MinIO (pgsty/minio) as the S3 endpoint, and then drives complete deployments: labelled volumes with awkward contents, workloads that get stopped and hooked, restores back into volumes, retention, concurrency and interruption. Assertions read volumes back through tar streams, so nothing depends on host paths being visible from the test process.
$ make integration # local Docker + a MinIO container
$ DOCKUP_TEST_IMAGE=dockup:dev make integration
$ DOCKUP_TEST_KEEP=1 go test -tags integration ./test/integration/... # keep failures aroundEdge cases covered deliberately: empty volumes, zero-byte files, spaces, tabs, newlines, quotes, emoji and 160-character names, symlinks, hardlinks, 0400/0640 modes, non-root ownership, 24 MiB single files, 1,200-file trees, fifos (skipped with a warning), excludes, all three compression modes, encrypted archives with and without an identity, fan-out to two targets, per-target failures, retention pruning and its dry run, bind-mount sources, pre/post hook ordering, failing hooks, containers that must not be touched, concurrent backups, an interrupted backup, an unwritable spool directory, a second daemon, and a daemon without a Docker socket.