Docker and Deployment Tutorial Intermediate

How to Back Up Docker Volumes Automatically

A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.

AI-assisted draft

The first draft of this article was generated with AI, then fact-checked, edited and verified by a human editor before publication.

A nightly systemd timer copying Docker volume snapshots to offsite storage

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Docker
27.x
Restic
0.17
Postgresql
17
Systemd
255

Before you start

  • A Docker host with named volumes you care about
  • An S3-compatible bucket or another offsite target
  • sudo access to create systemd units
On this page

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

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

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

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

bash
sudo apt install -y restic
/etc/backup/restic.env
RESTIC_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=K00xxxxxxxxxxxxxxxxxxxxxxxxxxxx
bash
sudo 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 init

One script for the whole job

/usr/local/sbin/docker-backup.sh
#!/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%
bash
sudo chmod 700 /usr/local/sbin/docker-backup.sh

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

/etc/systemd/system/docker-backup.service
[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
/etc/systemd/system/docker-backup.timer
[Unit]
Description=Nightly Docker backup

[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
bash
sudo 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-pager
Expected output
bash
Files:        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:

bash
systemctl list-timers docker-backup.timer
Expected output
bash
NEXT                        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.timer

Test the restore

This is the step that turns a script into a backup.

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

bash
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;'
Expected output
bash
 count
-------
    27
(1 row)
bash
docker rm -f pg-restore-test
rm -rf /tmp/restore-test

Put 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 TTYdocker 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:

bash
[ -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 .gitignore files for good reason.
  • Application encryption keys. n8n’s N8N_ENCRYPTION_KEY is 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.

Frequently asked questions

Can I just tar the Docker volume directory while the container runs?

For static files, yes. For a database, no. A tar of a live PostgreSQL or MySQL data directory captures pages mid-write and usually restores into a corrupt cluster. Use `pg_dump` or `mysqldump`, or stop the container first.

Where does Docker actually store named volumes?

Under `/var/lib/docker/volumes/<name>/_data` on Linux. You can back that path up directly, but running a helper container that mounts the volume is more portable and does not depend on Docker's internal layout.

How often should backups run?

Work backwards from how much data you can afford to lose. Nightly means up to 24 hours of loss. If that is too much, run database dumps every few hours — they are cheap — and keep the file-volume snapshot nightly.

Do I need restic, or is rsync enough?

rsync gives you a mirror, which means a deletion or an encryption event propagates to your only copy on the next run. restic keeps immutable, deduplicated, encrypted snapshots with retention, which is what you want for a backup as opposed to a mirror.

How do I know the backup ran last night?

Check `systemctl list-timers` for the last trigger and `journalctl -u docker-backup.service --since yesterday` for the exit status. Better, have the script emit a heartbeat to an external monitor so a host that is off does not silently stop reporting.

Sources

  1. Back up, restore, or migrate data volumes Docker Primary source Accessed 6 Jul 2026
  2. restic documentation restic project Primary source Accessed 6 Jul 2026
  3. pg_dump PostgreSQL Global Development Group Primary source Accessed 6 Jul 2026
  4. systemd.timer systemd Primary source Accessed 6 Jul 2026
4 min read

Part of a learning path

  • n8n intermediate

    How to Deploy n8n on an Ubuntu VPS with Docker

    A production n8n install on Ubuntu 24.04 using Docker Compose, PostgreSQL and Caddy for automatic TLS — with the encryption key, webhook URL and backup steps most quickstarts leave out.

    5 min
  • Data intermediate

    How to Run Meilisearch in Production with Docker

    A Meilisearch deployment that survives a real workload — master key handling, scoped API keys, the index settings you must set before loading data, snapshots, and what its memory usage actually means.

    4 min AI-assisted
  • Security intermediate

    How to Secure a VPS That Runs Docker Containers

    SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

    4 min