Docker 📖 11 min read

Docker Volumes and Backups

Containers are meant to be thrown away and recreated, which is exactly why their data needs a plan. This guide shows where Docker keeps data, why copying a running database can produce a backup that won't restore, and how to make consistent dumps and volume archives. It ends by restoring into a throwaway project, because a backup you haven't restored is only a guess.

Jump to: volumes or bind mounts, database dumps, restoring or scheduling backups.

Before you start

You need a working Docker Engine and Compose plugin; the Docker Installation Guide covers both. The examples back up the Miniflux and PostgreSQL stack from the Docker Compose guide, but the commands work for any project. Replace miniflux with your project name and db-data with your volume name.

One rule shapes everything that follows: a backup you haven't restored is a guess. The last section restores into a throwaway project so you can prove the files are usable before you need them.

docker volume ls                          # every volume on the host
docker compose -p miniflux ps             # what the project is running now

Where container data actually lives

A container's own filesystem is a writable layer on top of its image. It disappears when the container is removed, and Compose removes and recreates containers whenever you change their settings or pull a newer image. Anything you want to keep has to live outside that layer, in one of two places:

  • Named volumes are created and managed by Docker. On a default Linux install their data sits under /var/lib/docker/volumes/name/_data, owned by root.
  • Bind mounts map a host directory you choose, such as ./data, into the container.

To see which a running container uses:

docker inspect --format '{{range .Mounts}}{{.Type}}  {{.Name}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' miniflux-db-1

Any path the image declares as a VOLUME but that you didn't mount gets an anonymous volume with a random 64-character name. Those are easy to lose: docker compose down -v and docker volume prune both remove them. If docker inspect shows a volume with a long hexadecimal name, give that path a named volume in your Compose file.

Volumes or bind mounts

PropertyNamed volumeBind mount
LocationDocker chooses, under /var/lib/dockerYou choose
First start with an empty mountDocker copies the image's files at that path into the volumeThe host directory hides whatever the image had there
File ownershipDocker sets it up from the imageWhatever is on the host; often a permissions mismatch
Backing upNeeds a helper container or root accessAny backup tool that can read the directory
Good forDatabases and app state you don't edit by handConfig files you edit, media libraries, files shared with the host

Both are fine. What matters is that you know which one each service uses and that both are in your backup plan.

Why copying a live database is risky

A database writes to several files at once and keeps recent changes in memory and in its write-ahead log. Copying its data directory while it's running can capture those files at different moments, producing a backup that won't start, or one that starts with silently missing rows. There are two safe approaches:

  1. A logical dump made by the database's own tool while it runs, such as pg_dump or mariadb-dump. It's consistent, portable across versions and usually small. Use this for databases.
  2. A file copy with the service stopped. Brief downtime, but it works for anything, including apps that keep state in SQLite or plain files.

Dump a database from its container

Run the dump tool inside the database container with docker compose exec, and send its output to a file on the host. -T turns off the pseudo-terminal so the binary output isn't mangled.

PostgreSQL
mkdir -p ~/backups/miniflux
cd ~/stacks/miniflux
docker compose exec -T db pg_dump -U miniflux -d miniflux --format=custom \
  > ~/backups/miniflux/miniflux-$(date +%F).dump
MariaDB or MySQL
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -uroot -p"$MARIADB_ROOT_PASSWORD" --all-databases' \
  > ~/backups/app/app-$(date +%F).sql

For PostgreSQL, --format=custom produces a compressed archive that pg_restore can restore selectively; the pg_dump documentation describes the other formats. For MariaDB, --single-transaction takes a consistent snapshot of InnoDB tables without locking them. The single quotes make the shell inside the container expand $MARIADB_ROOT_PASSWORD from its own environment, so the password never appears in your shell history.

Check that the file isn't empty and that it can be read:

ls -lh ~/backups/miniflux/
docker compose exec -T db pg_restore --list < ~/backups/miniflux/miniflux-$(date +%F).dump | head

A dump of a few hundred bytes usually means the command failed and wrote an error message into the file. Open it with head to see.

Archive a volume with a helper container

Named volumes are owned by root inside /var/lib/docker. Instead of reading that directory directly, start a short-lived container that mounts the volume read-only alongside a host directory, and let it write a tar archive:

cd ~/stacks/miniflux
docker compose stop db

docker run --rm \
  -v miniflux_db-data:/source:ro \
  -v ~/backups/miniflux:/backup \
  alpine:3 \
  tar -czf /backup/db-data-$(date +%F).tar.gz -C /source .

docker compose start db

Note the volume name. Compose prefixes volumes with the project name, so db-data in the file is miniflux_db-data to Docker. docker volume ls shows the real names. The $(date +%F) is expanded by your shell before the container starts, which is what you want.

The archive keeps numeric file ownership and permissions, so a restore puts the files back exactly as the database expects them. List the contents to check the archive:

tar -tzvf ~/backups/miniflux/db-data-$(date +%F).tar.gz | head

For a bind mount, you don't need the helper container. Stop the service and archive the host directory directly:

docker compose stop app
sudo tar -czf ~/backups/app/data-$(date +%F).tar.gz -C ~/stacks/app data
docker compose start app

What else belongs in the backup

Data on its own isn't enough to rebuild a service. Back up the project directory as well:

  • compose.yaml and any override files, so you know exactly which image versions and settings produced the data.
  • .env, which holds the passwords the data was created with. Store this copy encrypted or in a password manager, because it contains secrets.
  • Any config files bind-mounted into containers.

You don't need to back up images. They can be pulled again, as long as the tags in compose.yaml are specific enough to get the same versions back.

Restore and test

Practise restoring into a separate project, so a mistake can't touch the live data. Copy the project directory, give the copy its own project name and port, and restore into it.

cp -r ~/stacks/miniflux ~/stacks/miniflux-restore-test
cd ~/stacks/miniflux-restore-test
sed -i 's/^name: miniflux$/name: miniflux-restore-test/' compose.yaml
sed -i 's/127.0.0.1:8080:8080/127.0.0.1:8090:8080/' compose.yaml
docker compose config | grep -E '^name:|published'

From a PostgreSQL dump

Start only the database so it creates an empty one, then load the dump into it:

docker compose up -d db
docker compose exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists --no-owner \
  < ~/backups/miniflux/miniflux-2026-10-19.dump
docker compose up -d

From a volume archive

Create the empty volume, unpack the archive into it, then start the stack:

docker volume create miniflux-restore-test_db-data
docker run --rm \
  -v miniflux-restore-test_db-data:/target \
  -v ~/backups/miniflux:/backup:ro \
  alpine:3 \
  sh -c 'cd /target && tar -xzf /backup/db-data-2026-10-19.tar.gz'
docker compose up -d

Then check that the restored copy actually works, not just that it started:

docker compose ps
docker compose exec db psql -U miniflux -d miniflux -c 'SELECT count(*) FROM feeds;'
curl -sI http://127.0.0.1:8090/healthcheck

Log in on port 8090 and confirm your feeds are there. Then remove the test project and its volume:

docker compose down -v
cd ~ && rm -rf ~/stacks/miniflux-restore-test

A dump also restores into a newer PostgreSQL version, which is how you move between major versions: dump from the old version, change the image tag, start on an empty volume and restore. A volume archive only works with the same major version that created it.

Scheduling backups

Put the commands in a script so every run is the same:

/usr/local/bin/backup-miniflux.sh
#!/usr/bin/env bash
set -euo pipefail

project_dir=/home/deploy/stacks/miniflux
dest=/home/deploy/backups/miniflux
keep_days=14
stamp=$(date +%F-%H%M)

mkdir -p "$dest"
cd "$project_dir"

docker compose exec -T db pg_dump -U miniflux -d miniflux --format=custom > "$dest/miniflux-$stamp.dump.partial"
mv "$dest/miniflux-$stamp.dump.partial" "$dest/miniflux-$stamp.dump"
tar -czf "$dest/project-$stamp.tar.gz" -C "$project_dir" compose.yaml .env

find "$dest" -name 'miniflux-*.dump' -mtime +"$keep_days" -delete
find "$dest" -name 'project-*.tar.gz' -mtime +"$keep_days" -delete

set -euo pipefail stops the script at the first failure, including a failed pg_dump inside the pipeline. Writing to a .partial file and renaming it afterwards means a failed run never leaves a half-written file that looks like a good backup. Replace the paths with your own and make the script executable with sudo chmod 750.

Run it daily with a systemd timer, which logs every run to the journal. The systemd services guide explains the unit files in detail.

/etc/systemd/system/backup-miniflux.service
[Unit]
Description=Back up the Miniflux database
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
User=deploy
ExecStart=/usr/local/bin/backup-miniflux.sh
/etc/systemd/system/backup-miniflux.timer
[Unit]
Description=Daily Miniflux backup

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

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup-miniflux.timer
sudo systemctl start backup-miniflux.service      # run once now to test it
journalctl -u backup-miniflux.service -n 20

The deploy user must be in the docker group for docker compose exec to work. Persistent=true runs a missed backup at the next boot if the machine was off at 03:15.

How many backups to keep

Keeping only the latest backup protects you from a dead disk, but not from a mistake you notice a week later. If a bad migration or a deleted feed went unnoticed for three days, last night's backup has the problem in it too. A simple schedule covers both cases:

CopiesCovers
One per day for 7 to 14 daysRecent mistakes and failed upgrades
One per week for 4 to 8 weeksProblems noticed late
One per month for 6 to 12 monthsSlow corruption and old data you deleted on purpose and later wanted back

The script above keeps 14 days of daily copies, which is a sensible start for a small service. Tools such as restic express the full schedule directly, for example with restic forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune. Whatever you keep, watch the size: du -sh ~/backups/* once a month catches a backup that has suddenly grown, or shrunk to almost nothing because a dump started failing.

Get a copy off the machine

Backups on the same disk as the data protect against mistakes, not against a failed disk, a stolen machine or ransomware. Keep at least one copy somewhere else: another machine on your network, a NAS, or object storage. A deduplicating tool such as restic encrypts the backups before they leave the host and stores repeated runs efficiently. Whatever you use, repeat the restore test from the off-site copy every few months.

Troubleshooting

The volume is empty after docker compose up in a new directory

The project name changed, so Compose created a new volume instead of using the old one. docker volume ls will show both. Set name: at the top of compose.yaml to the original project name, or move the data across with the helper-container method.

pg_restore: error: input file appears to be a text format dump

The dump was made without --format=custom, so it's plain SQL. Restore it with psql instead: docker compose exec -T db psql -U miniflux -d miniflux < file.sql.

The restored database refuses to start: database files are incompatible with server

You restored a volume archive into a different PostgreSQL major version. Use the image tag the archive was made with, or restore from a logical dump instead.

Permission errors on a bind mount after a restore

Unpack archives with sudo tar -xzf so numeric owners are kept, and check the UID the container runs as with docker compose exec app id.

Next steps