Back Up a Docker Compose Stack (Volumes + Recipe)
One script that tars every named volume in a Compose stack plus its compose file and .env into a timestamped folder, with an optional stop for a clean copy.
bash#!/usr/bin/env bash
# backup-compose-stack.sh — save the irreplaceable parts of one Compose stack.
#
# Backs up: every named volume (tar via a throwaway container, the pattern
# from Docker's own volume docs) + the compose file + .env, into a
# timestamped folder. Images are NOT backed up — the registry has them.
#
# Usage: ./backup-compose-stack.sh # back up, stacks stay running
# ./backup-compose-stack.sh --stop # stop stack during the copy (safest)
set -u
# ==== MAKE THESE VALUES YOUR OWN ==========================================
STACK_DIR="/opt/stacks/uptime-kuma" # folder containing compose.yaml
BACKUP_ROOT="$HOME/stack-backups" # where backups land (ideally another disk)
# ==========================================================================
cd "$STACK_DIR" || { echo "FAIL cannot cd to $STACK_DIR"; exit 1; }
# Project name = how Compose prefixes volumes. Defaults to the folder name,
# lowercased with invalid characters stripped (Compose's own normalization);
# override by exporting PROJECT before running if yours differs (e.g. you
# set COMPOSE_PROJECT_NAME or the `name:` key in compose.yaml).
PROJECT="${PROJECT:-$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')}"
TS=$(date +%Y%m%d-%H%M%S)
DEST="$BACKUP_ROOT/$PROJECT-$TS"
mkdir -p "$DEST" || exit 1
echo "Backing up stack '$PROJECT' -> $DEST"
# Read the volume list up front — if the compose file or daemon is broken,
# fail loudly instead of "successfully" backing up nothing.
VOLS=$(docker compose config --volumes) || { echo "FAIL cannot read compose config"; exit 1; }
STOPPED="no"
if [ "${1:-}" = "--stop" ]; then
echo "Stopping the stack for a clean copy..."
docker compose stop || { echo "FAIL could not stop the stack"; exit 1; }
STOPPED="yes"
fi
# 1) The recipe: compose file(s) + overrides + .env
for f in compose.yaml compose.yml docker-compose.yml docker-compose.yaml \
compose.override.yaml compose.override.yml docker-compose.override.yml .env; do
[ -f "$f" ] && cp -v "$f" "$DEST/"
done
# 2) Every named volume, tarred read-only via a throwaway container.
# The inspect check matters: docker run auto-creates a missing volume, so
# without it a wrong project name would "back up" a fresh empty volume.
FAILED=0
for vol in $VOLS; do
full="${PROJECT}_${vol}"
if ! docker volume inspect "$full" >/dev/null 2>&1; then
echo "FAIL $full not found — wrong project name, or an external/custom-named volume?"
FAILED=$((FAILED+1))
continue
fi
echo "Archiving volume $full ..."
if docker run --rm \
-v "$full":/source:ro \
-v "$DEST":/backup \
busybox tar czf "/backup/volume-$vol.tgz" -C /source .; then
echo "OK volume-$vol.tgz"
else
echo "FAIL $full — tar step failed"
FAILED=$((FAILED+1))
fi
done
if [ "$STOPPED" = "yes" ]; then
echo "Restarting the stack..."
docker compose start
fi
echo
ls -lh "$DEST"
if [ "$FAILED" -gt 0 ]; then
echo "Backup INCOMPLETE: $FAILED volume(s) failed."
exit 1
fi
echo "Backup complete."
What this does
First: make these values your own — the stack folder, backup destination, and volume names in this script are example placeholders (full list in Notes). Edit the marked block at the top before running anything.
A Compose stack is three things: the recipe (compose.yaml + .env), the images (re-pullable anytime from the registry — Docker Hub, usually), and the data in named volumes (the only part you can’t download again). This script saves the parts worth saving: it copies the recipe files and archives every named volume the stack declares into one timestamped folder under your backup root. Volumes are read through a throwaway busybox container with the volume mounted read-only — the same throwaway-container approach Docker’s volume documentation uses — so nothing about the stack is modified. It discovers the volume list with docker compose config --volumes, prefixes each with the project name (how Compose names volumes on disk), and verifies each volume actually exists before archiving — Docker silently creates missing volumes, so a naming mismatch would otherwise produce a convincing backup of nothing.
Run it with --stop to stop the stack for the seconds the tar takes and restart it after. The script exits with an error code if anything fails — a missing volume, a failed tar, an unreadable compose file — so a scheduled cron job, or you before an update, can catch an incomplete backup instead of trusting it.
Prerequisites
- Docker with the Compose v2 plugin (
docker compose versionworks — the space, not the hyphen). - Run as a user in the
dockergroup (or with sudo). - Enough free space at the backup destination for your volumes’ contents.
- The stack’s folder contains its compose file — the script runs
docker composecommands from there.
Notes
-
Placeholders to replace, all in the marked block at the top:
/opt/stacks/uptime-kuma(your stack folder),$HOME/stack-backups(your backup destination), and — if your project name differs from the folder name — exportPROJECTbefore running. Theuptime-kuma/kuma-datanames appearing in examples map to whatever your stack and volumes are called. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy. -
Databases deserve
--stop(or better). Tar-ing a volume while a database writes to it can capture a mid-write state that restores into corruption. Stop the stack for the copy, or use the app’s own dump tool (pg_dump,mysqldump) and back the dump up instead — the dump is the most restorable form of a database. -
External and custom-named volumes (declared
external: trueor with their ownname:) are not project-prefixed, so the script’s existence check flags them asFAILrather than guessing. Archive those by their exact name with the samedocker run --rm -v NAME:/source:ro ...one-liner. -
Folder names with dots or other punctuation: the script mimics Compose’s project-name normalization (lowercase, strip invalid characters), but Compose also trims leading dashes and underscores — if your folder name is exotic, export
PROJECTexplicitly rather than trusting the guess. A wrong project name now fails loudly instead of backing up nothing. -
Bind mounts aren’t covered. A
./configfolder next to the compose file rides along when you copy the stack folder; an absolute-path bind mount like/mnt/mediais host data the script deliberately doesn’t touch — back that up with your host’s backup tool. -
Restore is the mirror image — create the volume under its full name, untar into it, then
docker compose up -d:docker volume create uptime-kuma_kuma-data docker run --rm -v uptime-kuma_kuma-data:/target -v ~/stack-backups:/backup:ro busybox tar xzf /backup/volume-kuma-data.tgz -C /targetThe full walkthrough — including moving a stack to a new host this way — is in the Compose maintenance post.
-
This is per-stack surgery, not disaster recovery. If your Docker host is a Proxmox guest, whole-host snapshots (PBS/vzdump) are the layer under this one — see the post for how the two fit together.