How-To

Docker Volume Management: Backup, Migration, and Storage Strategies

The Back Room Tech Staff · Edited by Aseem Kishore
26 min read

The Back Room Tech is reader-supported. We may earn a commission when you buy through links on our site. Learn more.

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

TaskCommand patternStep
Create and inspect a volumedocker volume create NAME / docker volume inspect NAMEStep 2
Back up a volume to tardocker run --rm --mount ...,readonly --mount type=bind,... alpine tar czf ...Step 4
Restore into a new volumedocker volume create NEW then alpine tar xzf ... -C /dataStep 5
Move a volume to another hostStream tar over ssh into docker run -i on the remote hostStep 6
Fix permission denied on a volumeLook up the image’s user with id, then chown -R through a helper containerStep 7
NFS-backed volumedocker volume create --driver local --opt type=nfs ...Step 8
Read-only mount--mount ...,readonly or -v NAME:/path:roStep 9
Audit volume disk usagedocker system df -vStep 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 typeLifecycleHost accessBest forBackup method
Named volumeLives until you remove itThrough Docker (default /var/lib/docker/volumes/... on Linux, configurable)Databases, app state, portable dataHelper container + tar
Anonymous volumeRandom name, easy to lose track ofThrough DockerScratch data declared by an image’s VOLUMESame, but find it first with docker inspect
Bind mountTied to a host pathDirectConfig files you edit on the host, dev source codeNormal host backup tools
tmpfsGone when the container stopsNoneSecrets in memory, cachesNot applicable

Prerequisites

  • A Linux host running Docker Engine. The commands use Ubuntu/Debian package names. On RHEL-family hosts, swap apt for dnf and nfs-common for nfs-utils.
  • root or sudo access, or a user in the docker group. 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.
RequirementDetails
OSLinux (examples use Ubuntu/Debian package names)
DockerDocker Engine, CLI access (not Docker Desktop)
Helper imagealpine (BusyBox tar); debian:stable-slim if you want GNU tar
NFS clientnfs-common on Debian/Ubuntu
NetworkSSH 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: local means 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.
Terminal showing `docker volume create --label app=demo mydata` echoing `mydata`, followed by `docker volume inspect mydata` JSON output with the Driver, Labels, and Mountpoint fields visible

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
Formattype=volume,source=X,target=Y,readonlyX:Y:ro
Readability in reviewsEach key is namedYou have to remember field order
Volume driver optionsSupported (volume-opt=...)Not expressible
Extra options like volume-subpathSupported on releases that have itNot available
Missing named volumeCreated silentlyCreated silently
Missing bind-mount host pathErrors outSilently creates an empty directory
Typing speedSlowerFaster

Warning: Neither flag protects you from a typo in a volume name. --mount type=volume,source=mydat,... and -v mydat:/data will both create a brand-new, empty volume called mydat. 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
Terminal running the alpine helper-container backup one-liner with --mount type=volume,source=mydata,target=/data,readonly and the bind mount to /srv/backups/docker, showing tar's verbose file list and the resulting mydata-YYYY-MM-DD.tar.gz filename

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, then docker start myapp. Downtime equals backup time.
  • Use the database’s own dump tool, such as pg_dump for 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
Terminal showing `docker volume create mydata_restored` followed by the alpine tar xzvf restore one-liner, with the destination volume name mydata_restored and the extracted file list visible

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 initdb or data-directory error mentioning Permission denied or Operation not permitted.
  • Redis: it can’t write its dump.rdb or AOF file, and errors about failed saves show up in docker logs.
  • Nextcloud: the web installer or occ complains 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 (or user: 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 chown is 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 -R rewrites 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 local driver mounts the share when a container first uses the volume. docker volume create doesn’t mount anything, so a bad address often shows up only at docker 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_squash on the server, root inside containers becomes an unprivileged user on the share. Step 7’s chown trick 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
Terminal output of `docker system df -v` scrolled to the Local Volumes space usage section, showing the VOLUME NAME, LINKS, and SIZE columns with several volumes listed

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 NAME and docker volume prune permanently delete data. There’s no undo. On current Engine releases, docker volume prune removes only unused anonymous volumes by default, and --all extends it to unused named volumes. Run docker volume prune --help to 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

SettingExampleDefaultNotes
--mount type=volumetype=volume,source=pgdata,target=/var/lib/postgresql/dataRead-writePreferred in scripts and production
readonly / ro (in --mount)...,target=/data,readonlyOffContainer can’t write to the mount
-v NAME:PATH[:ro]-v pgdata:/var/lib/postgresql/data:roRead-writeShort form; fine interactively
--driver local --opt type=nfsSee Step 8Local diskOptions fixed at creation
--opt o=addr=IP,rw,nfsvers=4o=addr=192.168.1.50,rw,nfsvers=4N/AMatch nfsvers to what the server supports
--label key=value--label backup=nightlyNoneUse for filtering in backup scripts
docker system df -vN/ASummary only without -vPer-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 ls and 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: true with 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-owner with GNU tar for 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-common is installed.
  • Repeat the manual mount test 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 rerun docker 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=nightly turns your backup script into a filter instead of a hard-coded list.
  • Mount the source volume readonly in backup jobs. A typo in a backup command then can’t damage live data.
  • Pin helper images in automation. Use alpine:3 rather than alpine, 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.

StepActionApplies To
2docker volume create / inspectAll volumes
3Use --mount in scriptsProduction, automation
4–5Helper-container tar backup and restoreAny named volume
6Stream over SSH to a new hostServer migrations
7id + chown -R via helperPostgres, Redis, Nextcloud, non-root images
8local driver with NFS optionsShared or centralized storage
9readonly / :roConfig, certs, static content
10docker system df -vDisk audits and cleanup
11Checksum diffVerifying every restore

Resources

Sources