Containers are disposable, but the data in their volumes is not. A homelab that runs a few services on Docker Compose needs one boring thing before anything else: a backup you have actually restored at least once.

This guide backs up named volumes with a throwaway container, automates it with a small script, and ends with the restore test.

Find what to back up

Compose prefixes volume names with the project name, so list them first:

docker volume ls

A project called myapp with a data volume shows up as myapp_data. Those names are what the commands below use.

Back up one volume

Mount the volume read-only in a temporary Alpine container, mount a folder from the host next to it, and archive the contents:

docker run --rm \
  -v myapp_data:/data:ro \
  -v "$PWD":/backup \
  alpine tar czf "/backup/myapp_data-$(date +%F).tar.gz" -C /data .

The :ro flag guarantees the backup cannot modify the data, and --rm removes the container as soon as the archive is written. The result is a dated .tar.gz file in the current directory.

Databases need a dump

Copying the files of a running database can produce a backup that does not start. Ask the database to export itself instead. With a PostgreSQL service called db in your Compose file:

docker compose exec -T db pg_dump -U postgres appdb | gzip > "appdb-$(date +%F).sql.gz"

The -T flag disables the pseudo-terminal so the output pipes cleanly. Other databases have equivalents, such as mysqldump for MariaDB and MySQL.

For everything that is not a database (configuration, uploads, media metadata), the volume archive above is enough. If a service writes constantly and has no dump command, stop it for the few seconds the backup takes with docker compose stop.

Automate it

Put the loop in a script, for example /usr/local/bin/backup-volumes.sh:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR=/srv/backups/docker
VOLUMES=(myapp_data myapp_config)
STAMP=$(date +%F)

mkdir -p "$BACKUP_DIR"

for volume in "${VOLUMES[@]}"; do
  docker run --rm \
    -v "$volume":/data:ro \
    -v "$BACKUP_DIR":/backup \
    alpine tar czf "/backup/${volume}-${STAMP}.tar.gz" -C /data .
done

# Keep seven days of archives.
find "$BACKUP_DIR" -name '*.tar.gz' -mtime +7 -delete

Make it executable with chmod +x /usr/local/bin/backup-volumes.sh, then schedule it with crontab -e:

0 3 * * * /usr/local/bin/backup-volumes.sh >> /var/log/backup-volumes.log 2>&1

It now runs every night at 03:00, and the log tells you when something fails.

Restore, and test it

A restore is the same trick in reverse. Create an empty volume and extract the archive into it:

docker volume create myapp_data_restored

docker run --rm \
  -v myapp_data_restored:/data \
  -v /srv/backups/docker:/backup:ro \
  alpine tar xzf /backup/myapp_data-2026-10-03.tar.gz -C /data

Restore into a new volume first and point a test copy of the service at it. If it starts and the data is there, the backup is good. Do this once now and again after any change to the setup, because an untested backup is only a hope.

Keep a copy elsewhere

Backups on the same disk as the data protect you from mistakes, not from a dead drive. Copy the backup folder to a second machine or an external disk, for example with rsync -a /srv/backups/docker/ user@other-host:/srv/backups/docker/, so one failure cannot take out both.