Linuxcontainers

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.

DistrosUbuntu 24.04, Debian 12
Shellbash
Updated
Script
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 version works — the space, not the hyphen).
  • Run as a user in the docker group (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 compose commands 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 — export PROJECT before running. The uptime-kuma/kuma-data names 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: true or with their own name:) are not project-prefixed, so the script’s existence check flags them as FAIL rather than guessing. Archive those by their exact name with the same docker 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 PROJECT explicitly rather than trusting the guess. A wrong project name now fails loudly instead of backing up nothing.

  • Bind mounts aren’t covered. A ./config folder next to the compose file rides along when you copy the stack folder; an absolute-path bind mount like /mnt/media is 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 /target

    The 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.