Docker volumes hold the state your containers cannot rebuild: database files, uploaded assets, issued certificates. An image backup does not capture them, and a plain tar copy leaves unencrypted data on whatever target you push it to. Restic covers both gaps with a single static binary: deduplicated, client-side encrypted snapshots to local disk, SFTP or S3.
This tutorial sets up an encrypted restic repository on S3-compatible storage, backs up a named Docker volume, automates the run with a systemd timer, and restores the volume from a snapshot.
Why isn't a disk image enough for Docker volumes?
A disk image captures the whole block device at one point in time, so it restores an entire host but cannot restore a single Docker volume, and it gives you no per-file history inside the /var/lib/docker/volumes tree.
Both backup layers answer different questions. Image backups answer "the host is gone, how do I get it back". File-level backups answer "someone dropped a table three days ago, where is yesterday's pgdata".
| Property | Disk image | restic file backup |
|---|---|---|
| Restores a full host | Yes | No |
| Restores a single volume | No | Yes |
| Per-file granularity | No | Yes |
| Deduplication across snapshots | Limited | Yes |
| Client-side encryption | Depends on target | Always |
Prerequisites
- A Linux host with Docker 20.10 or newer and at least one named volume
restic0.16 or newer (restic versionto check)- Root access, because volume data under
/var/lib/docker/volumesis root-owned - An S3-compatible bucket plus access key and secret key
- Optionally, automated disk-image backups for the host itself, so the file-level snapshots described here stay a complement rather than your only copy
Install restic from the distribution packages:
# apt update && apt install -y restic
# restic version
restic 0.16.4 compiled with go1.22.2 on linux/amd64If the packaged version is older than 0.16, install the official static binary from the upstream release page instead. Version matters for two flags used later: --read-data-subset accepts percentage values from 0.12 onward, and --skip-if-unchanged requires 0.17.
What is a restic repository?
A restic repository is a directory tree of encrypted, content-addressed pack files in which every data blob is encrypted with AES-256 and authenticated with Poly1305-AES on the client before it is uploaded to the storage backend.
The practical consequence: the storage provider never sees file names, sizes or contents. The repository password is the only key. Lose it and the data is unrecoverable, so store it in a password manager before the first backup, not after.
Restic also splits files into variable-sized chunks using content-defined chunking. A 40 GB volume that changes by 200 MB per day produces snapshots that cost roughly 200 MB of new storage, not 40 GB.
Initialize the repository
Create the password and environment files first. Keep both in a root-only directory:
# mkdir -p /root/.restic && chmod 700 /root/.restic
# openssl rand -base64 32 > /root/.restic/password
# chmod 600 /root/.restic/passwordWrite the connection settings to /root/.restic/env. Use plain KEY=VALUE lines without export, because systemd's EnvironmentFile= does not parse shell syntax:
RESTIC_REPOSITORY=s3:https://<s3-endpoint>/<bucket>/docker-volumes
RESTIC_PASSWORD_FILE=/root/.restic/password
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEYAny endpoint that speaks the S3 API works here, including S3-compatible object storage, which keeps the repository off the machine it protects. Restrict the file and initialize the repository once:
# chmod 600 /root/.restic/env
# set -a; . /root/.restic/env; set +a
# restic init
created restic repository 4a7c1f9b2e at s3:https://<s3-endpoint>/<bucket>/docker-volumesThe set -a wrapper exports every variable the file defines for the current shell. Run it in every interactive session in which you call restic manually.
Matching infrastructure at centron
Backups do not belong on the same machine: cBacks stores disk images off-host on a schedule. Explore cBacks →
Back up a Docker volume
Resolve the volume's path on disk. Docker stores named volumes under /var/lib/docker/volumes/<name>/_data, and docker volume inspect confirms it:
# docker volume inspect --format '{{ .Mountpoint }}' pgdata
/var/lib/docker/volumes/pgdata/_dataThe flow depends on what the volume contains. Static files can be read while the container runs; a database engine writing to the volume needs either a stopped container or a logical dump.
graph TD
A["Volume to back up"] --> B{"Written by a database?"}
B -->|"No"| C["docker compose stop service"]
B -->|"Yes"| D["docker exec pg_dumpall"]
C --> E["restic backup /var/lib/docker/volumes/pgdata/_data"]
D --> F["restic backup --stdin"]
E --> G["Encrypted repository on S3"]
F --> G
G --> H["restic forget --prune"]
H --> I["restic check --read-data-subset=5%"]
For a file volume, stop the writer, snapshot, start it again:
# docker compose -f /srv/app/docker-compose.yml stop app
# restic backup /var/lib/docker/volumes/appdata/_data --tag docker-volume --tag appdata
# docker compose -f /srv/app/docker-compose.yml start appThe first run reads everything. Subsequent runs use the parent snapshot and only upload changed chunks:
Files: 3 new, 12 changed, 8241 unmodified
Added to the repository: 41.204 MiB (39.882 MiB stored)
processed 8256 files, 4.112 GiB in 0:07
snapshot 9c2b41ea savedTags are what make the repository manageable later. Use one tag for the class of data (docker-volume) and one for the specific volume, so restic forget can apply separate retention policies per volume.
Consistent backups for databases
Copying a live PostgreSQL or MySQL data directory produces a snapshot that may or may not replay cleanly. Pipe a logical dump into restic instead, which never touches disk on the host:
# docker exec -t postgres pg_dumpall -U postgres | restic backup --stdin --stdin-filename pgdump.sql --tag pgdumpRestic deduplicates the stream just like a file, so daily dumps of a slowly changing database stay cheap. Keep the dump and the volume snapshot as separate tags: the dump restores the data, the volume snapshot restores configuration files that live next to it.
Automate the backup with a systemd timer
Write /etc/systemd/system/restic-docker.service. Type=oneshot runs the ExecStart lines in order and reports failure if any of them exits non-zero:
[Unit]
Description=Restic backup of Docker volumes
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
EnvironmentFile=/root/.restic/env
ExecStart=/usr/bin/restic backup /var/lib/docker/volumes/appdata/_data --tag docker-volume --tag appdata
ExecStart=/usr/bin/restic forget --tag appdata --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneAdd the matching timer in /etc/systemd/system/restic-docker.timer:
[Unit]
Description=Daily restic backup of Docker volumes
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.targetPersistent=true catches up a missed run after the host was off at 02:30. Enable and test:
# systemctl daemon-reload
# systemctl enable --now restic-docker.timer
# systemctl start restic-docker.service
# journalctl -u restic-docker.service -n 20 --no-pagerHow long should restic snapshots be kept?
Retention in restic is defined by policy flags on restic forget, for example --keep-daily 7 --keep-weekly 4 --keep-monthly 6, which keeps 17 snapshots covering roughly six months and marks everything else for removal.
forget only removes snapshot references. The data stays in the repository until prune repacks the affected pack files:
# restic forget --tag appdata --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --dry-run
# restic forget --tag appdata --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneRun the --dry-run variant the first time and read the remove column. Always scope the policy with --tag or --path. An unscoped forget groups snapshots by host and path by default, and a policy meant for one volume then silently applies to all of them.
Restore a volume from a snapshot
List the available snapshots first:
# restic snapshots --tag appdata
ID Time Host Tags Paths
9c2b41ea 2026-08-17 02:31:04 host01 docker-volume,appdata /var/lib/docker/volumes/appdata/_data
4f81d0c7 2026-08-18 02:34:11 host01 docker-volume,appdata /var/lib/docker/volumes/appdata/_dataRestore into a staging directory rather than over the live volume. Restic recreates the full absolute path below --target:
# restic restore 9c2b41ea --target /var/tmp/restore
# ls /var/tmp/restore/var/lib/docker/volumes/appdata/_dataThen move the data into a fresh volume and point the container at it:
# docker compose -f /srv/app/docker-compose.yml stop app
# docker volume create appdata_restored
# cp -a /var/tmp/restore/var/lib/docker/volumes/appdata/_data/. /var/lib/docker/volumes/appdata_restored/_data/Update the volume name in the compose file, start the service, and verify the application before deleting the old volume. Restoring a database dump follows the same pattern with restic dump:
# restic dump latest /pgdump.sql | docker exec -i postgres psql -U postgresVerify the repository
A backup that has never been read back is an assumption. Restic has two verification levels:
# restic check
# restic check --read-data-subset=5%Plain check validates the repository structure, index and snapshot metadata without downloading pack files. --read-data-subset=5% downloads and re-hashes a random five percent of the actual data, which detects silent corruption on the storage backend. Running the subset check weekly covers the full repository over five months at a fraction of the transfer cost of --read-data.
Expected output on a healthy repository:
using temporary cache in /tmp/restic-check-cache-1841
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
read 5.0% of data packs
no errors were foundTroubleshooting
repository is already locked exclusively — a previous run was killed and left a lock behind. Confirm no restic process is running, then clear it with restic unlock. Stale locks older than 30 minutes are removed by restic unlock without further flags.
wrong password or no key found — RESTIC_PASSWORD_FILE points at the wrong file, or the file gained a trailing newline from an editor. Verify with restic cat config, which prints the repository config only when the key decrypts.
RequestError or BucketRegionError against S3 — the endpoint URL or region is wrong. Restic expects s3:https://<endpoint>/<bucket>/<path> with no trailing slash, the bucket must already exist, and some providers require AWS_DEFAULT_REGION to be set explicitly in the environment file.
Wrap-up
You now have encrypted, deduplicated snapshots of your Docker volumes on S3-compatible storage, a daily systemd timer that backs up and prunes in one run, and a verified restore path.
Two habits keep this reliable: schedule restic check --read-data-subset=5% as its own weekly timer, and restore a snapshot into a throwaway volume once per quarter. The retention policy is worth revisiting as the volume grows, since prune cost scales with repository size, not with the number of snapshots.
More on Docker
- Run Ollama in a Docker Container
- Run Nextcloud with Docker Compose
- Shrink Docker Images with Multi-Stage Builds
Testen Sie Ihr Setup auf ccloud³
Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.