Your Postgres data, Nextcloud uploads, and Redis snapshots live in Docker volumes. If the host dies and you have no volume backup, that data is gone. This guide covers volume backup and migration on a Linux Docker Engine host. You’ll create and inspect named volumes, back them up to tar with a throwaway Alpine container, and restore them on the same host or a new one. You’ll also fix UID/GID permission errors, back a volume with NFS, and lock mounts down as read-only.
The short answer: mount the volume read-only into a temporary alpine container, bind-mount a host backup directory next to it, and run tar inside. To restore, run the same pattern in reverse into a new, empty volume. Stop the app container first if it writes constantly, as databases do. Use --mount instead of -v in anything you script.
Docker Volume Backup and Migration: Quick Reference
| Task | Command pattern | Step |
|---|---|---|
| Create and inspect a volume | docker volume create NAME / docker volume inspect NAME | Step 2 |
| Back up a volume to tar | docker run --rm --mount ...,readonly --mount type=bind,... alpine tar czf ... | Step 4 |
| Restore into a new volume | docker volume create NEW then alpine tar xzf ... -C /data | Step 5 |
| Move a volume to another host | Stream tar over ssh into docker run -i on the remote host | Step 6 |
| Fix permission denied on a volume | Look up the image’s user with id, then chown -R through a helper container | Step 7 |
| NFS-backed volume | docker volume create --driver local --opt type=nfs ... | Step 8 |
| Read-only mount | --mount ...,readonly or -v NAME:/path:ro | Step 9 |
| Audit volume disk usage | docker system df -v | Step 10 |
What Is Docker Volume Management?
A Docker volume is storage that the Docker Engine creates and tracks for you. It sits outside the container’s writable layer. Data in a volume survives when you stop, remove, or replace a container. The Docker volumes documentation describes volumes as the preferred way to persist container data.
A bind mount, by contrast, ties a container to a specific path on the host. Volumes have their own lifecycle. Removing a container doesn’t remove its volume. That’s great for safety, but it also means unused volumes pile up.
Docker has no built-in docker volume export command for Engine hosts. Docker Desktop’s Volumes view can export and import volume contents, but that’s a Desktop feature, and a headless Docker Engine server doesn’t have it. On Engine, the standard pattern is a short-lived helper container that mounts the volume and runs tar. For background, see the Docker storage overview and Persisting container data.
| Mount type | Lifecycle | Host access | Best for | Backup method |
|---|---|---|---|---|
| Named volume | Lives until you remove it | Through Docker (default /var/lib/docker/volumes/... on Linux, configurable) | Databases, app state, portable data | Helper container + tar |
| Anonymous volume | Random name, easy to lose track of | Through Docker | Scratch data declared by an image’s VOLUME | Same, but find it first with docker inspect |
| Bind mount | Tied to a host path | Direct | Config files you edit on the host, dev source code | Normal host backup tools |
| tmpfs | Gone when the container stops | None | Secrets in memory, caches | Not applicable |
Prerequisites
- A Linux host running Docker Engine. The commands use Ubuntu/Debian package names. On RHEL-family hosts, swap
aptfordnfandnfs-commonfornfs-utils. - root or sudo access, or a user in the
dockergroup. That group is root-equivalent, so treat it that way. - Internet access to pull
alpine(a small, lightweight image) from Docker Hub, or a local mirror. - Free disk space for backups, roughly the size of the volumes you’re archiving. A separate external USB SSD or a NAS share is a better target than the Docker host’s own disk.
- For migration: SSH access from the source host to a second Docker host.
- For the NFS section: a reachable NFS server, such as a Synology or TrueNAS NAS or a Linux box, with an export the Docker host is allowed to mount.
| Requirement | Details |
|---|---|
| OS | Linux (examples use Ubuntu/Debian package names) |
| Docker | Docker Engine, CLI access (not Docker Desktop) |
| Helper image | alpine (BusyBox tar); debian:stable-slim if you want GNU tar |
| NFS client | nfs-common on Debian/Ubuntu |
| Network | SSH between hosts for migration; NFS ports open to the server for Step 8 |
Environment notes
The commands target Docker Engine on a Debian/Ubuntu host with a bash shell. This guide doesn’t assume a particular Engine release. Run docker version and check flag behavior against the official volumes docs for your release. Sample outputs are representative. Your timestamps, sizes, and IDs will differ.
Dates, filenames, and timestamps in the examples (such as 2026-11-06) are illustrative. When you run the commands yourself, substitute whatever $(date +%F) and ls actually produce on your system.
Step-by-Step Guide
Step 1: Confirm Docker Engine and Your Access
Make sure the daemon is running and your user can talk to it.
docker version --format 'Client: {{.Client.Version}} Server: {{.Server.Version}}'
docker info --format 'Storage driver: {{.Driver}} Docker root: {{.DockerRootDir}}'
Expected output (versions will vary):
Client: XX.X.X Server: XX.X.X
Storage driver: overlay2 Docker root: /var/lib/docker
If you see permission denied while trying to connect to the Docker daemon socket, prefix the commands with sudo or add your user to the docker group. Note the Docker root path printed above.
On a typical Linux install with default settings, the Docker root is /var/lib/docker, so local volumes live under /var/lib/docker/volumes/. That path is configurable in the daemon settings and can differ on other platforms or custom installs, so always confirm it with the command above rather than assuming the default.
Step 2: Create and Inspect a Named Volume
Create a volume with a label. Labels make it easy to filter volumes later in backup scripts.
# --label attaches metadata you can filter on later (docker volume ls --filter label=app=demo)
docker volume create --label app=demo mydata
Expected output:
mydata
Now inspect it:
docker volume inspect mydata
Expected output:
[
{
"CreatedAt": "2026-11-06T09:14:22Z",
"Driver": "local",
"Labels": {
"app": "demo"
},
"Mountpoint": "/var/lib/docker/volumes/mydata/_data",
"Name": "mydata",
"Options": null,
"Scope": "local"
}
]
The fields that matter:
- Driver:
localmeans plain host disk, unless driver options say otherwise (see Step 8). - Mountpoint: where the data actually lives on the host. You can read it as root. Don’t write into it while a container is using it.
- Options: empty for a plain local volume. NFS options show up here.
Step 3: Attach the Volume, --mount vs -v
Docker gives you two ways to attach a volume. Both produce the same mount:
# Long form: comma-separated key=value pairs, order doesn't matter
docker run --rm --mount type=volume,source=mydata,target=/data alpine ls /data
# Short form: source:target[:options], position matters
docker run --rm -v mydata:/data alpine ls /data
Both print nothing yet, because the volume is empty. Seed it with some test data so the backup steps have something to work on:
docker run --rm \
--mount type=volume,source=mydata,target=/data \
alpine sh -c 'echo "hello from mydata" > /data/hello.txt && mkdir -p /data/config && echo "setting=1" > /data/config/app.conf && ls -lR /data'
Expected output:
/data:
total 8
drwxr-xr-x 2 root root 4096 Nov 6 09:16 config
-rw-r--r-- 1 root root 18 Nov 6 09:16 hello.txt
/data/config:
total 4
-rw-r--r-- 1 root root 10 Nov 6 09:16 app.conf
Which syntax should you use? Docker’s volumes documentation says --mount is generally preferred. It’s more explicit and supports all the available options. In practice, the differences look like this:
--mount | -v / --volume | |
|---|---|---|
| Format | type=volume,source=X,target=Y,readonly | X:Y:ro |
| Readability in reviews | Each key is named | You have to remember field order |
| Volume driver options | Supported (volume-opt=...) | Not expressible |
Extra options like volume-subpath | Supported on releases that have it | Not available |
| Missing named volume | Created silently | Created silently |
| Missing bind-mount host path | Errors out | Silently creates an empty directory |
| Typing speed | Slower | Faster |
Warning: Neither flag protects you from a typo in a volume name.
--mount type=volume,source=mydat,...and-v mydat:/datawill both create a brand-new, empty volume calledmydat. If that’s your database container, it’ll start “fresh” and look like it lost all its data. In scripts, check that the volume exists before you mount it. The backup script in the Configuration section shows how.
My rule: -v is fine at an interactive prompt. Use --mount, or Compose’s long syntax, in anything that runs unattended.
Step 4: Back Up a Named Volume to a tar Archive
Create a backup directory on the host. Ideally it sits on a different disk or a mounted NAS share.
sudo mkdir -p /srv/backups/docker
Now run the backup one-liner:
docker run --rm \
--mount type=volume,source=mydata,target=/data,readonly \
--mount type=bind,source=/srv/backups/docker,target=/backup \
alpine \
tar czvf /backup/mydata-$(date +%F).tar.gz -C /data .
# --rm deletes the helper container when tar finishes
# readonly on the source volume means the backup job can never modify your data
# -C /data . archives the *contents* of /data with relative paths, so restores need no --strip-components
# $(date +%F) is expanded by your host shell, giving a filename like mydata-YYYY-MM-DD.tar.gz
Expected output (BusyBox tar verbose listing):
./
./config/
./config/app.conf
./hello.txt
Verify the archive from the host:
ls -lh /srv/backups/docker/
tar tzvf /srv/backups/docker/mydata-$(date +%F).tar.gz
Expected output:
-rw-r--r-- 1 root root 245 Nov 6 09:20 mydata-2026-11-06.tar.gz
drwxr-xr-x root/root 0 2026-11-06 09:16 ./
drwxr-xr-x root/root 0 2026-11-06 09:16 ./config/
-rw-r--r-- root/root 10 2026-11-06 09:16 ./config/app.conf
-rw-r--r-- root/root 18 2026-11-06 09:16 ./hello.txt
The exact byte count, timestamp, and filename above are just this run’s output; yours will differ based on your tar implementation, the files you archived, and your system clock.
The archive is owned by root on the host, because the helper container runs as root. Lock down the backup directory with chmod 700 if the volumes hold anything sensitive.
The --volumes-from variant. Docker’s docs show a pattern that borrows all of a running container’s mounts. That’s handy when you don’t remember the volume names:
# Mounts every volume attached to the container "myapp" at the same paths, then archives /var/lib/myapp
docker run --rm \
--volumes-from myapp \
--mount type=bind,source=/srv/backups/docker,target=/backup \
alpine \
tar czvf /backup/myapp-$(date +%F).tar.gz -C /var/lib/myapp .
Replace /var/lib/myapp with the container’s actual mount path. Check it with docker inspect --format '{{json .Mounts}}' myapp.
Do you need to stop the container? You don’t need to remove anything. The container and its image stay put. But tar copies files one at a time. If a database writes during the copy, the archive can hold a torn, inconsistent state. For databases, pick one of these:
- Stop briefly:
docker stop myapp, run the backup, thendocker start myapp. Downtime equals backup time. - Use the database’s own dump tool, such as
pg_dumpfor Postgres, and back up the dump file instead.
Static data like uploads, config, or media is usually fine to archive live.
Step 5: Restore the Backup into a New Volume
Always restore into a new, empty volume first. That way you can check it before you point anything at it.
docker volume create mydata_restored
docker run --rm \
--mount type=volume,source=mydata_restored,target=/data \
--mount type=bind,source=/srv/backups/docker,target=/backup,readonly \
alpine \
tar xzvf /backup/mydata-2026-11-06.tar.gz -C /data
# Here the backup directory is the read-only side; the destination volume must be writable
# -C /data extracts into the volume root, matching how the archive was created
Expected output:
mydata_restored
./
./config/
./config/app.conf
./hello.txt
Check contents and numeric ownership:
docker run --rm --mount type=volume,source=mydata_restored,target=/data,readonly \
alpine sh -c 'cat /data/hello.txt && ls -lnR /data'
# -n shows numeric UID/GID instead of names — this is what actually matters to the app container
Expected output:
hello from mydata
/data:
total 8
drwxr-xr-x 2 0 0 4096 Nov 6 09:16 config
-rw-r--r-- 1 0 0 18 Nov 6 09:16 hello.txt
...
To use the restored data, start your app container with source=mydata_restored. If the old volume is gone, you can restore straight into a fresh volume with the original name.
Warning: Extracting into a volume that already has data merges the archive over it. Old files that aren’t in the archive stay behind. If you must reuse a volume name, back up the existing volume first. Then remove it with
docker volume rm NAME(this permanently deletes its data) and recreate it empty.
Ownership caveat. For volumes owned by non-root users, compare ls -ln on the restored volume against the source. tar may map owners by name, and the Alpine helper’s /etc/passwd doesn’t match your app image’s users. If the numbers differ, use GNU tar with --numeric-owner for both backup and restore:
docker run --rm \
--mount type=volume,source=mydata,target=/data,readonly \
--mount type=bind,source=/srv/backups/docker,target=/backup \
debian:stable-slim \
tar --numeric-owner -czpf /backup/mydata-$(date +%F).tar.gz -C /data .
# --numeric-owner stores and restores raw UID/GID numbers, ignoring user names
# -p preserves permissions
On restore, use the same image with tar --numeric-owner -xzpf ... -C /data.
Step 6: Migrate a Volume to Another Host
There are two ways to move a volume: copy the archive file, or stream it directly.
Option A: copy the archive. Good when you want a backup file to keep anyway.
# On the source host
scp /srv/backups/docker/mydata-2026-11-06.tar.gz [email protected]:/tmp/
# On the destination host
docker volume create mydata
docker run --rm \
--mount type=volume,source=mydata,target=/data \
--mount type=bind,source=/tmp,target=/backup,readonly \
alpine tar xzvf /backup/mydata-2026-11-06.tar.gz -C /data
Option B: stream over SSH. Nothing touches the source disk. That helps when the volume is larger than your free space.
docker run --rm \
--mount type=volume,source=mydata,target=/data,readonly \
alpine tar czf - -C /data . \
| ssh [email protected] \
'docker volume create mydata >/dev/null && docker run --rm -i --mount type=volume,source=mydata,target=/data alpine tar xzf - -C /data'
# tar czf - writes the archive to stdout instead of a file
# -i on the remote docker run keeps stdin open so tar can read the stream
The remote user needs to run docker without a password prompt. That means docker group membership, or a scoped sudo rule.
If the remote account uses sudo, call it explicitly inside the quoted remote command. sudo -n fails fast instead of prompting, and the tar stream still reaches docker run -i on stdin:
docker run --rm \
--mount type=volume,source=mydata,target=/data,readonly \
alpine tar czf - -C /data . \
| ssh [email protected] \
'sudo -n docker volume create mydata >/dev/null && sudo -n docker run --rm -i --mount type=volume,source=mydata,target=/data alpine tar xzf - -C /data'
Your network is usually the bottleneck. Moving large volumes of Nextcloud data across a slower link takes a while, and faster NICs and switches make a noticeable difference if you do this often. After migrating, run the checksum comparison in Step 11 on both hosts. Then bring the app up on the new host with the same volume name in its Compose file.
Step 7: Fix UID/GID Ownership for Non-Root Containers
This is the most common reason a restored or migrated volume “doesn’t work.” Linux stores file permissions as numeric UIDs and GIDs. Say the files in a volume are owned by UID 0 (root), and the container’s process runs as a service user with a different UID. Writes fail.
Typical symptoms:
- Postgres: the container exits with an
initdbor data-directory error mentioningPermission deniedorOperation not permitted. - Redis: it can’t write its
dump.rdbor AOF file, and errors about failed saves show up indocker logs. - Nextcloud: the web installer or
occcomplains that the data directory isn’t writable. Nextcloud runs as the web server user,www-data.
Official images like Postgres and Redis usually start as root, fix ownership of their own data directory, and then drop to the service user. Check each image’s Docker Hub page for the exact behavior of your tag. That automatic fix breaks in three common cases:
- You pass
--user(oruser:in Compose), so the entrypoint never runs as root. - You restored files that are owned by a different UID than the image expects.
- The volume is on NFS with root squashing, so the entrypoint’s root
chownis refused.
Find the UID/GID the image actually uses. Don’t guess. Debian-based and Alpine-based variants of the same image can use different numbers.
# Override the entrypoint so we just run `id` for the image's service user
docker run --rm --entrypoint id postgres postgres
docker run --rm --entrypoint id redis redis
docker run --rm --entrypoint id nextcloud www-data
Expected output format (numbers depend on the image and tag you run):
uid=NNN(postgres) gid=NNN(postgres) groups=NNN(postgres)
uid=NNN(redis) gid=NNN(redis) groups=NNN(redis)
uid=NN(www-data) gid=NN(www-data) groups=NN(www-data)
Run these against the exact tag you deploy, such as postgres:16-alpine rather than plain postgres.
Compare with what’s in the volume:
docker run --rm --mount type=volume,source=pgdata,target=/data,readonly alpine ls -ln /data
Fix ownership with a one-off helper:
# Replace 999:999 with the uid:gid printed by the id command above
docker run --rm \
--mount type=volume,source=pgdata,target=/data \
alpine chown -R 999:999 /data
Warning:
chown -Rrewrites ownership on every file in the volume. Double-check the volume name and the UID/GID first. On a volume shared by several apps, recursive chown can break the other app.
Then start the real container normally. Use docker logs -f to confirm it gets past initialization.
Step 8: Create an NFS-Backed Named Volume
An NFS-backed volume lets several Docker hosts use the same data under a normal Docker volume name. You don’t need host-level /etc/fstab entries. Docker’s built-in local driver handles it through driver options. The pattern below follows the NFS examples in the Docker volumes documentation. Re-check the option syntax there against your Engine release before you roll it out widely.
8a. Install the NFS client on the Docker host:
sudo apt update
sudo apt install -y nfs-common
8b. Test the export outside Docker first. If this step fails, Docker will fail too, with a vaguer error.
sudo mkdir -p /mnt/nfstest
sudo mount -t nfs -o nfsvers=4 192.168.1.50:/srv/docker-volumes /mnt/nfstest
sudo touch /mnt/nfstest/write-test && ls -ln /mnt/nfstest
sudo rm /mnt/nfstest/write-test
sudo umount /mnt/nfstest
Replace 192.168.1.50 and /srv/docker-volumes with your server and export. Create the subdirectory you’ll use (appdata below) on the server ahead of time.
8c. Create the volume:
docker volume create \
--driver local \
--opt type=nfs \
--opt o=addr=192.168.1.50,rw,nfsvers=4 \
--opt device=:/srv/docker-volumes/appdata \
appdata_nfs
# type=nfs -> filesystem type passed to mount
# o=addr=... -> NFS server address plus mount options (rw, protocol version)
# device=:/path -> the export path; note the leading colon
Expected output:
appdata_nfs
8d. Inspect and test it:
docker volume inspect appdata_nfs --format '{{json .Options}}'
docker run --rm --mount type=volume,source=appdata_nfs,target=/data \
alpine sh -c 'echo nfs-ok > /data/probe.txt && cat /data/probe.txt'
Expected output:
{"device":":/srv/docker-volumes/appdata","o":"addr=192.168.1.50,rw,nfsvers=4","type":"nfs"}
nfs-ok
A few things to know:
- The
localdriver mounts the share when a container first uses the volume.docker volume createdoesn’t mount anything, so a bad address often shows up only atdocker run. - Driver options are fixed once the volume exists. To change them, remove the volume (the data on the NFS server stays) and create it again.
- With
root_squashon the server, root inside containers becomes an unprivileged user on the share. Step 7’schowntrick won’t work. Set ownership on the NFS server instead. - Running a Postgres data directory over NFS is possible, but how well it behaves depends on the NFS version, server, mount options, and network. It’s a common source of locking and latency trouble. Keep databases on local SSD if you can, and back them up to the NAS instead.
- If your NAS and Docker hosts share a power circuit, put them on a UPS. An NFS server dropping out mid-write is a fast way to corrupt app data.
Step 9: Lock Down Mounts as Read-Only
Read-only mounts limit the damage if a container is compromised. A reverse proxy only needs to read certificates. A static site container only needs to read its content.
docker run --rm \
--mount type=volume,source=mydata,target=/data,readonly \
alpine touch /data/should-fail
Expected output:
touch: /data/should-fail: Read-only file system
The -v equivalent appends :ro:
docker run --rm -v mydata:/data:ro alpine touch /data/should-fail
To confirm a running container’s mount is really read-only, check the RW field:
docker run -d --name ro-test --mount type=volume,source=mydata,target=/data,readonly alpine sleep 300
docker inspect --format '{{range .Mounts}}{{.Name}} -> {{.Destination}} RW={{.RW}}{{"\n"}}{{end}}' ro-test
docker rm -f ro-test
Expected output:
mydata -> /data RW=false
You can mix read-only and writable volumes in the same container. For example, mount config read-only and a separate data volume read-write. For extra hardening, add --read-only to make the container’s root filesystem read-only too, with tmpfs mounts for /tmp.
Step 10: Audit Volume Disk Usage with docker system df -v
Volumes outlive their containers, so they pile up. See where your space is going:
docker system df -v
The docker system df reference lists the other output modes.
Look for the volumes section of the report. The sample below is representative, and column layout can vary between releases:
Local Volumes space usage:
VOLUME NAME LINKS SIZE
pgdata 1 1.84GB
nextcloud_data 1 12.6GB
mydata 0 28B
mydata_restored 0 28B
3f9c2a...e81 0 412MB
LINKS is how many containers reference the volume. 0 means nothing uses it, though a stopped container you plan to restart still counts. Long hex names are anonymous volumes, usually created by an image’s VOLUME instruction.
To list dangling volumes before you remove anything:
docker volume ls --filter dangling=true
Warning:
docker volume rm NAMEanddocker volume prunepermanently delete data. There’s no undo. On current Engine releases,docker volume pruneremoves only unused anonymous volumes by default, and--allextends it to unused named volumes. Rundocker volume prune --helpto confirm what your version does. Back up anything you’re unsure about with Step 4 first.
Step 11: Verify the Backup and Restore End-to-End
A backup you haven’t restored is a guess. This comparison checksums every file in the source and restored volumes and diffs the results:
checksums() {
docker run --rm --mount type=volume,source="$1",target=/data,readonly \
alpine sh -c 'cd /data && find . -type f -exec sha256sum {} + | sort -k2'
}
diff <(checksums mydata) <(checksums mydata_restored) && echo "MATCH: volumes are identical"
Expected output:
MATCH: volumes are identical
Any lines printed instead show files that differ or are missing. This compares regular-file contents only. It doesn’t check ownership, permissions, or symlinks, so pair it with the ls -lnR check from Step 5. For migrations, run checksums mydata on each host and diff the saved output. For databases, the real test is starting the app on the restored volume and querying it.
Configuration
Key Settings Reference
| Setting | Example | Default | Notes |
|---|---|---|---|
--mount type=volume | type=volume,source=pgdata,target=/var/lib/postgresql/data | Read-write | Preferred in scripts and production |
readonly / ro (in --mount) | ...,target=/data,readonly | Off | Container can’t write to the mount |
-v NAME:PATH[:ro] | -v pgdata:/var/lib/postgresql/data:ro | Read-write | Short form; fine interactively |
--driver local --opt type=nfs | See Step 8 | Local disk | Options fixed at creation |
--opt o=addr=IP,rw,nfsvers=4 | o=addr=192.168.1.50,rw,nfsvers=4 | N/A | Match nfsvers to what the server supports |
--label key=value | --label backup=nightly | None | Use for filtering in backup scripts |
docker system df -v | N/A | Summary only without -v | Per-volume size and link count |
Docker Compose Long Syntax
Compose has the same long-vs-short split. The long form mirrors --mount:
# /opt/stacks/app/compose.yaml
services:
db:
image: postgres:16
volumes:
- type: volume
source: pgdata
target: /var/lib/postgresql/data
web:
image: nginx:stable
volumes:
- type: volume
source: site_content
target: /usr/share/nginx/html
read_only: true # equivalent of readonly / :ro
volumes:
pgdata:
labels:
backup: nightly
site_content:
external: true # created beforehand (e.g. the NFS volume from Step 8); Compose won't create or delete it
external: true is the safe choice for volumes you migrated or created by hand. Compose expects them to exist already and won’t treat them as part of the stack. Also, docker compose down -v removes the stack’s named volumes. Leave -v off unless you mean it.
A Scripted Nightly Backup
This script backs up every volume labeled backup=nightly. It refuses to run against a volume that doesn’t exist, which guards against the silent-typo problem from Step 3. It keeps 14 days of archives.
#!/usr/bin/env bash
# /usr/local/sbin/docker-volume-backup.sh
set -euo pipefail
BACKUP_DIR=/srv/backups/docker
KEEP_DAYS=14
STAMP=$(date +%F_%H%M)
mkdir -p "$BACKUP_DIR"
docker volume ls --quiet --filter label=backup=nightly | while IFS= read -r VOL; do
# Fail loudly if the volume vanished between listing and backup
if ! docker volume inspect "$VOL" >/dev/null 2>&1; then
echo "ERROR: volume $VOL not found, skipping" >&2
continue
fi
echo "Backing up $VOL"
docker run --rm \
--mount type=volume,source="$VOL",target=/data,readonly \
--mount type=bind,source="$BACKUP_DIR",target=/backup \
debian:stable-slim \
tar --numeric-owner -czpf "/backup/${VOL}-${STAMP}.tar.gz" -C /data .
done
# Remove archives older than KEEP_DAYS
find "$BACKUP_DIR" -name '*.tar.gz' -mtime +"$KEEP_DAYS" -delete
Install and schedule it:
sudo chmod 700 /usr/local/sbin/docker-volume-backup.sh
echo '30 2 * * * root /usr/local/sbin/docker-volume-backup.sh >> /var/log/docker-volume-backup.log 2>&1' | sudo tee /etc/cron.d/docker-volume-backup
For databases, add a docker stop and docker start around the volume, or replace the tar step with a native dump. Then copy /srv/backups/docker off the host, for example with rsync to a NAS. A backup on the same disk as the data won’t survive a disk failure.
Tips and Troubleshooting
Container starts “empty” after a deploy: all data seems gone
Cause: The volume name in your -v, --mount, or Compose file doesn’t match the existing volume. Docker created a new, empty one. Compose also prefixes volume names with the project name, so pgdata in project app becomes app_pgdata. A renamed directory changes the project name.
Fix:
- Run
docker volume lsand look for both the old and new names. - Compare sizes with
docker system df -v. The real data is in the big one. - Point the service at the correct name, or declare it
external: truewith the exact name. - Remove the accidental empty volume only after confirming it’s empty.
Permission denied from Postgres, Redis, or Nextcloud after a restore
Cause: The numeric UID/GID on the restored files doesn’t match the user the container runs as. This often comes from name-based tar mapping or from running with --user.
Fix:
- Get the expected IDs:
docker run --rm --entrypoint id IMAGE:TAG USERNAME. - Inspect the volume:
docker run --rm --mount type=volume,source=VOL,target=/data,readonly alpine ls -ln /data. - Fix it:
docker run --rm --mount type=volume,source=VOL,target=/data alpine chown -R UID:GID /data. - Use
--numeric-ownerwith GNUtarfor future backups (Step 5).
NFS volume fails with failed to mount local volume or the container hangs on start
Cause: One of these: the NFS client package is missing, the nfsvers value doesn’t match the server, a firewall blocks NFS (TCP 2049 for NFSv4), the export doesn’t allow this host’s IP, or the device subdirectory doesn’t exist.
Fix:
- Confirm
nfs-commonis installed. - Repeat the manual
mounttest from Step 8b. Fix it until it works outside Docker. - Check the server’s export list allows the Docker host’s IP.
- Recreate the volume with the corrected options:
docker volume rm appdata_nfs, then rerundocker volume create. Data on the server itself is untouched.
Restored files land in the wrong place, like /data/data/...
Cause: The archive was created with absolute or prefixed paths, such as tar cf backup.tar /dbdata in the classic --volumes-from example. It was then extracted without stripping that prefix.
Fix: Create archives with -C /data . as shown in Step 4, so paths are relative. For an existing archive with a prefix, check the layout with tar tzf FILE | head. Then extract with --strip-components=1 (one level per leading directory).
docker commit backup doesn’t include the database
Cause: docker container commit captures only the container’s writable layer. Data in volumes is excluded by design.
Fix: Back up volumes separately with Step 4. Treat images as code and volumes as data.
Disk keeps filling even though old containers are gone
Cause: Volumes aren’t removed with their containers.
Fix: Run docker system df -v. Then run docker volume ls --filter dangling=true to find unused volumes. Back up anything uncertain, then remove it with docker volume rm NAME.
Quick tips
- Label everything you back up.
--label backup=nightlyturns your backup script into a filter instead of a hard-coded list. - Mount the source volume
readonlyin backup jobs. A typo in a backup command then can’t damage live data. - Pin helper images in automation. Use
alpine:3rather thanalpine, so a base-image change doesn’t surprise you at 2 AM. - Docker Desktop users have a GUI export option in the Volumes view, covered in Docker’s backup and restore docs. The CLI pattern here works on Desktop too and is easier to script.
Wrapping Up
You can now back up any named volume with one docker run, restore it into a fresh volume, and stream it to a new host over SSH. Checksums prove the copy matches. The parts that bite people are the UID/GID mismatch and the silent empty-volume typo, and both now have concrete checks.
My take: the helper-container tar pattern is unglamorous but hard to beat. It needs no extra tools, works the same on every Linux host, and fails in obvious ways. Just don’t trust it for live databases without a stop or a native dump. Put NFS behind your backups, not under your databases.
| Step | Action | Applies To |
|---|---|---|
| 2 | docker volume create / inspect | All volumes |
| 3 | Use --mount in scripts | Production, automation |
| 4–5 | Helper-container tar backup and restore | Any named volume |
| 6 | Stream over SSH to a new host | Server migrations |
| 7 | id + chown -R via helper | Postgres, Redis, Nextcloud, non-root images |
| 8 | local driver with NFS options | Shared or centralized storage |
| 9 | readonly / :ro | Config, certs, static content |
| 10 | docker system df -v | Disk audits and cleanup |
| 11 | Checksum diff | Verifying every restore |
Resources
- Volumes: Docker Docs
- Docker storage overview
- Persisting container data: Docker Concepts
- Keep data with volumes and bind mounts
- Back up and restore Docker Desktop data