New features: - init command: inject default x-backup config into compose files - --dir flag: scan a custom directory for stacks - --to flag: override restore target path Bugfixes: - resolve_docker_volume: no longer double-prefixes volume names - metadata has_volumes now correctly tracks volume state - warn_named_volumes message now accurate about backup-volumes setting Output formatting: - Terminal colors via tput (auto-disabled on non-TTY) - Unicode symbols: ✓ ● ℹ ⚠ ✗ ◇ - Section headers with ━━━ separators - Colored verify results (green OK, red FAIL, yellow SKIP) - Cleaner interactive menus with colored prompts Progress indicators (no dependencies): - tar --checkpoint shows live byte progress during backup/restore - Graceful fallback when checkpoint unavailable or non-TTY
Docker Compose Stack Backup & Restore
A bash script for backing up and restoring Docker Compose stacks with full permission preservation, container lifecycle management, and running-stack discovery.
By default, --all discovers stacks relative to the script's location (so drop the script in your stacks directory or add it to PATH and cd to your stacks). Backups go into $PWD/backups/. External stacks anywhere on the filesystem are discoverable via --running or explicit --stack /path.
How It Works
A stack is any directory containing a compose file (compose.yaml, compose.yml, docker-compose.yaml, or docker-compose.yml).
Backup Flow
1. Resolve target stacks (--all, --running, or --stack)
2. For each stack:
a. Elevate to root via sudo (if not already)
b. Load x-backup config from compose.yaml
c. Run pre-backup hook (if configured)
d. If x-backup.stop != false → `docker compose down`
e. Dump named Docker volumes into _volumes/ (unless opted out)
f. Create a timestamped tar.gz archive:
tar -czf <backup-dir>/<stack>_<timestamp>.tar.gz \
--same-permissions --preserve-permissions \
[--exclude=<path>...] \
-C <archive-root> <stack-name>
g. Write .meta file (path, sha256, size, volumes flag)
h. Run post-backup hook (if configured)
i. Apply retention (prune old backups if configured)
j. If was stopped → `docker compose up -d`
The archive contains the entire stack directory — compose file, .env file, and all data subdirectories (bind mounts). Permissions and ownership are preserved via tar --same-permissions --preserve-permissions, which is critical for bind mounts that run as non-root UIDs inside containers. Named Docker volumes are also dumped by default (see backup-volumes).
Restore Flow
1. Resolve target stacks (same logic as backup)
2. For each stack:
a. Find the most recent backup matching <stack>_*.tar.gz
b. Remove any stale pre-restore directories from previous failed restores
c. Load x-backup config from the backup archive
d. `docker compose down` (always — we're about to replace the directory)
e. Run pre-restore hook (if configured)
f. If stack directory exists → rename to <stack>.pre-restore-<timestamp>
g. Extract backup:
tar -xzf <backup-file> \
--same-permissions --preserve-permissions \
-C <stacks-dir>
h. Remove the pre-restore copy (backup archive is the authoritative source)
i. Restore named Docker volumes from _volumes/ in the archive
j. Run post-restore hook (if configured)
k. `docker compose up -d` (only if the stack was running before restore)
Discovery
The script finds stacks in three ways:
| Mode | What it finds |
|---|---|
--all |
Every subdirectory of the script's directory containing a compose file |
--running |
All running compose projects anywhere on the filesystem by reading ConfigFiles from docker compose ls --format json |
--stack <spec> |
A specific stack by name (relative to the current directory) or by absolute path |
Add --dir <path> to any command to override the default scan directory (script dir for backup/restore, current dir for init).
External Stacks
When --running discovers a compose project living outside the stacks directory, the script uses its absolute path directly. During backup, it archives from the correct parent directory so the stack's directory structure is preserved. Restore writes back to the same absolute path.
You can also explicitly target external stacks by passing an absolute path to --stack:
./docker-stack-backup.sh backup --stack /srv/docker/nextcloud
This is useful for ad-hoc backups of stacks that aren't under your main stacks directory.
Usage
Init
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or specify a directory to scan (no cd needed)
./docker-stack-backup.sh init --dir /mnt/data/stacks
# Or target specific stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack random_stack
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
./docker-stack-backup.sh init -s stack_a -s stack_b
./docker-stack-backup.sh init --dry-run --verbose # preview changes
Backup
# Interactive menu — shows all available stacks with running status
./docker-stack-backup.sh backup
# All stacks found in the script's directory (drop it in your stacks folder)
./docker-stack-backup.sh backup --all
# Only stacks currently running in Docker (wherever they live)
./docker-stack-backup.sh backup --running
# Specific stacks (by name or absolute path)
./docker-stack-backup.sh backup --stack random_stack
./docker-stack-backup.sh backup --stack /srv/docker/nextcloud
./docker-stack-backup.sh backup -s stack_a -s stack_b
./docker-stack-backup.sh backup --stack stack_a,stack_b
# Scan a specific directory for stacks instead of the script's directory
./docker-stack-backup.sh backup --dir /srv/docker --all
# With a custom backup destination
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
# Dry run to see what would happen
./docker-stack-backup.sh backup --all --dry-run --verbose
Running backup without flags opens an interactive menu:
Available stacks:
-----------------
1) external_stack (running, /tmp/external_stack)
2) random_stack (running)
all) Back up all stacks
q) Cancel
Select stacks to back up (numbers, 'all', or 'q'):
After selecting stacks, the script re-executes via sudo with your selections as --stack flags so root elevation happens only when needed. Local stacks and running stacks discovered elsewhere are listed together. Select by index, ranges (1-3), or all.
Restore
# Interactive menu — shows all available backups with readable dates
./docker-stack-backup.sh restore
# Restore a specific stack from its latest backup
./docker-stack-backup.sh restore --stack random_stack
# Restore an external stack by path
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
# Restore to a different location (overrides the original path)
./docker-stack-backup.sh restore --stack random_stack --to /new/location/random_stack
# Restore from a specific backup directory
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
Running restore without --stack opens an interactive menu:
Available backups:
------------------
1) external_stack 2026-07-11 19:49:49
2) random_stack 2026-07-11 19:49:48
all) Restore all stacks
q) Cancel
Select stacks to restore (numbers, 'all', or 'q'):
After selecting backups, the script re-executes via sudo with the resolved stack paths so the actual restore runs as root. You can enter space-separated numbers, ranges like 1-3, or all. Stacks that were originally external (backed up from an absolute path) use a stored .meta file to restore to the correct location automatically.
When restoring over an existing stack directory, the current directory is briefly renamed to <stack>.pre-restore-<timestamp> as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically.
Init (Inject Default Config)
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or target running/external stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
Scans each compose file (relative to the current directory by default, or use --dir to point elsewhere) and injects a default x-backup block at the top:
x-backup:
stop: true
backup-volumes: true
# pre-hook: ""
# post-hook: ""
# pre-restore-hook: ""
# post-restore-hook: ""
# exclude:
# - path/to/exclude
# retention: 7
Active defaults (stop: true, backup-volumes: true) are uncommented. Optional fields are commented out as a reference — uncomment and adjust as needed.
If the compose file already has an x-backup: block, it is removed first so the result is always a clean standard config. This makes init idempotent and safe to re-run at any time.
Does not require root by default — reads and writes compose files as the current user. If a compose file is owned by another user (e.g. root), the script automatically escalates with sudo for the write.
Verify
# Check integrity of all backups
./docker-stack-backup.sh verify
# Check a specific stack's backups
./docker-stack-backup.sh verify --stack random_stack
Each backup has a SHA256 hash stored in its .meta file. The verify command recalculates it and reports any corruption:
OK random_stack_20260711_204621.tar.gz (4.0K)
FAIL badstack_20260711_120000.tar.gz (sha256 mismatch)
Does not require root — read-only operation.
List Stacks & Backups
./docker-stack-backup.sh list
Shows all discovered stacks — local ones and running ones found elsewhere — with their x-backup config tags. Backups display a truncated SHA256 hash, original path for external stacks, and a [volumes] tag if named volumes were included.
=== Stacks ===
- random_stack (running) [x-backup: pre-hook post-hook exclude retention=7]
- nextcloud (/srv/docker/nextcloud) (running)
=== Backups ===
random_stack_20260711_204621.tar.gz (4.0K) -> random_stack sha256:e3b0c44298fc..
nextcloud_20260711_193245.tar.gz (128M) -> nextcloud sha256:1a2b3c4d5e6f.. [volumes]
External stacks show their full path in parentheses.
x-backup Extensions
You can control per-stack backup behavior by adding an x-backup top-level key to your compose.yaml. This follows Docker Compose's extension mechanism.
services:
app:
image: nginx
db:
image: postgres:16
x-backup:
stop: false
exclude:
- app_data/cache
- db_data/wal_archive
pre-hook: pg_dumpall -U postgres > /tmp/db_dump.sql
post-hook: rm -f /tmp/db_dump.sql
pre-restore-hook: echo "About to restore..."
post-restore-hook: echo "Restore complete"
retention: 14
Configuration Options
| Key | Type | Default | Description |
|---|---|---|---|
stop |
boolean | true |
Stop containers during backup. Set to false for zero-downtime backups of databases that handle snapshots internally. |
exclude |
list | — | Paths to exclude from the archive, relative to the stack directory. Useful for large cache directories, temp files, or generated data. |
pre-hook |
string | — | Shell command run before the backup archive is created (after container stop). |
post-hook |
string | — | Shell command run after the backup archive is created (before container start). |
pre-restore-hook |
string | — | Shell command run before a restore (after container stop). |
post-restore-hook |
string | — | Shell command run after a restore (before container start). |
retention |
integer | — | Maximum number of backups to keep. Older backups are pruned after each successful backup. |
backup-volumes |
boolean | true |
Dump named Docker volumes alongside the file backup. Set to false to skip. Volumes are archived into a _volumes/ directory inside the archive and restored automatically. |
Hook Examples
Database-safe backup — dump the database before tarring, then clean up:
x-backup:
pre-hook: docker exec -t $(docker ps --filter name=db -q) pg_dumpall -U postgres > /tmp/db.sql
post-hook: rm -f /tmp/db.sql
Notification on completion:
x-backup:
post-hook: curl -fsS -m 10 --retry 3 -o /dev/null "https://hc-ping.com/my-uuid"
Named Docker Volumes (backup-volumes)
Compose files can define named volumes — data stored inside Docker's storage area rather than on the host filesystem. By default the script detects them from your compose file and dumps them into _volumes/ inside the archive.
Variable substitution in volume paths (${VAR:-/default}:/container/path) is handled correctly — parameterised bind mounts are not mistaken for named volumes.
To skip named volumes (e.g. they're ephemeral or backed up separately), set x-backup.backup-volumes: false:
services:
app:
image: nginx
volumes:
- ./app_data:/app/data # bind mount — always backed up
- app_cache:/app/cache # named volume — skipped if opted out
volumes:
app_cache:
x-backup:
backup-volumes: false
When opted out, a warning is shown listing the skipped volumes:
[!] Named volumes detected:
- app_cache
- pgdata
These store data inside Docker's storage area, not on the host filesystem.
Set 'x-backup.backup-volumes: false' to skip them.
When enabled (default), each named volume is dumped via docker run ... alpine tar into a _volumes/ directory inside the backup archive:
random_stack_20260711_204621.tar.gz
random_stack/
compose.yaml
...
_volumes/
app_cache.tar.gz
pgdata.tar.gz
During restore, the volumes are recreated and their data is extracted back. The _volumes/ directory is automatically removed after restore.
Metadata Files
Each backup archive has an accompanying .meta file with key-value metadata for identification and verification:
stack=random_stack
path=/home/jeremy/Projects/docker/random_stack
date=20260711_204621
compose=compose.yaml
volumes=true
sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
size=4096
| Field | Description |
|---|---|
stack |
Stack name (directory basename) |
path |
Original full path — used by interactive restore to place files correctly |
date |
Backup timestamp (YYYYMMDD_HHMMSS) |
compose |
Compose filename found in the stack |
volumes |
Whether named Docker volumes were included |
sha256 |
SHA256 hex digest of the archive file |
size |
Archive size in bytes |
The verify command reads sha256 and size to check integrity. Legacy .meta files (just a bare path string) are still supported.
Detecting x-backup Config in list
Running list shows active x-backup settings for each stack:
=== Stacks ===
- random_stack (running) [x-backup: no-stop pre-hook post-hook exclude retention=7]
Non-default settings (anything other than stop: true) are displayed as tags.
Directory Layout
Stack discovery (--all) looks relative to the script's location. Backups default to $PWD/backups/ so archives land where you ran the script, not where the script lives. Use --backup-dir to override.
/data/stacks/ # put the script here, or cd here and use it from PATH
├── random_stack/ # A compose project
│ ├── compose.yaml
│ ├── .env
│ ├── app_data/ # Bind-mounted volume dir
│ └── db_data/ # Bind-mounted volume dir
├── nextcloud/ # Another compose project
│ ├── compose.yaml
│ └── ...
└── backups/ # Created automatically in $PWD
├── random_stack_20260711_191731.tar.gz
└── nextcloud_20260711_193245.tar.gz
/srv/docker/
└── grafana/ # External running stack (discovered via --running
├── compose.yaml # or targeted with --stack /srv/docker/grafana)
├── .env
└── data/
Running in a Container (Cron)
The script is designed to be trivially containerized. Run the container as root (or use user: root) to preserve file ownership.
Example Docker Compose entry:
services:
stack-backup:
image: alpine:latest
user: root
entrypoint: |
sh -c "
apk add --no-cache docker-cli tar &&
/data/docker-stack-backup.sh backup --running --backup-dir /backups
"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /path/to/stacks:/data:ro # stacks dir for discovery
- /path/to/backups:/backups
# Mount any external stack directories that --running may discover:
- /srv/docker:/srv/docker:ro
restart: no
Or as a cron job on the host (in root's crontab, or use sudo):
0 3 * * * /path/to/stacks/docker-stack-backup.sh backup --running >> /var/log/stack-backup.log 2>&1
Requirements
-
Root access — backup and restore require root because
tar --same-permissions --preserve-permissionsonly preserves UID/GID ownership when running as root. The script automatically re-executes viasudowhen needed. Read-only commands (list,verify, and the interactive menus) run without elevation. Theinitcommand also runs without root by default, but auto-escalates withsudoif the compose file isn't writable by the current user.When run via
sudo, the backup archives (.tar.gzand.metafiles) are automaticallychowned back to the original user so you can list, copy, or delete them without root. -
bash (no bashisms beyond
set -euo pipefailand arrays) -
tar (for archive creation/extraction)
-
docker (for compose lifecycle —
docker compose)
If Docker is not available or not running, the script will still back up and restore files; it simply skips the container stop/start steps with a warning.