Back Up and Restore Docker Volumes and Databases on a VPS

Published on

Back up Docker volumes and bind mounts with tar, dump databases instead of copying live files, restore a Compose stack on a new VPS and automate it with cron.

A VPS control panel listing one Debian server with its memory, cores, disk and status

Your app runs in Docker Compose on a VPS, and everything it stores sits in volumes: the database, uploads, settings. One docker compose down -v, a failed upgrade or a dead disk and it's gone, and a copy of the volume folders taken while the database runs may not even start. This guide backs up volumes, bind mounts and databases as the Docker and database docs describe, automates it, restores it on a new VPS and tests it. Commands are for Ubuntu 24.04 and Debian 12 with Docker Engine and the Compose plugin, run with sudo.

Quick answer: Dump each database while it runs (pg_dump, or mariadb-dump --single-transaction), because a copy of a running database's files may not be usable. Then stop the stack and archive each named volume through a throwaway container, docker run --rm -v VOLUME:/data:ro -v /var/backups/docker:/backup ubuntu:24.04 tar czf /backup/VOLUME.tar.gz -C /data ., plus the project folder with its compose file, .env and bind mounts. To restore, keep the folder name so the volume names match, create each volume with the Compose project label and extract its archive with --numeric-owner before the first up, or load the dumps into fresh database containers. Copy everything off the server and test a restore.

Step 1: Find Where the Data Lives

WhereIn a compose fileBack it up with
Named volumepgdata:/var/lib/postgresqlA throwaway container (Step 3)
Bind mount./uploads:/app/uploadstar on the host (Step 4)
Anonymous volumeOnly a container pathName it first (see the FAQ)
Container layerAnything not mountedNothing: it's deleted with the container

After docker compose down, the next up doesn't mount anonymous volumes again (compose down). List each container's mounts:

sudo docker ps --format '{{.Names}}'
C=your_db_container                   # a name from that list
sudo docker inspect --format '{{range .Mounts}}{{println .Type .Name .Source .Destination}}{{end}}' "$C"

Each line shows the type, the volume name (empty for a bind mount), the host path and the container path. An anonymous volume shows a long random name instead of one you chose.

Compose names volumes <project>_<volume> unless they set name:, and the project name defaults to the folder that holds the compose file, so pgdata in /opt/myapp becomes myapp_pgdata. Compose labels its volumes with the project, so you can list them per project, and docker system df -v shows each volume's size:

sudo docker volume ls --filter label=com.docker.compose.project=myapp
sudo docker system df -v

Step 2: Dump Databases, Don't Copy Their Files

A database keeps part of its state in memory and writes its files constantly. PostgreSQL's manual says the server "must be shut down in order to get a usable backup" of its files. A dump doesn't need that: pg_dump "makes consistent exports even if the database is being used concurrently", and mariadb-dump with --single-transaction dumps InnoDB tables consistently "without blocking any applications".

Run the tools inside the database container, so they match the server's version, and keep -T: it stops docker compose exec from allocating a pseudo-terminal, which can corrupt a dump. The database service here is called db.

cd /opt/myapp
# PostgreSQL: -U is your POSTGRES_USER, appdb your database
sudo docker compose exec -T db pg_dump -U postgres -Fc appdb > appdb.dump
# MariaDB: the single quotes let the container fill in its own variables
sudo docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > appdb.sql

Use the variable names from your compose file; older files use MYSQL_ROOT_PASSWORD and MYSQL_DATABASE. Since MariaDB 11.0 the mariadb image has no mysqldump, and the mysql image uses mysqldump, mysql and the MYSQL_ variables. Our PostgreSQL backup guide covers roles, restore options and major version upgrades.

Redis with RDB snapshots needs no dump: its persistence docs call copying the RDB file "completely safe while the server is running", while AOF needs the extra steps on that page. SQLite has no server to dump from, and SQLite warns that a copy made mid-transaction "might contain some old and some new content, and thus be corrupt". Stop the container first, as the next step does.

Step 3: Archive Named Volumes With a Throwaway Container

Docker's volumes docs back up a volume with a container that mounts it next to a host folder and runs tar. Stop the stack first, so nothing writes during the copy.

sudo install -d -m 700 -o "$USER" /var/backups/docker
cd /opt/myapp
sudo docker compose stop
D=$(date +%F); V=myapp_uploads
sudo docker run --rm -v "$V":/data:ro -v /var/backups/docker:/backup ubuntu:24.04 \
  tar czf "/backup/$V-$D.tar.gz" -C /data .

Run the docker run line once per volume, with its name in V, and leave the stack stopped for Step 4. --rm deletes the helper container afterwards, :ro keeps the volume read-only, and -C /data . stores paths relative to the volume, so the archive extracts into any volume, the top folder's owner and mode included. Containers run as root by default, so tar reads every file and records owners and permissions.

Step 4: Bind Mounts and the Project Folder

A bind mount is an ordinary host folder that host processes and containers "can modify the mounted files simultaneously" (storage overview), so archive it with tar on the host while the stack is still stopped. Take the whole project folder: it also holds the compose file and .env, which you need to recreate the stack with the same images and passwords. Then start the stack again:

sudo tar czf "/var/backups/docker/myapp-files-$D.tar.gz" -C /opt/myapp .
sudo docker compose start

That includes ./data/postgres if you followed our Docker Compose production guide. Bind mounts outside the folder, such as /srv/media, need their own tar line. With .env inside, the archive is as secret as your passwords.

Step 5: Automate It With a Script and cron

Save this as /usr/local/bin/docker-backup:

#!/bin/sh
set -eu
APP=/opt/myapp            # folder with the compose file
PROJECT=myapp             # name shown by: docker compose ls -a
DEST=/var/backups/docker
KEEP_DAYS=14
DAY=$(date +%F)

cd "$APP"
# 1. Dump the database while it runs (no database server? remove these two lines)
docker compose exec -T db pg_dump -U postgres -Fc appdb > "$DEST/$PROJECT-db-$DAY.dump.part"
mv "$DEST/$PROJECT-db-$DAY.dump.part" "$DEST/$PROJECT-db-$DAY.dump"

# 2. Stop the stack, archive its volumes and folder, start it again
docker compose stop
trap 'docker compose start' EXIT
for V in $(docker volume ls -q --filter "label=com.docker.compose.project=$PROJECT"); do
  docker run --rm -v "$V":/data:ro -v "$DEST":/backup ubuntu:24.04 \
    tar czf "/backup/$V-$DAY.tar.gz" -C /data .
done
tar czf "$DEST/$PROJECT-files-$DAY.tar.gz" -C "$APP" .

# 3. Delete archives older than KEEP_DAYS
find "$DEST" -type f -mtime +"$KEEP_DAYS" -delete

For MariaDB, use the Step 2 command and a .sql name. set -e stops the script at the first failed command, so a broken night never deletes older archives, and the trap starts the stack again on any exit. find ignores the fraction of a day, so -mtime +14 means at least 15 days old. The database volume is archived while stopped, so that archive is consistent too, and the dump also loads into a newer PostgreSQL version. Volumes with external: true don't carry this project's label: add their names after the $(...).

Run it once, check the files, then add the cron line to root's crontab, which runs it at 03:30 server time and logs its output:

sudo chmod 700 /usr/local/bin/docker-backup
sudo /usr/local/bin/docker-backup
ls -l /var/backups/docker
sudo crontab -e
30 3 * * * /usr/local/bin/docker-backup >> /var/log/docker-backup.log 2>&1

No crontab command? Install it with sudo apt install cron. The stack is down while tar runs.

A backup on the VPS's own disk dies with that disk, so pull a copy from another machine every day, with rsync installed on both:

rsync -a youruser@VPS_IP:/var/backups/docker/ ~/vps-docker-backups/

Without --delete, rsync keeps every old archive there, so prune that machine with a find line like the script's.

Step 6: Restore on a New VPS

Install Docker Engine and the Compose plugin (Ubuntu, Debian). A Space-Node VPS is KVM with full root access, and you pick Ubuntu 24.04 LTS or Debian when you order. Create /var/backups/docker as in Step 3 and copy the archives in. Restore the project folder under its old name, which sets the project and volume names, then every volume before the first up:

D=2026-10-30                          # the date of the backup
sudo mkdir -p /opt/myapp
sudo tar xzf "/var/backups/docker/myapp-files-$D.tar.gz" --numeric-owner -C /opt/myapp
cd /opt/myapp
for F in /var/backups/docker/myapp_*-"$D".tar.gz; do
  V=$(basename "$F" "-$D.tar.gz")
  sudo docker volume create --label com.docker.compose.project=myapp "$V"
  sudo docker run --rm -v "$V":/data -v /var/backups/docker:/backup ubuntu:24.04 \
    tar xzf "/backup/$V-$D.tar.gz" --numeric-owner -C /data
done
sudo docker compose up -d
sudo docker compose logs --tail 50

--numeric-owner restores owners by user ID, not name: the GNU tar manual explains that owners come out wrong when the password file in use doesn't match the files', and the helper image's users aren't your app's. docker volume create --label gives each volume the project label Compose puts on its own volumes, so the Step 5 script finds them again and Compose reuses them untouched. Without the label, Compose warns that the volume already exists but was not created by Docker Compose, and the script's label filter skips it every night. Published ports bypass UFW here too, as our Docker and UFW guide explains.

From the dump instead. Use it for a newer PostgreSQL major version or when the volume archive is broken. Move that volume's archive out of the folder before the loop, and start only the database. On an empty volume the image first runs a temporary server that listens only on a Unix socket, and the postgres and mariadb image docs warn it won't accept connections until setup ends. So wait until pg_isready gets a TCP connection, which only the final server accepts, then load the dump:

sudo docker compose up -d db
until sudo docker compose exec -T db pg_isready -h 127.0.0.1 -U postgres; do sleep 2; done
sudo docker compose exec -T db pg_restore -U postgres --clean --if-exists --create -d postgres < "/var/backups/docker/myapp-db-$D.dump"
sudo docker compose up -d

For MariaDB, replace the middle two lines; healthcheck.sh --connect passes once the TCP port accepts connections:

until sudo docker compose exec -T db healthcheck.sh --connect --innodb_initialized; do sleep 2; done
sudo docker compose exec -T db sh -c 'exec mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' < "/var/backups/docker/myapp-db-$D.sql"

Moving n8n? Restore its encryption key too, as our n8n migration guide shows.

Step 7: Test the Restore

Weekly, check that the archives are whole. gzip -t is silent for a good file and reports unexpected end of file for a cut-off one, and a complete mariadb-dump file ends with a -- Dump completed on line:

for F in /var/backups/docker/*.tar.gz; do gzip -t "$F" || echo "BROKEN: $F"; done
tail -n 1 /var/backups/docker/myapp-db-2026-10-30.sql

Monthly, restore a volume into a scratch volume and compare it with the live one. diff -rq lists only the files that differ, which should be the ones changed since the backup:

V=myapp_uploads; D=2026-10-30
sudo docker run --rm -v restore-test:/data -v /var/backups/docker:/backup ubuntu:24.04 \
  tar xzf "/backup/$V-$D.tar.gz" --numeric-owner -C /data
sudo docker run --rm -v restore-test:/a:ro -v "$V":/b:ro ubuntu:24.04 diff -rq /a /b
sudo docker volume rm restore-test

A few times a year, run Step 6 on a spare VPS, check yesterday's data in the app and time it.

If It Still Fails

The app starts empty. Compose used other volume names: compare sudo docker volume ls with your archives. Another folder name, -p or a name: changes the project name. A bind-mount folder that wasn't restored gets created empty by Compose, and the database image then creates a new database in it.

Permission denied in the app's logs. The files changed owner. You extracted without --numeric-owner, or without sudo, when tar extracts files as yourself. Extract again with both.

The cron job fails, but the script works by hand. Read /var/log/docker-backup.log. the input device is not a TTY, or from Docker 29.4 on cannot attach stdin to a TTY-enabled container because stdin is not a terminal, means a line uses docker exec -it. Cron has no terminal: drop -it, or use docker compose exec -T.

tar exits with status 1: file changed as we read it. Something wrote to the volume during the copy, so that file isn't an exact copy. Stop the stack first, as the script does.

FAQ

Can I copy /var/lib/docker/volumes directly? Docker's storage overview calls direct access to volume data "unsupported, undefined behavior" that may break the volume. Use the helper container from Step 3.

Does docker commit save my volumes? No. The commit reference says "Commits do not include any data contained in mounted volumes."

Which commands delete volume data? docker compose down -v removes the project's named volumes, docker volume rm removes one volume, and docker volume prune -a removes every volume no container uses. Plain docker volume prune removes only unused anonymous volumes.

How do I back up an anonymous volume? With --volumes-from, as in Docker's backup example, and the container path from Step 1: sudo docker run --rm --volumes-from "$C" -v /var/backups/docker:/backup ubuntu:24.04 tar czf /backup/anon.tar.gz -C /var/lib/postgresql/data .. Then name the mount in the compose file and extract the archive into that volume as in Step 6, before the next up.

Are backups included with a Space-Node VPS? Not in the plan price; you can arrange them through a support ticket. Keep your own archives anyway: you choose what they hold and where the copies live.

Sources

All opened on 30 October 2026: