The short answer
A Docker backup you have never restored is a guess. The only way to convert it into a fact is to plant a marker you can verify, export each service the way that service expects, restore somewhere temporary, and compare. On our test stack that whole verification took 7 seconds of wall-clock execution and produced three numbers that matter: nginx ready in 0.660 s, PostgreSQL in 0.833 s, Redis in 2.042 s — all with their persistent data intact.
This guide is the drill we actually ran on a Beelink EQi12 (Intel Core i3-1215U) hosting a three-service Compose stack, with the raw evidence published in our measurement data repository under docker/EQi12_Recovery_*. Every number below comes from those files. It also includes the single most instructive failure of the test: a Redis restore that looked like data loss and was really one extra byte added by a text pipeline.
If you already have a stack running, start at Step 1. If you are still assembling one, the Docker Compose home server stack guide builds the nginx, PostgreSQL and Redis stack these commands are written against.
Why a backup you have never restored is only an assumption
Volume backups fail in ways that produce no error at backup time. The four we see most often on home servers:
- The export ran against the wrong database. A
pg_dumpagainst the defaultpostgresdatabase succeeds and produces a file that restores cleanly — into a database that was never in use. - The binary payload was altered in transport. A shell pipeline that treats bytes as text silently appends or translates characters. The restore then fails, or worse, restores something subtly wrong.
- The instruction set was never backed up. The data survived; the compose file,
.envand mounted configs lived only on the host that failed. - The backup landed on the same device it was protecting. A second copy on the same NVMe drive is not a backup when that drive dies.
None of these produce a nonzero exit code at backup time. All of them are caught by a restore drill, which is why the drill — not the backup job — is the unit of work that matters.
How much space will this need? Size the destination with the backup retention and window planner, then check whether the nightly job finishes inside your window using the network transfer time calculator. If you are building a dedicated backup pool, the RAID and ZFS capacity calculator handles the fault-tolerance arithmetic.
The stack and the measurements behind this drill
Three containers, one host, one verification pass. This is the exact configuration, recorded in docker/EQi12_DockerWebStack_00_CONTAINER_BASELINE.txt:
| Container | Image | Published port | Health at test time |
|---|---|---|---|
eqi12-docker-test-database-1 | postgres:17-alpine | 5432/tcp (internal) | Up, healthy |
eqi12-docker-test-web-1 | nginx:alpine | 18080 → 80 | Up, healthy |
eqi12-docker-test-cache-1 | redis:8-alpine | 6379/tcp (internal) | Up, healthy |
Host: Beelink EQi12, Intel Core i3-1215U, Docker on Windows with WSL2. Test window: 2026-07-13, 09:22:09 → 09:22:16.
The drill answers four separate questions, and it is worth keeping them separate because they fail independently:
| Question | Method | Pass criterion |
|---|---|---|
| Does data survive a container restart? | Restart each container, read the marker back | Marker present |
| How long until the service is ready? | Time from restart command to readiness | Recorded, not judged |
| Can the export be restored? | Restore into a temporary database | Restored marker matches |
| Is the configuration reproducible? | Export and re-read config | Non-zero, complete file |
Step 1: Plant a marker you can verify later
A restore test needs something unique to compare. Write a random token into each stateful service and record it. If the token comes back after the restore, the restore worked. If it does not, nothing else in the test matters.
# PostgreSQL: a one-row marker table
docker compose exec -T database psql -U app -d app <<'SQL'
CREATE TABLE IF NOT EXISTS drill_marker (id serial PRIMARY KEY, token text NOT NULL, created_at timestamptz DEFAULT now());
INSERT INTO drill_marker (token) VALUES ('EQI12-DRILL-20260713');
SQL
# Redis: a marker key in a dedicated database
docker compose exec -T cache redis-cli -n 0 SET drill:marker EQI12-DRILL-20260713
# Record what you expect to see later
docker compose exec -T database psql -U app -d app -tAc 'SELECT token FROM drill_marker ORDER BY id DESC LIMIT 1;'
docker compose exec -T cache redis-cli -n 0 GET drill:markerUse a fresh token for every drill. If you reuse the same string, an old backup that happens to contain it will pass a test it should fail.
Step 2: Export each stateful service the way it expects
Do not copy /var/lib/docker/volumes and call it a backup. Copying live database files mid-write gives you an artifact that may restore into a corrupt database. Use each service’s own export tool — it exists precisely because it knows how to produce a consistent snapshot while running.
# PostgreSQL: logical dump (works while the container is running)
docker compose exec -T database pg_dump -U app app > backup-postgres-$(date +%Y%m%d).sql
# Redis: force a snapshot, then copy the RDB out of the volume
docker compose exec -T cache redis-cli -n 0 BGSAVE
docker compose exec -T cache redis-cli -n 0 LASTSAVE # wait until this timestamp advances
docker compose cp cache:/data/dump.rdb ./backup-redis-$(date +%Y%m%d).rdb
# nginx: export the served configuration
docker compose exec -T web cat /etc/nginx/nginx.conf > backup-nginx-$(date +%Y%m%d).conf
# Compose file and environment (the part everyone forgets)
cp docker-compose.yml backup-compose-$(date +%Y%m%d).yml
cp .env backup-env-$(date +%Y%m%d)Our measured exports were tiny because the drill database held one marker row — 1,344 bytes for the PostgreSQL dump, 82 bytes for the Redis marker payload and 7,546 bytes for the nginx configuration (docker/EQi12_Recovery_05_BACKUP_RESTORE_RESULT.txt). The sizes are irrelevant; the completeness is not. A dump file that exists but contains no marker is the failure mode that a size check will never catch.
For the Redis marker specifically, we also captured a binary DUMP for a byte-exact restore test. That is where the interesting failure happened — see Step 4.
Step 3: Restart the containers and time the recovery
Restarting is not the same as restoring, but it answers the question you will actually be asked at 2 a.m.: if this container dies, does the data survive, and how long until it serves traffic again?
for svc in web database cache; do
start=$(date +%s.%N)
docker compose restart "$svc" >/dev/null
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q "$svc")")" = "healthy" ]; do sleep 0.1; done
end=$(date +%s.%N)
echo "$svc ready in $(echo "$end - $start" | bc) s"
doneMeasured result (docker/EQi12_Recovery_04_RECOVERY_RESULTS.csv):
| Service | Ready after restart | Persistence | Result |
|---|---|---|---|
| nginx | 0.660 s | Configuration reloaded | Pass |
| PostgreSQL | 0.833 s | Marker row present | Pass |
| Redis | 2.042 s | Marker key present | Pass |
Two things to read from this table. First, sub-second does not mean “safe to skip verification” — it means the container was ready, which says nothing about whether the data inside it is the data you backed up. Second, Redis taking roughly 2.5× longer than PostgreSQL to report ready is expected: it loads the RDB from disk during startup, so a larger dataset moves that number.
If a restart does not bring a container back at all rather than bringing it back slowly, you have a different problem. Work through the layered diagnosis in Docker containers missing after a Windows reboot before you touch the restore path.

Step 4: Restore into a temporary database and compare
Never restore over your live database to test a backup. Restore into a temporary database, compare, then drop it.
# Create a throwaway database
docker compose exec -T database psql -U app -d postgres -c 'CREATE DATABASE restore_check;'
# Restore the dump into it
docker compose exec -T database psql -U app -d restore_check < backup-postgres-20260713.sql
# Compare against the token you recorded in Step 1
docker compose exec -T database psql -U app -d restore_check -tAc 'SELECT token FROM drill_marker ORDER BY id DESC LIMIT 1;'
# Clean up
docker compose exec -T database psql -U app -d postgres -c 'DROP DATABASE restore_check;'The expected output is the exact token you planted. Anything else — an empty result, a different value, a connection error — means the backup is not a backup yet.
Our drill ran this comparison and verified two more things before declaring success: the temporary PostgreSQL database count returned to 0 and the temporary Redis database (db15) key count returned to 0. Leftover test artifacts are how a clean drill turns into a confusing production state three months later.
The failure that looks like data loss: one extra byte
The first Redis restore attempt in our test failed. The command returned an error, the marker did not come back, and the raw log looks exactly like corruption.
It was not. Here is the corrected conclusion, verbatim from docker/EQi12_Recovery_08_CORRECTED_CONCLUSION.txt:
Redis first binary DUMP/RESTORE attempt: INVALID. PowerShell’s text pipeline appended one newline byte to the binary payload, so Redis rejected the altered payload. This is a test-harness transport error, not data loss.
The sequence was: redis-cli --no-raw DUMP key piped through a PowerShell pipeline, then fed back into RESTORE. PowerShell’s pipeline treats the output as text and normalised the line ending, adding a single 0x0A byte. Redis validates the payload checksum, saw a mismatch, and refused it — correctly.
The corrected, binary-safe attempt passed: RESTORE returned OK and the marker matched. Both attempts remain in the raw evidence, and the invalid one is explicitly not reported as a Redis failure.
Two practical rules come out of this:
- Treat dumps as bytes, not text. Redirect to a file (
> dump.bin) rather than through a text pipeline, or base64-encode for transport and decode at restore time. - Keep failed attempts in the evidence. An audit trail that only contains passes is not an audit trail. The failed attempt is the reason the next person will not misdiagnose the same error.
What recovery actually costs you: measured reboot timings
A restore is the slow path. The fast path is the container coming back on its own after an unattended reboot — and knowing how long that takes tells you whether your monitoring will page you unnecessarily.
Three consecutive reboot cycles on the same host (docker/EQi12_DockerAutostart_3reboots.log, 2026-07-12):
| Cycle | Network reachable | Application container serving HTTP |
|---|---|---|
| 1 | 85.77 s | 206.41 s |
| 2 | 45.68 s | 166.40 s |
| 3 | 136.57 s | 257.36 s |
The spread is the finding. A three-fold difference between the fastest and slowest cycle means any health check with a fixed timeout tuned to the best case will fire false alarms. Budget for the worst observed case — roughly 4.3 minutes here — and alarm beyond it.
Note the two orders of magnitude between these numbers and Step 3: a container restart inside a running host is sub-second to ~2 s, while a full boot-to-serving cycle is 2.5–4.3 minutes. The difference is Windows login, WSL2 initialisation and Docker Desktop startup, all of which are covered in the Docker Desktop auto-start guide. If the power is the thing that failed rather than a planned reboot, the AC power recovery guide covers State After G3 and the three-cycle power-loss test.
A 15-minute drill you can repeat monthly
Run this once, then put it on a monthly calendar.
- Minute 0–2 — Write a fresh, unique token into every stateful service. Record it outside the server.
- Minute 2–5 — Export each service with its own tool (
pg_dump,BGSAVE+ RDB copy, config export). Copy the compose file and.env. - Minute 5–7 — Verify each export is non-empty and contains the token. Size alone proves nothing.
- Minute 7–9 — Restart each container, time the return to healthy, confirm the token survived.
- Minute 9–13 — Restore each export into a temporary location and compare the token.
- Minute 13–14 — Delete every temporary database, key and file created during the drill.
- Minute 14–15 — Copy the exports to a second device. Record the date and result.
That last minute is the one people skip, and it is the one that decides whether the drill was worth running. Our storage measurements for the destination side are in the SSD and USB port performance evidence, and if your backup drive reports suspiciously slow sustained writes, work through USB SSD stuck at 40 MB/s before you blame the backup job.
Decide what each service needs backed up
Not every container holds state, and treating them all the same wastes space and attention. Classify first:
| Service type | Examples | What to back up | What you can rebuild |
|---|---|---|---|
| Database | PostgreSQL, MariaDB, InfluxDB | Logical dump via the engine’s own tool | Image, config, empty schema |
| Cache / queue | Redis, RabbitMQ | Snapshot only if contents are irreplaceable; otherwise nothing | Everything from upstream |
| Reverse proxy | nginx, Traefik, Caddy | Config files and certificate material | Binary, default config |
| Application config | Home Assistant, Jellyfin | Configuration directory | Application image |
| Media library | Jellyfin, Plex, Navidrome | Only if not re-rippable; otherwise a catalogue list | The media itself |
The cache row is where most home labs over-backup. Redis held an 82-byte marker in our drill; a real deployment might hold sessions that regenerate on login, or it might hold a job queue that does not. Decide which one you have before you schedule a nightly export.
Mistakes that quietly break Docker backups
- Backing up the volume directory instead of running an application-aware export. Works until it does not, and it never warns you first.
- Reusing the same marker token across drills. An old backup passes a test it should fail.
- Piping binary dumps through a text pipeline. Covered above; it fakes data loss.
- Verifying the file exists instead of verifying it restores. A 1,344-byte dump with no marker in it is still a 1,344-byte dump.
- Keeping the backup on the same physical device. Plan a second destination with the RAID and ZFS capacity calculator rather than assuming the spare port counts.
- Never testing the offsite copy. The copy you have never restored is in the same category as the backup you have never restored.
Frequently asked questions
Do I need to stop containers before backing up Docker volumes?
No, not for an application-aware export. Use the service's own tool — pg_dump for PostgreSQL, BGSAVE or the RDB file for Redis, a config export for nginx. Stopping the stack is only required when copying raw volume directories, because a live database file copied mid-write may not restore.
How often should I test a restore?
Monthly for anything you cannot rebuild from a script, and immediately after changing the stack. The drill takes about 15 minutes for a three-service stack. An untested backup is an assumption, not a backup.
Why did redis-cli RESTORE reject my dump file?
The most common cause is transport, not Redis. Piping binary DUMP output through a text pipeline appends a newline byte and Redis rejects the altered payload. Write the dump to a file and read it back as bytes, or base64-encode for transport and decode on restore.
Is a volume backup enough, or do I also need the compose file?
You need both. The volume holds state; the compose file, .env and any mounted config hold the instructions to rebuild the container that reads that state. Our drill exported 7,546 bytes of nginx configuration alongside the database and cache payloads for exactly this reason.
How long does a Docker stack take to come back after an unattended reboot?
On our test host the network was reachable 45.7 to 136.6 seconds after boot across three cycles, and the application container answered HTTP 166.4 to 257.4 seconds after boot. Individual container restarts inside a running stack were far faster: 0.660 s for nginx, 0.833 s for PostgreSQL, 2.042 s for Redis.
Should I back up the whole /var/lib/docker directory?
No. It mixes images, layers, logs and build cache with your data and produces a large, hard-to-verify artifact. Export the data you care about with each service's own tool, keep the compose file in version control, and let image layers be re-pulled from the registry.
Where the numbers come from
Every measurement in this article is from a single verification pass on 2026-07-13 (recovery) and 2026-07-12 (reboot cycles), published unmodified in the eqi12-measurement-data repository under the docker/ directory:
| File | Contents |
|---|---|
EQi12_Recovery_04_RECOVERY_RESULTS.csv | Per-service restart time and persistence result |
EQi12_Recovery_05_BACKUP_RESTORE_RESULT.txt | Dump sizes and restore-match results |
EQi12_Recovery_08_CORRECTED_CONCLUSION.txt | Interpretation, including the invalid Redis attempt |
EQi12_DockerWebStack_00_CONTAINER_BASELINE.txt | Container images, ports and health |
EQi12_DockerAutostart_3reboots.log | Three reboot cycles with boot-to-network and boot-to-service timings |
The repository is CC BY 4.0. Serial numbers, MAC addresses and host-specific identifiers are stripped from everything published there. Our full measurement methodology explains how each class of test is constructed and why failed attempts are retained.
Ready to build the stack these commands target? Start with the Docker Compose home server stack guide, then size the backup destination with the backup retention and window planner.