VPS Snaps

How to back up and restore Docker volumes

Mount the volume read-only into a throwaway container and have it write a tar archive to the host: docker run --rm -v myvol:/data:ro -v "$(pwd)":/backup alpine tar czf /backup/myvol.tar.gz -C /data .. To restore, extract the archive into a new volume the same way. If the volume holds a database, stop its container first or dump the database instead, because files copied mid-write may not start again.

9 min readUpdated Checked against official documentation

Named volumes, bind mounts and anonymous volumes

Docker gives containers storage in three ways, and each one is backed up differently:

TypeWhere the data livesHow to back it up
Named volume: -v shop_uploads:/uploadsA directory Docker manages, by default /var/lib/docker/volumes/<name>/_data on LinuxWith a helper container, as shown below
Bind mount: -v /srv/shop/uploads:/uploadsA host directory you choseLike any other directory, with tar or rsync on the host
Anonymous volume: -v /uploads with no nameA volume named with a random IDGive it a name first. docker run --rm deletes a container's anonymous volumes when it exits.

Docker's documentation describes volumes as completely managed by Docker. Read and write them through a container, as below, rather than through the host path, which depends on how Docker was installed.

Find the volumes a container uses

List the volumes on the host. Add -q to print names only, which is handy in scripts:

Terminal
docker volume ls

Compose puts the project name in front of every volume it creates. The project name defaults to the name of the directory holding the Compose file, so a volume declared as uploads in /srv/shop is called shop_uploads.

To see what one container mounts, print the Mounts section of docker inspect as JSON:

Terminal
docker inspect --format '{{json .Mounts}}' shop-web-1

Each entry has a Type (volume or bind), a Name for volumes, the host Source and the container Destination. To go the other way, --filter volume= lists the containers that mount a volume, and -a includes stopped ones:

Terminal
docker ps -a --filter volume=shop_uploads

docker volume inspect shop_uploads prints the volume's driver, labels and Mountpoint, the host directory that holds its files.

Keep the copy consistent

The helper container reads files while your application may still be writing them. For uploads, configuration and other files that are written once, that is fine. For anything that changes files in place, such as a database, pick one of these:

MethodDowntimeWhat you get
Stop the container: docker stop shop-db-1Until the copy finishesA clean copy. Docker sends SIGTERM, then SIGKILL after 10 seconds; give a slow database longer with -t 60.
Pause it: docker pause shop-db-1, later docker unpause shop-db-1Until the copy finishes; requests hang instead of failingFiles that do not change during the copy, but nothing the process still held in memory. A database sees it like a power cut.
Dump the database insteadNoneA consistent, portable file. See how to back up a database in Docker Compose.

For a Compose project, docker compose stop stops every service and docker compose start starts them again, with containers and volumes left as they were.

Managing containers with Portainer? Its own settings live in a volume too: see how to back up Portainer.

Back up a named volume

Terminal
docker run --rm -v shop_uploads:/data:ro -v "$(pwd)":/backup alpine tar czf /backup/shop_uploads-$(date +%F).tar.gz -C /data .
  • --rm deletes the helper container when tar finishes.
  • -v shop_uploads:/data:ro mounts the volume at /data. :ro makes it read-only, so the backup cannot change it.
  • -v "$(pwd)":/backup bind-mounts the current directory at /backup, so the archive lands on the host.
  • alpine is a small image whose BusyBox tar keeps ordinary files, permissions and owners.
  • tar czf FILE creates (c) a gzip-compressed (z) archive in FILE (f). $(date +%F) adds today's date, such as 2026-10-03.
  • -C /data . changes into the volume and archives its contents, so paths in the archive are relative and extract cleanly anywhere.

If you mistype the volume name, Docker does not complain. It creates a new, empty volume with that name, and you archive nothing. Check names with docker volume ls and remove a stray volume with docker volume rm.

List the archive on the host to confirm it holds your files:

Terminal
tar -tzvf shop_uploads-2026-10-03.tar.gz | head

Docker's own documentation shows a variant: --volumes-from mounts every volume of an existing container at the same paths, so one command archives them all. The :ro suffix mounts them read-only:

Terminal
docker run --rm --volumes-from shop-web-1:ro -v "$(pwd)":/backup alpine tar czf /backup/shop-web-1.tar.gz /var/www/uploads

tar drops the leading /, so that archive holds paths such as var/www/uploads/logo.png. Restore it with -C / in a container that mounts the volume at the same path.

Restore into a new volume

Restore into a fresh volume, check it, then switch the application over. A bad archive never touches the live data.

Terminal
docker volume create shop_uploads_restored
Terminal
docker run --rm -v shop_uploads_restored:/data -v "$(pwd)":/backup:ro alpine tar xzf /backup/shop_uploads-2026-10-03.tar.gz --numeric-owner -C /data
  • x extracts. tar runs as root in the helper container, so it restores each file's permissions and owner.
  • --numeric-owner uses the user and group IDs stored in the archive. Without it, tar first looks owner names up in the helper image's own user list, and an archive made in a different image can come back with the wrong owners.
  • :ro on /backup stops the restore from writing into your backup directory.

Count the files in the original and the restored volume and compare:

Terminal
docker run --rm -v shop_uploads_restored:/data:ro alpine sh -c 'find /data -type f | wc -l'

To run a Compose service on the restored volume, declare it external and give its real name. Compose then uses it as is: it does not create it or add the project prefix, and it stops with an error if the volume is missing.

docker-compose.yml
volumes:
  uploads:
    external: true
    name: shop_uploads_restored

To restore under the original name instead, stop the containers that use it, remove it with docker volume rm shop_uploads, and extract into a new volume with the old name.

Move a volume to another host

Pipe the archive over SSH instead of writing a file. The first container writes it to standard output. The second, started with -i so it reads standard input, extracts it into a volume of the same name on the other host, which Docker creates on first use:

Terminal
docker run --rm -v shop_uploads:/data:ro alpine tar czf - -C /data . | ssh root@new-host 'docker run --rm -i -v shop_uploads:/data alpine tar xzf - --numeric-owner -C /data'

-f - means standard output when creating and standard input when extracting. Stop the application on the old host first if its data still changes. With Compose, keep the same project name on the new host so the volume names match. Compose then prints a warning that the volume already exists but was not created by Docker Compose and uses it; declaring it external as above silences that.

The new host also needs the images the containers ran. Most can be pulled again, but some cannot: see how to back up Docker images.

Automate it with cron

This script archives a list of volumes, stops if a name is wrong, checks each archive, and deletes archives older than seven days:

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

BACKUP_DIR="/var/backups/docker-volumes"
KEEP_DAYS=7
VOLUMES="shop_uploads shop_config"
STAMP="$(date +%Y-%m-%d_%H%M)"

mkdir -p "$BACKUP_DIR"
find "$BACKUP_DIR" -name '*.partial' -type f -delete

for VOL in $VOLUMES; do
  OUT="$BACKUP_DIR/$VOL-$STAMP.tar.gz"
  docker volume inspect "$VOL" > /dev/null
  docker run --rm -v "$VOL":/data:ro -v "$BACKUP_DIR":/backup alpine \
    tar czf "/backup/$VOL-$STAMP.tar.gz.partial" -C /data .
  tar -tzf "$OUT.partial" > /dev/null
  mv "$OUT.partial" "$OUT"
done

find "$BACKUP_DIR" -name '*.tar.gz' -type f -mtime +"$KEEP_DAYS" -delete
Terminal
sudo chmod 755 /usr/local/bin/docker-volume-backup.sh
/etc/cron.d/docker-volume-backup
0 3 * * * root /usr/local/bin/docker-volume-backup.sh >> /var/log/docker-volume-backup.log 2>&1

docker volume inspect fails for a volume that does not exist, and set -e stops the script there instead of archiving a new empty volume. tar -tzf reads the whole archive, so a truncated file fails before it is renamed. Leave database volumes out unless you stop their containers around the copy. More in the cron guide.

The archives share a disk with the volumes they protect, so copy them off the server too, for example with rclone.

What not to do

  • Don't copy /var/lib/docker/volumes while containers run. Files change during the copy, and a database copied that way may refuse to start. The path is also an internal detail that varies between installs.
  • Don't back up all of /var/lib/docker. Images and containers can be pulled and recreated. What you need is the volumes, the bind-mounted directories and your Compose files.
  • Don't treat cleanup commands as harmless. docker compose down -v removes the project's named volumes. docker volume prune --all removes every named volume no container uses, including those of a project you just took down.
  • Don't keep the only copy on the same server. See the 3-2-1 backup rule.

Common errors

SymptomCause and fix
The archive is tiny and lists no filesThe volume name was wrong, so Docker created an empty volume. Check docker volume ls, then remove the stray volume.
Restored files end up in a subfolder such as data/The archive was made from an absolute path instead of with -C /data .. Extract with --strip-components=1, or make the archive again.
volume is in use when removing a volumeA container, perhaps a stopped one, still mounts it. Find it with docker ps -a --filter volume=NAME and remove that container first.
The application reports permission errors after a restoreFile owners changed. Extract as root with --numeric-owner.
Permission denied on /backup on Fedora, RHEL or another SELinux hostSELinux blocks the bind mount. Add :z to it, as in -v "$(pwd)":/backup:z, and only on a dedicated backup directory, never a system one.
The database will not start after a restoreThe volume was archived while the database was writing. Restore an archive taken with the container stopped, or restore from a dump.

Self-hosted apps such as Uptime Kuma and Vaultwarden keep a SQLite database in their volume, which a plain tar can copy mid-write: see how to back up a SQLite database safely. Running Coolify? See how to back up Coolify.

Frequently asked questions

Where are Docker volumes stored?
On a standard Linux install, under /var/lib/docker/volumes/<name>/_data. Run docker volume inspect with the volume name to see the exact Mountpoint, and read or write it through a container rather than directly.
Can I back up a Docker volume while the container is running?
Yes. The helper container reads it without stopping anything, which is fine for files that are written once, such as uploads. For a database, stop the container for the copy or dump the database instead.
Does docker compose down delete volumes?
No. Named volumes survive docker compose down. docker compose down -v deletes them, and so does docker volume prune --all once no container uses them.
How do I copy a Docker volume to another server?
Pipe a tar archive over SSH. A helper container on the old host writes the archive to standard output, and another on the new host, started with -i, extracts it into a volume of the same name.
How do I back up a bind mount?
A bind mount is an ordinary host directory, so back it up with tar or rsync like any other directory. No helper container is needed.

How this was checked

Commands, limits and prices were checked against these official pages, on October 3, 2026: