Guide

Docker volume backups you can actually restore

docker commit doesn't save your volumes, and copying /var/lib/docker while a database is running gives you a corrupt file. Build a Docker volume backup pipeline with verified restores.

8 min readBackup Data

Two beliefs cost people their data more often than anything else in Docker, and both sound reasonable.

The first: docker commit saves my container, so my data is safe. It doesn't. commit captures the container's filesystem layers and explicitly excludes anything mounted as a volume — which is exactly where your database lives. Committing a Postgres container gives you a perfect image of Postgres with none of your rows in it.

The second: I'll just tar up /var/lib/docker/volumes. You can, and for a directory of static files it's fine. Do it to a running database and you get a torn copy — files captured mid-write, at different instants, with the write-ahead log out of step with the data files. It'll tar without complaint. It'll restore into a database that won't start.

This guide builds a Docker backup pipeline where the restore is part of the loop:

  1. Work out what actually needs backing up
  2. Get a consistent copy (different rules for databases)
  3. Ship it off-box: encrypted, deduplicated, versioned
  4. Prove it restores
  5. Retention and scheduling

1. Find out what you actually have

docker volume ls
docker ps --format '{{.Names}}' | xargs -I{} docker inspect -f \
  '{{.Name}}: {{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} {{end}}' {}

That second command is the one worth running. It prints every mount on every running container and separates two things people conflate:

  • Named volumes — managed by Docker, living under /var/lib/docker/volumes/<name>/_data. Invisible unless you go looking, which is why they get forgotten.
  • Bind mounts — ordinary host directories. Easy to back up with any normal tool, since they're just paths.

Anonymous volumes (no name, random hex ID) are the trap: they're created implicitly when an image declares VOLUME and compose doesn't map it, they hold real data, and they're discarded by docker compose down -v without ceremony.

2. Get a consistent copy

For databases, dump — don't copy files. A logical dump is a consistent, portable, restorable artifact. A file copy of a live database is neither:

mkdir -p ./docker-backup/dumps

# Postgres in a container:
docker exec -t postgres_container \
  pg_dump --format=custom --username=postgres app_db \
  > ./docker-backup/dumps/app_db.dump

# MySQL / MariaDB:
docker exec -t mysql_container \
  mysqldump --single-transaction --user=root --password="$MYSQL_ROOT_PASSWORD" app_db \
  > ./docker-backup/dumps/app_db.sql

--single-transaction on MySQL takes the dump inside one consistent transaction rather than locking tables — that's what makes it safe against a live server on InnoDB.

Our Postgres and MySQL guides go deeper on the database side.

For everything else — tar the volume out through a helper container. This is the canonical pattern, and it works without knowing where Docker stores anything:

docker run --rm \
  -v my_app_data:/data:ro \
  -v "$PWD/docker-backup/volumes":/backup \
  alpine \
  tar cf /backup/my_app_data.tar -C /data .

A throwaway alpine mounts the volume read-only, mounts a host directory, and tars one into the other. No root access to /var/lib/docker, no assumptions about the storage driver, works identically on Docker Desktop where that path isn't even on your machine.

Use uncompressed tar, not tar.gz. This looks like a mistake and isn't. Gzip output changes completely after the first altered byte, so a compressed archive of a barely-changed volume looks entirely new to any deduplicating backup system — you'd upload the whole thing every night. Plain tar keeps unchanged bytes in the same places, so content-defined chunking recognises them and ships only the delta. The service compresses on its own; let it.

For apps that can't be quiesced, stop the container. A few seconds of downtime buys a copy you can trust:

docker stop my_app
docker run --rm -v my_app_data:/data:ro -v "$PWD/docker-backup/volumes":/backup \
  alpine tar cf /backup/my_app_data.tar -C /data .
docker start my_app

To sweep every volume in a compose project:

for vol in $(docker volume ls -q --filter label=com.docker.compose.project=myproject); do
  docker run --rm -v "$vol":/data:ro -v "$PWD/docker-backup/volumes":/backup \
    alpine tar cf "/backup/${vol}.tar" -C /data .
done

And back up the config too — docker-compose.yml, .env, and any bind-mounted config directories. Restoring data into a stack you can't reproduce is a bad afternoon:

cp docker-compose.yml .env ./docker-backup/

3. Ship it off-box

Backups on the same host as the containers die with the host. Push them somewhere else.

Sign in at Backup Data, claim the free 5 GB workspace, mint an API key scoped backup:write, backup:read, snapshots:read, then:

npm install @lighthouse-web3/baas-js-sdk
export LH_API_KEY="lh_xxxxxxxxxxxxxxxxxxxxxxxx"
export LH_WORKSPACE_ID="your-workspace-uuid"
import { BackupClient } from "@lighthouse-web3/baas-js-sdk";

const client = new BackupClient({
  apiKey: process.env.LH_API_KEY,
  workspaceId: process.env.LH_WORKSPACE_ID,
});

const snapshot = await client.backup(["./docker-backup"], {
  description: "nightly docker stack backup",
  tags: { type: "docker", stack: "myproject", host: "app-01" },
});

console.log(`snapshotId=${snapshot.snapshotId} totalSize=${snapshot.totalSize}`);

(A Go SDK offers the same surface.)

Every run is an immutable point-in-time snapshot. Because uploads are content-addressed and chunked with FastCDC, and because you used uncompressed tar, a 10 GB volume that changed slightly overnight uploads megabytes rather than gigabytes.

Volumes hold credentials far more often than people expect — app config, .env files, database contents. Encrypt client-side:

import { generateKeyfile } from "@lighthouse-web3/baas-js-sdk";

// once:
generateKeyfile("/secure/lh.keyfile", process.env.LH_KEYFILE_PASSPHRASE);

// then on every backup:
await client.backup(["./docker-backup"], {
  description: "nightly-encrypted",
  encryption: {
    keyfilePath: "/secure/lh.keyfile",
    passphrase: process.env.LH_KEYFILE_PASSPHRASE,
  },
});

AES-GCM on your machine; the server stores only ciphertext. You hold the keys — lose the keyfile and passphrase and the data is gone for good, with no server-side reset. Keep the keyfile off the host you're backing up, or a dead server takes the key with it.

4. Prove it restores

Restore into a scratch directory and bring the stack up from nothing but the backup:

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const target = mkdtempSync(join(tmpdir(), "docker-restore-drill-"));
await client.restore(snapshot.snapshotId, target, {
  onProgress: (e) => console.log(`[${e.phase}] ${e.current}/${e.total}`),
});

Every restore verifies chunk integrity against expected checksums before reassembly, so corruption surfaces here rather than during an incident.

Load a tar back into a fresh volume — the mirror image of the backup command:

docker volume create my_app_data_restore

docker run --rm \
  -v my_app_data_restore:/data \
  -v "$TARGET/docker-backup/volumes":/backup:ro \
  alpine \
  sh -c "cd /data && tar xf /backup/my_app_data.tar"

Restore a database dump into a throwaway container:

docker run -d --name pg_restore_test -e POSTGRES_PASSWORD=test postgres:16
sleep 5
docker exec -i pg_restore_test createdb -U postgres app_db_restore
docker exec -i pg_restore_test pg_restore -U postgres -d app_db_restore \
  < "$TARGET/docker-backup/dumps/app_db.dump"
docker exec -i pg_restore_test psql -U postgres -d app_db_restore \
  -c "SELECT count(*) FROM users;"

Then start the app against the restored volume and actually load a page. A volume that mounts is not the same as an application that works — file ownership is the usual culprit, because the UID inside the container has to match the UID that owns the restored files. Tar preserves numeric ownership when run as root, which is why the helper-container pattern above matters more than it looks.

Time the whole thing. That's your real RTO.

The drill checklist:

  • ✅ Restore into fresh volume names, never over live ones
  • ✅ Rebuild the stack from the backed-up docker-compose.yml, not the one on disk
  • ✅ Start the app and load a real page — mounting isn't working
  • ✅ Check file ownership inside the container if the app won't start
  • ✅ Confirm every named volume came back, including the anonymous ones you found in step 1
  • ✅ Record wall-clock time, keep a log across drills
  • docker rm -f the test containers and docker volume rm the test volumes when done

5. Retention and scheduling

const before = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();

// sourceId identifies the machine and path a backup came from; every snapshot
// carries it. Retention is currently applied across the whole workspace, so
// keepLatest counts every snapshot in it, not just this source's.
const policy = { sourceId: snapshot.sourceId, keepLatest: 14, before };

const preview = await client.pruneSnapshots({ ...policy, dryRun: true });
console.log("would delete:", preview.snapshotIds);

await client.pruneSnapshots({ ...policy, dryRun: false });
# /etc/cron.d/docker-backup (nightly at 02:45)
45 2 * * * root /opt/backup/docker-backup.sh >> /var/log/docker-backup.log 2>&1

Where docker-backup.sh is the dump-and-tar from step 2 followed by node upload.mjs. Monthly, run the drill.

The pipeline, complete

StepCommand / callProves
Inventorydocker inspect mountsYou know what you'd lose
Databasesdocker exec pg_dumpThe copy is consistent
Volumeshelper container + tarYou captured the data, not the image
Configdocker-compose.yml, .envYou can rebuild the stack
Off-boxclient.backup([...])It survives losing the host
Restore drillfresh volume + tar x + start the appIt actually restores
Retentionclient.pruneSnapshots({...})Storage stays bounded

The gap between most Docker backup scripts and this one isn't the tar command. It's that the volumes were found rather than assumed, the databases were dumped rather than copied, the config came along, and someone has actually watched it come back.

Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.

Start with the free tier

5 GB free, no card required. Point your existing dump at Backup Data and get to a first verified snapshot in about ten minutes.

Read the quickstart

Keep reading

Guide · 7 minAutomating backups with GitHub Actions (and when not to)Guide · 6 minBack up model checkpoints before your spot instance disappearsGuide · 7 minBacking up a Linux server without backing up the whole disk