Docker volumes fall into two categories and they need different treatment.
Databases must be dumped by their own tooling, because copying a data directory while the engine is writing to it produces a file that restores into a corrupt cluster. Everything else — uploaded files, configuration, certificates — can be snapshotted as-is.
This sets up both, on one nightly systemd timer, with offsite encrypted storage.
Requirements
- A Docker host with named volumes. Check what you have:
docker volume ls- An S3-compatible bucket. Backblaze B2, Cloudflare R2, Wasabi and AWS S3 all work with restic. Use a bucket in a different provider or region from the server.
- Enough free disk for one database dump. Dumps are written locally before they are uploaded.
Dump databases with their own tools
PostgreSQL, running as a Compose service:
docker compose exec -T postgres \
pg_dump -U n8n -Fc n8n > /var/backups/n8n-$(date +%F).dump-T disables TTY allocation, which is required when the command runs from cron
or systemd rather than an interactive shell. Without it you get
the input device is not a TTY and an empty file.
-Fc writes PostgreSQL’s custom format. It compresses, and pg_restore can
extract individual tables from it — worth the flag when you need one table back
and not the whole database.
MySQL or MariaDB:
docker compose exec -T mariadb \
mariadb-dump --single-transaction --quick -u root -p"$MYSQL_ROOT_PASSWORD" app \
> /var/backups/app-$(date +%F).sql--single-transaction takes a consistent snapshot without locking every table,
on InnoDB.
Snapshot file volumes
For volumes holding ordinary files, mount the volume into a throwaway container and copy it out:
docker run --rm \
-v n8n_data:/data:ro \
-v /var/backups/staging:/backup \
alpine:3.20 \
tar czf /backup/n8n_data.tar.gz -C /data .Mounting :ro guarantees the helper cannot modify the volume it is reading.
That gives you a local copy. Getting it offsite, deduplicated and encrypted is restic’s job.
sudo apt install -y resticRESTIC_REPOSITORY=s3:s3.eu-central-003.backblazeb2.com/my-backup-bucket/host-1
RESTIC_PASSWORD_FILE=/etc/backup/restic-password
AWS_ACCESS_KEY_ID=00xxxxxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=K00xxxxxxxxxxxxxxxxxxxxxxxxxxxxsudo install -d -m 0700 /etc/backup
openssl rand -base64 48 | sudo tee /etc/backup/restic-password > /dev/null
sudo chmod 600 /etc/backup/restic-password /etc/backup/restic.env
set -a; . /etc/backup/restic.env; set +a
restic initOne script for the whole job
#!/usr/bin/env bash
set -euo pipefail
STACK=/opt/n8n
STAGING=/var/backups/staging
VOLUMES=(n8n_data caddy_data)
mkdir -p "$STAGING"
trap 'rm -rf "${STAGING:?}"/*' EXIT
# 1. Database dump
docker compose -f "$STACK/compose.yaml" exec -T postgres \
pg_dump -U n8n -Fc n8n > "$STAGING/n8n.dump"
# 2. File volumes
for vol in "${VOLUMES[@]}"; do
docker run --rm \
-v "$vol":/data:ro \
-v "$STAGING":/backup \
alpine:3.20 tar czf "/backup/$vol.tar.gz" -C /data .
done
# 3. Ship it
restic backup --tag nightly --host "$(hostname -s)" "$STAGING"
# 4. Retention
restic forget --tag nightly --prune \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6
# 5. Integrity, on a subset so it stays fast
restic check --read-data-subset=5%sudo chmod 700 /usr/local/sbin/docker-backup.shset -euo pipefail is what makes this safe to automate. Without -e a failed
pg_dump still uploads an empty file and the job reports success.
The trap clears staging on exit, including on failure, so a partial dump does
not sit on disk until it fills it.
Run it on a systemd timer
Timers beat cron here: the exit status lands in the journal, Persistent=true
catches up a run the machine slept through, and RandomizedDelaySec stops every
host in a fleet hitting the bucket at the same second.
[Unit]
Description=Back up Docker volumes and databases
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
EnvironmentFile=/etc/backup/restic.env
ExecStart=/usr/local/sbin/docker-backup.sh
Nice=10
IOSchedulingClass=idle[Unit]
Description=Nightly Docker backup
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now docker-backup.timer
sudo systemctl start docker-backup.service # run once now
journalctl -u docker-backup.service -n 30 --no-pagerFiles: 3 new, 0 changed, 0 unmodified
Added to the repository: 214.882 MiB (198.104 MiB stored)
processed 3 files, 512.331 MiB in 0:24
snapshot 8a1c4f22 saved
docker-backup.service: Deactivated successfully.Confirm the schedule took:
systemctl list-timers docker-backup.timerNEXT LEFT LAST PASSED UNIT
Tue 2026-07-07 03:22:41 UTC 9h left Mon 2026-07-06 03:19:07 UTC 14h ago docker-backup.timerTest the restore
This is the step that turns a script into a backup.
restic snapshots --tag nightly
restic restore latest --target /tmp/restore-test
ls -la /tmp/restore-test/var/backups/staging/Then load the dump into a scratch database and query it:
docker run -d --name pg-restore-test -e POSTGRES_PASSWORD=test postgres:17-alpine
docker cp /tmp/restore-test/var/backups/staging/n8n.dump pg-restore-test:/tmp/n8n.dump
docker exec -it pg-restore-test createdb -U postgres restoretest
docker exec -it pg-restore-test pg_restore -U postgres -d restoretest /tmp/n8n.dump
docker exec -it pg-restore-test psql -U postgres -d restoretest \
-c 'SELECT count(*) FROM workflow_entity;' count
-------
27
(1 row)docker rm -f pg-restore-test
rm -rf /tmp/restore-testPut this in a calendar reminder for once a month. The failure mode it catches — backups that have been running green for a year and cannot be restored — is common and entirely preventable.
Common errors
the input device is not a TTY — docker compose exec without -T under
systemd or cron. Add -T.
The dump file is 0 bytes and the job reported success. The shell redirect
created the file before the command failed. set -euo pipefail plus a size
check after the dump prevents it:
[ -s "$STAGING/n8n.dump" ] || { echo "empty dump"; exit 1; }Fatal: unable to open config file: Stat: The AWS Access Key Id you provided does not exist — restic found the repository URL but not valid credentials.
Under systemd, credentials must come from EnvironmentFile; your interactive
shell’s environment is not inherited.
repository is already locked exclusively — a previous run was killed
mid-operation. Confirm nothing is running, then restic unlock.
The timer exists but never fires. It was created but not enabled.
systemctl enable --now docker-backup.timer, and check
systemctl list-timers shows a NEXT value.
Backups grow without bound. restic forget was run without --prune, so
snapshots were removed from the index but the data was never deleted.
What a complete backup includes
A database dump alone will not rebuild your host. Go through this list once and add whatever is missing to the staging directory:
- The Compose files and Caddyfile or nginx config. Keep them in git as well, but a backup that can be restored without network access is worth having.
- Environment files. They hold the credentials the dump does not, and they
are excluded by most
.gitignorefiles for good reason. - Application encryption keys. n8n’s
N8N_ENCRYPTION_KEYis the obvious case: it decrypts every stored credential and it is not in the database dump. A restore without it gives you workflows that cannot authenticate to anything. - TLS certificates and ACME account data, if you are not using a proxy that re-issues automatically.
- Cron entries and systemd units you wrote by hand, including this one.
The test for completeness is specific: could you rebuild this host on a new server, from this backup plus a fresh OS install, without asking anyone a question? If the answer needs a “well, I’d also need to…”, that thing belongs in the backup.
Encryption keys and passwords are the exception. Those go in a password manager, not in the archive they protect — an encrypted backup whose key is inside it is not encrypted.
Retention, encryption and offsite
The retention above keeps 7 daily, 4 weekly and 6 monthly snapshots. Set it from your own recovery requirements rather than copying it — the question is how far back you would need to go to find a copy from before a problem started, and for silent data corruption that can be weeks.
Keep at least one copy in a different provider from the server. A backup in the same account as the thing it backs up shares a failure mode with it: a compromised or suspended account takes both.
Use append-only credentials where the provider supports them, so a compromised host cannot delete its own history. Backblaze B2 application keys and S3 bucket policies both do this, and it is the single strongest control against ransomware reaching your backups.
Finally, monitor the outcome, not the schedule. Have the script report success to an external heartbeat service; a host that is powered off never fails a backup, it just stops taking them.