The obvious approach — stop Docker, back up /var/lib/docker, restore it — fails
the requirement this tool exists for: restoring onto a freshly installed
machine.
| Problem | Consequence |
|---|---|
| overlay2 layout is tied to the Docker version and storage driver | a restore onto a newer Docker, or one using a different driver, may not start |
systemctl stop docker SIGKILLs containers after a timeout |
PostgreSQL and MySQL come back through crash recovery, which is not the same as a consistent backup |
| the image store is duplicated data | the backup is several times larger for content the registry already has |
compose files live outside /var/lib/docker |
the thing needed to rebuild the stack is not in the backup at all |
So bg-backup backs up what a restore actually needs:
compose files + .env + every resolved env_file
named volume contents
bind-mount sources
image DIGESTS (not tags)
network SUBNETS
logical database dumps
A restore is then: install Docker → restore files → compose pull && up → load
dumps. That works on any host. See
ADR-0004.
Digests, not tags. compose up re-resolves :latest. Without a pinned
digest, a restore silently brings up a different version than the one the data
was written by.
Network subnets. If they are not recreated explicitly, Docker assigns new ones from its address pool, and every firewall rule or ACL that referenced the old subnet stops matching — silently.
An image built locally and never pushed cannot be pulled during a restore.
bg-backup discover warns about each one. JOB_DOCKER_EXPORT_IMAGES=missing
(the default) exports exactly those into the repository via docker save,
streamed through --stdin-from-command so a failing save aborts rather than
storing a truncated tar.
With EXPORT_IMAGES=none those stacks are not restorable, and doctor says
so.
| Mode | Mechanism | Downtime | Consistency |
|---|---|---|---|
none |
live copy | none | none guaranteed |
dump |
logical dump — automatic for detected databases | none | exact (transactional) |
docker-pause |
docker pause only the project being read |
seconds | crash-consistent |
docker-stop |
compose stop for that project |
minutes | clean |
service-stop |
systemctl stop <units> |
minutes | clean |
lvm / btrfs / zfs |
freeze → block snapshot → thaw → back up the snapshot | none | crash-consistent |
Default for the docker job: dumps for databases, docker-pause for the file
part. The dumps run first, while everything is still running — a logical dump
needs a live server and is transactionally consistent in its own right, so it
does not need the freeze window.
The only mode that is both consistent and downtime-free:
fsfreeze -f <mount> flush and block writers - held for MILLISECONDS
lvcreate --snapshot
fsfreeze -u <mount> thaw immediately
mount the snapshot OVER the original path in a PRIVATE mount namespace
restic backup <path>
Two things decide whether this works:
fsfreeze must never be held for the backup's duration. A frozen filesystem
blocks every writer on the machine, so a 40-minute backup becomes a 40-minute
outage — worse than the docker-stop it replaces.
The snapshot is mounted over the original path, so the paths restic stores are
the production paths. Backing up /mnt/snap/var/lib/docker instead would produce
a snapshot whose contents restore to the wrong place, and nobody notices until a
restore.
bg-backup discover reports whether LVM/btrfs/ZFS is available and whether the
volume group has room. An LVM snapshot that runs out of copy-on-write space is
dropped by the kernel mid-backup, and restic then reads I/O errors from a device
that used to work — so the space check happens up front.
See Architecture. The short version: the exit
trap, a state file in /run replayed on the next start, and ExecStopPost= in
the systemd unit — which runs even when the process was SIGKILLed.
restic backup --stdin-from-command --stdin-filename /db/<engine>/<container>/<obj> -- <dump>--stdin-from-command fails the whole backup when the dump command exits
non-zero. The alternative,
docker exec ... pg_dumpall | restic backup --stdin # NEVERstores whatever bytes arrived before the failure as a valid snapshot and reports success.
Dumps are never pre-compressed: restic compresses already, and plain SQL deduplicates across days at content-defined chunk boundaries while a gzip stream changes wholesale after the first differing byte.
No credential ever reaches bg-backup. Each engine reads the password inside the
container from the container's own environment, so it is not in bg-backup's
config, not in argv, and not in /proc/<pid>/cmdline.
Image name first, then confirmation by environment variable or exposed port.
Never the container name — a container called postgres-backup running alpine
is not a database, and treating it as one produces a failing dump every night that
somebody eventually silences.
Override per container:
labels:
backup.bauer-group.com/engine: postgres
backup.bauer-group.com/skip: "true"
backup.bauer-group.com/tier: critical| Engine | Mechanism | Reports degraded when |
|---|---|---|
| PostgreSQL | pg_dumpall --globals-only, then pg_dump -Fc per database |
the database list cannot be read |
| MySQL / MariaDB | --single-transaction --quick --routines --triggers --events --hex-blob |
MyISAM or Aria tables exist |
| MongoDB | mongodump --archive --oplog |
standalone deployment |
| Redis | BGSAVE, wait, then dump.rdb |
— (skipped when it is a pure cache) |
| SQLite | .backup / VACUUM INTO |
a file cannot be read |
| InfluxDB | 1.x influxd backup -portable, 2.x influx backup |
— (3.x refused) |
| ClickHouse | BACKUP DATABASE … TO Disk |
no backup disk configured |
| Elasticsearch | snapshot API into a registered fs repository |
snapshot state PARTIAL |
| MSSQL | BACKUP DATABASE … WITH CHECKSUM, COMPRESSION |
— |
degraded is treated as a failure. "A snapshot exists but its consistency is
not guaranteed" is exactly the silent state this tool exists to remove.
MySQL --single-transaction is InnoDB-only. MyISAM and Aria tables are not
in the transaction, so the dump is internally inconsistent for them. The engine
detects those tables and reports degraded rather than pretending.
MongoDB --oplog needs a replica set. On a standalone deployment the dump is
taken without one, so writes during the dump may be partially captured.
PostgreSQL globals come first. A dump without roles and grants restores a database nobody can log into. They are a separate object, restored before any per-database dump — otherwise the restore GRANTs to roles that do not exist yet, and PostgreSQL reports that as warnings rather than an error, so it looks like it worked.
Exact row counts, for PostgreSQL. The counts used by verify are taken inside
the same exported transaction snapshot the dump used:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_export_snapshot(); -- passed to pg_dump --snapshot=That makes the post-restore comparison a hard equality rather than a tolerance. Other engines capture counts immediately after the dump, with a documented tolerance.
Elasticsearch needs a compose change first. A data-directory copy is not a
usable backup — Lucene mmaps its segments. The snapshot API is the only supported
mechanism and it requires path.repo, which means editing the compose file and
restarting the service before any correct backup is possible. The engine fails
with the exact change needed rather than falling back to something that cannot
restore.
InfluxDB 3.x is refused. It has no logical dump; the correct backup is a snapshot of its object store. Pretending would be worse than an honest error.
If a database's data directory is restored and its dump is loaded, the official entrypoint sees a non-empty data directory, skips initialisation, and the dump is applied on top of live data. The tool refuses the combination.
bg-backup runs list # pick a complete run
bg-backup restore project --name mystack --run <id> # compose files + volumes
bg-backup restore volume --name pg_data --run <id>
bg-backup restore db --db postgres/pg/app.dump --into pg
bg-backup dump <snap> /db/postgres/pg/app.dump | pg_restore -d appRestore by run, not by "latest" per snapshot — see Architecture.
restore volume refuses while a container is using the volume, offers to stop
and restart the consumers, and swaps …/volumes/<name>/_data by rename rather
than renaming the volume object (Docker cannot rename volumes; this keeps the
driver, options and labels intact).