Skip to content

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.

console
$ dockup plan      # resolved sources, schedules, next runs, issues
$ dockup status    # last run, next run, size, issues; works without Docker

Manual backups ​

console
$ dockup backup                       # every valid source, once
$ dockup backup --source volume:appdata
$ dockup backup --dry-run             # show the keys a run would create

Manual 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 ​

console
$ 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 ​

console
$ 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.age

What happens:

  1. The newest archive is selected (--archive latest, the default) from the first target of the source, or from --target.
  2. It is downloaded, decrypted (if .age) and decompressed in the controller.
  3. A helper container mounts the volume or bind path read-write and untars the stream into it.
  4. Unless --force is passed, the helper refuses to write into a directory that already has content (exit code 3 → "pass --force to overwrite").

A restore does not stop the workload that uses the volume. Stop it first if it would fight over the data:

console
$ docker compose stop app
$ docker compose run --rm dockup restore --source volume:appdata --force
$ docker compose start app

Restores 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:

yaml
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-age says 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-run shows what would be deleted; dockup prune does it without backing up.

Monitoring ​

EndpointMeaning
/healthzLiveness.
/readyzDocker has been read successfully.
/metricsPrometheus exposition, including dockup_source_last_success_timestamp_seconds.

A useful alert is "no successful backup for a source in more than 26 hours":

promql
time() - dockup_source_last_success_timestamp_seconds > 26 * 3600

The container also ships a HEALTHCHECK that calls dockup healthcheck, which queries /readyz from inside the distroless image.

Notifications ​

yaml
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:

console
# 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 ​

SymptomCheck
source is already being backed upA scheduled or manual run is in flight; dockup status --history 5 shows it.
volume declares dockup labels but is not enabledAdd dockup.enable=true.
target "x" is not configureddockup config targets.
another dockup instance is already runningOnly one agent per host is supported; stop the other container.
cannot determine own imageSet helper-image when running outside a container.
A run fails at pruneThe archive was uploaded; the target listing or delete failed. Check credentials and lifecycle rules.
refusing to restore into a non-empty volumePass --force if overwriting is intended.

Testing ​

Two layers, both required before a release:

LayerWhereWhat it covers
Unitgo 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
Integrationgo 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.

console
$ 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 around

Edge 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.

Released under the Mozilla Public License 2.0.