backup.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Backup / restore of killBottleneck data. Everything (maps, users, uploaded files)
# lives in ./pb_data — this script packs that folder, or restores it from an archive.
#
# ./backup.sh → creates kb-backup-YYYY-MM-DD.tgz (consistent even while the instance runs)
# ./backup.sh restore FILE → restores data from the given archive (replaces pb_data)
#
# Encryption: export KB_BACKUP_PASSPHRASE and the backup is written as
# kb-backup-YYYY-MM-DD.tgz.gpg (gpg --symmetric, AES256). Keep the passphrase
# outside the repo (env var, password manager) — a lost passphrase means the
# archive cannot be opened by anyone, including you. Restore accepts both plain
# .tgz and .tgz.gpg archives, so older unencrypted backups keep working.
#
# Messages are English so the script works the same for everyone; `obnovit` is kept
# as an alias for `restore` so older Czech instructions keep working.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
if [ "${1:-}" = "restore" ] || [ "${1:-}" = "obnovit" ]; then
[ -f "${2:-}" ] || { echo "Usage: ./backup.sh restore kb-backup-....tgz[.gpg]"; exit 1; }
ARCHIVE="$2"
DECRYPTED=""
if [ "${ARCHIVE%.gpg}" != "${ARCHIVE}" ]; then
DECRYPTED="pb_data.restore-tmp.tgz"
rm -f "$DECRYPTED"
echo "Decrypting $ARCHIVE …"
if [ -n "${KB_BACKUP_PASSPHRASE:-}" ]; then
# --passphrase-fd keeps the passphrase out of `ps` output, unlike --passphrase
gpg --batch --yes --passphrase-fd 3 -o "$DECRYPTED" -d "$ARCHIVE" 3<<<"${KB_BACKUP_PASSPHRASE}"
else
gpg --yes -o "$DECRYPTED" -d "$ARCHIVE"
fi
ARCHIVE="$DECRYPTED"
fi
# The live data must never be deleted before the archive has proven itself:
# a corrupted download or a hand-crafted archive (absolute paths, ..) would
# otherwise take the running instance's data with it. So: list & check first,
# unpack next to the live folder, and only then swap the folders.
echo "Checking archive $2 …"
LIST=$(tar tzf "$ARCHIVE")
BAD=$(printf '%s\n' "$LIST" | awk '$0 !~ /^pb_data(\/|$)/ || /(^|\/)\.\.(\/|$)/')
# Symbolic links have no business in a data folder: PocketBase runs as root inside the
# container and would follow them (storage/<x> -> ../../data.db), so a hand-crafted archive is refused.
LINKS=$(tar tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/')
if [ -n "$BAD" ] || [ -n "$LINKS" ]; then
echo "Refusing to restore: archive contains entries outside pb_data/ or symbolic links:" >&2
printf '%s\n' "$BAD" "$LINKS" | sed -n '1,5p' >&2
[ -z "$DECRYPTED" ] || rm -f "$DECRYPTED"
exit 1
fi
STAGE="pb_data.restore-tmp"
rm -rf "$STAGE"; mkdir -m 700 "$STAGE"
tar xzf "$ARCHIVE" -C "$STAGE" --no-same-owner
[ -d "$STAGE/pb_data" ] || { echo "Refusing to restore: archive has no pb_data folder." >&2; rm -rf "$STAGE"; [ -z "$DECRYPTED" ] || rm -f "$DECRYPTED"; exit 1; }
# The database must prove itself too, still before the live data is touched: an archive
# taken by an older version of this script from a running instance can carry a silently
# broken index (see the backup branch below), and PocketBase would start on it without a
# word. sqlite3 comes from the instance image (same one as the snapshot), the live
# instance keeps running meanwhile.
if [ -f "$STAGE/pb_data/data.db" ]; then
IMG=$(docker compose images -q killbottleneck 2>/dev/null | head -n1)
[ -n "$IMG" ] || IMG=$(docker compose config --images 2>/dev/null | head -n1)
echo "Checking database integrity …"
# stderr apart from the result: a docker warning (platform, pull) must not make a sound archive look damaged
CHECK=$(docker run --rm -v "$PWD/$STAGE/pb_data:/d" "$IMG" sqlite3 /d/data.db "PRAGMA integrity_check;" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='_collections';" 2>"$STAGE/.check-err") && RC=0 || RC=$?
[ "$RC" -lt 125 ] || CHECK=$(tail -n1 "$STAGE/.check-err" 2>/dev/null)
if [ "$RC" -ge 125 ]; then
# docker could not run the check (no image yet, or an image without sqlite3) — say so, go on as before
echo "Warning: could not check the database (${CHECK##*$'\n'}) — restoring unchecked." >&2
elif [ "$RC" -ne 0 ] || [ "$CHECK" != "$(printf 'ok\n_collections')" ]; then
echo "Refusing to restore: the archive's database is damaged or not a killBottleneck database:" >&2
printf '%s\n' "$CHECK" | sed -n '1,5p' >&2
echo "Live data untouched. Try an older backup." >&2
rm -rf "$STAGE"; [ -z "$DECRYPTED" ] || rm -f "$DECRYPTED"
exit 1
fi
fi
echo "Stopping killBottleneck…"; docker compose stop
echo "Restoring data from $2 …"
rm -rf pb_data.old
[ ! -e pb_data ] || mv pb_data pb_data.old
mv "$STAGE/pb_data" pb_data
rm -rf "$STAGE"
[ -z "$DECRYPTED" ] || rm -f "$DECRYPTED"
echo "Starting killBottleneck…"; docker compose start
echo "Done. Previous data kept in pb_data.old — delete it once you have checked the restore."
else
OUT="kb-backup-$(date +%F).tgz"
# PocketBase keeps its SQLite databases in WAL mode, so a plain tar of a RUNNING instance
# reads data.db and data.db-wal at different moments. Under write load 4 of 35 such
# archives had a broken primary-key index (count(*) fine, record by id gone) and nothing
# noticed — not the backup, not the restore (code analysis 3, S8-09, 4 Nov 2026). While
# the container runs, the databases are therefore snapshotted with sqlite3 ".backup"
# (one consistent moment, no -wal/-shm) and packed from a staging folder together with
# the rest of pb_data. The archive keeps the pb_data/… layout, so old archives restore
# the same way. A stopped container writes nothing — plain tar is fine then.
TARDIR="."
STAGE=""
SNAP=".kb-backup-snapshot" # inside the volume: the container writes it, the host only reads it
# Clean up whatever this run left behind — the staging folder on the host and the snapshot
# inside the volume (a failed cp/tar would otherwise leave a second full copy of the database there).
uklid() {
[ -z "$STAGE" ] || rm -rf "$STAGE"
[ ! -e "pb_data/$SNAP" ] || docker compose exec -T killbottleneck rm -rf "/app/pb_data/$SNAP" 2>/dev/null || rm -rf "pb_data/$SNAP" 2>/dev/null || true
}
trap uklid EXIT
if [ -n "$(docker compose ps --status running -q killbottleneck 2>/dev/null)" ]; then
STAGE="pb_data.backup-tmp"
rm -rf "$STAGE"; mkdir -m 700 "$STAGE"; mkdir "$STAGE/pb_data"
echo "Taking a consistent database snapshot (the instance keeps running) …"
docker compose exec -T killbottleneck sh -c "rm -rf /app/pb_data/$SNAP && mkdir /app/pb_data/$SNAP \
&& sqlite3 /app/pb_data/data.db \".backup '/app/pb_data/$SNAP/data.db'\" \
&& { [ ! -f /app/pb_data/auxiliary.db ] || sqlite3 /app/pb_data/auxiliary.db \".backup '/app/pb_data/$SNAP/auxiliary.db'\"; }" \
|| { echo "Snapshot failed — an image older than 4 Nov 2026 has no sqlite3; rebuild with: docker compose up -d --build" >&2; exit 1; }
# Everything else (uploaded files in storage/, …) goes next to the snapshot: hard links
# where the system allows them (no second copy of the uploads), a real copy otherwise.
for f in pb_data/* pb_data/.[!.]*; do
[ -e "$f" ] || continue
case "${f#pb_data/}" in data.db|data.db-*|auxiliary.db|auxiliary.db-*|"$SNAP") continue;; esac
cp -al "$f" "$STAGE/pb_data/" 2>/dev/null || { rm -rf "$STAGE/pb_data/${f#pb_data/}"; cp -a "$f" "$STAGE/pb_data/"; }
done
cp "pb_data/$SNAP"/*.db "$STAGE/pb_data/"
docker compose exec -T killbottleneck rm -rf "/app/pb_data/$SNAP" || rm -rf "pb_data/$SNAP" || true
TARDIR="$STAGE"
fi
if [ -n "${KB_BACKUP_PASSPHRASE:-}" ]; then
OUT="${OUT}.gpg"
tar cz -C "$TARDIR" pb_data | gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-fd 3 -o "$OUT" 3<<<"${KB_BACKUP_PASSPHRASE}"
else
tar czf "$OUT" -C "$TARDIR" pb_data
echo "Note: the archive is NOT encrypted — export KB_BACKUP_PASSPHRASE to encrypt backups."
fi
chmod 600 "$OUT"
echo "Backup created: $OUT ($(du -h "$OUT" | cut -f1))"
fi