restic/ana-docker: extend pre-backup hooks (vaultwarden/gitea/openwebui)

Audit surfaced three DB-backed services not being dumped consistently
by the existing pre-backup.sh:

  - vaultwarden — migrated to external Postgres on PFI-Postgres
    (10.250.50.80); old sqlite block was dumping stale pre-migration
    files. Replace with pg_dump against the live database. Requires
    postgresql-client on ana-docker and VW_PG* creds in
    /etc/restic/dbcreds.env.
  - gitea — also on PFI-Postgres; no hook existed at all. Use
    `gitea dump` for a single zip that captures DB + repos + config +
    LFS + attachments consistently. No explicit creds needed; the
    container reads its own GITEA__database__* env.
  - openwebui — two local SQLite databases (webui.db + the ChromaDB
    vector store). .backup command if sqlite3 is in the image, volume-
    level fallback otherwise.

Refactor: each block now logs a WARN on failure instead of aborting the
whole script — partial dumps > no dumps when one service has an issue.

dbcreds.env.example committed as a template; real file goes to
/etc/restic/dbcreds.env root:600 on the host and is never in the repo.

Mattermost retired (user confirmed 2026-04-21); removed from the
pre-backup.sh list and flagged in README's stacks section. Mattermost
container was not running regardless; the audit surfaced that it was
already effectively gone. Compose dir on ana-docker can be removed as
separate cleanup.
This commit is contained in:
2026-04-21 00:39:16 -07:00
parent c3c05ddc53
commit 349f684d0b
3 changed files with 124 additions and 27 deletions
+1
View File
@@ -67,6 +67,7 @@ Per-host snapshots of the running system live under `servers/<host>/system-detai
**Anaheim non-GPU (ana-docker):**
- `traefik`, `crowdsec`, `gitea`, `vaultwarden`, `synapse`, `seafile`, `searxng`, `openwebui`, `sillytavern`, `mailrise`, `rustdesk`, `dockge`, `it-tools`
- (`mattermost` retired 2026-04-21 — compose dir may still linger, containers gone)
- Fleet services: `beszel` (metrics hub, port 8090), `dozzle-hub` (log viewer, 8088), `backrest` (restic UI, 9898)
- Backup target: `rest-server-ana` on port 8000
@@ -0,0 +1,25 @@
# /etc/restic/dbcreds.env on ana-docker — consumed by pre-backup.sh.
#
# Deploy to the host as:
# sudo install -o root -g root -m 0600 dbcreds.env /etc/restic/dbcreds.env
#
# Never commit the real file — it carries production DB passwords. This
# template is the only thing tracked in the repo.
# --- Vaultwarden → external Postgres on PFI-Postgres (10.250.50.80) ---------
VW_PGHOST=10.250.50.80
VW_PGPORT=5432
VW_PGUSER=vaultwarden
VW_PGDB=vaultwarden
VW_PGPASS=replace-with-postgres-password
# --- Gitea → external Postgres on PFI-Postgres -------------------------------
# Not needed: pre-backup.sh uses `gitea dump` which reads DB creds from the
# container's own GITEA__database__* env vars. Listed here for awareness
# in case we switch to direct pg_dump later.
#
# GITEA_PGHOST=10.250.50.80
# GITEA_PGPORT=5432
# GITEA_PGUSER=gitea
# GITEA_PGDB=gitea
# GITEA_PGPASS=replace-with-postgres-password
+98 -27
View File
@@ -7,68 +7,139 @@
# raw volume files are not safe to back up live.
#
# Containers handled here:
# - synapse-db (Postgres 16)
# - seafile-mysql (MariaDB 10.6)
# - vaultwarden (SQLite w/ WAL; online .backup via sqlite3 if available)
# - synapse-db (internal Postgres 16 — pg_dump)
# - seafile-mysql (internal MariaDB 10.6 — mysqldump)
# - vaultwarden (external Postgres on PFI-Postgres 10.250.50.80)
# - gitea (`gitea dump` captures DB + repos + config + LFS)
# - openwebui (local SQLite × 2 — main db + ChromaDB vector store)
#
# Gitea's DB is external (hosted elsewhere in the LAN) so we only back
# up its data volume; whoever owns the gitea Postgres backs it up
# separately.
# External DB credentials live in /etc/restic/dbcreds.env (root:600).
# Template: configs/restic/ana-docker/dbcreds.env.example in the repo.
#
# Required tooling on the host:
# docker — always
# pg_dump — for vaultwarden external Postgres; install via
# `apt install postgresql-client`. Without it,
# the vaultwarden block logs a warning and skips.
#
# Intentionally NOT handled:
# - mattermost (retired 2026-04-21 — stack dir lingers but is not running)
#
# Idempotent: clears and recreates its staging files each run.
# Errors in individual blocks are logged as WARN but don't abort the whole
# script — partial dumps are better than no dumps.
set -euo pipefail
STAGE=/var/lib/restic/stage
install -d -o root -g root -m 0700 "$STAGE"
log() { printf '%s pre-backup(ana-docker): %s\n' "$(date -Is)" "$*"; }
log() { printf '%s pre-backup(ana-docker): %s\n' "$(date -Is)" "$*"; }
warn() { log "WARN: $*" >&2; }
# Purge previous stage so stale dumps don't pile up and end up in the snapshot.
# Purge previous stage so stale dumps don't pile up into the snapshot.
find "$STAGE" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
# ---------- synapse-db (Postgres) ---------------------------------------------
# Load external-DB creds. Silently skipped if missing — individual blocks
# that need them will log their own WARN.
if [ -r /etc/restic/dbcreds.env ]; then
set -a; . /etc/restic/dbcreds.env; set +a
fi
# ---------- synapse (internal Postgres) ---------------------------------------
if docker inspect synapse-db >/dev/null 2>&1; then
log "dumping synapse postgres"
# -Fc custom format, internally compressed + restore-to-subset friendly
docker exec synapse-db \
pg_dump -U synapse -d synapse -Fc --clean --if-exists \
> "$STAGE/synapse.pg_dump"
> "$STAGE/synapse.pg_dump" \
|| warn "synapse pg_dump failed"
else
log "skip synapse: container not present"
fi
# ---------- seafile-mysql (MariaDB) -------------------------------------------
# ---------- seafile (internal MariaDB) ----------------------------------------
if docker inspect seafile-mysql >/dev/null 2>&1; then
log "dumping seafile mariadb"
# The root password lives in the container's own env (MYSQL_ROOT_PASSWORD);
# expand it inside the container so it never lands in the host's process list.
docker exec seafile-mysql sh -c \
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --quick 2>/dev/null' \
| gzip -c > "$STAGE/seafile.sql.gz"
| gzip -c > "$STAGE/seafile.sql.gz" \
|| warn "seafile mysqldump failed"
else
log "skip seafile: container not present"
fi
# ---------- vaultwarden (SQLite + WAL) ----------------------------------------
# Vaultwarden uses SQLite in WAL mode. A live copy of db.sqlite3 + -wal is
# usually recoverable, but sqlite3's own .backup pragma is the correct way
# to get a consistent snapshot. If the vaultwarden image has sqlite3
# available, use it; otherwise rely on restic backing up the raw volume.
# ---------- vaultwarden (external Postgres on PFI-Postgres 10.250.50.80) ------
# The vault moved from SQLite to external Postgres (date unclear). Any
# /data/db.sqlite3* files in the container are stale leftovers and should
# be deleted separately — this hook captures the live Postgres data only.
if docker inspect vaultwarden >/dev/null 2>&1; then
if docker exec vaultwarden sh -c 'command -v sqlite3 >/dev/null 2>&1'; then
log "dumping vaultwarden sqlite via .backup"
docker exec vaultwarden sqlite3 /data/db.sqlite3 \
".backup /tmp/vaultwarden.sqlite3"
docker cp vaultwarden:/tmp/vaultwarden.sqlite3 "$STAGE/vaultwarden.sqlite3"
docker exec vaultwarden rm -f /tmp/vaultwarden.sqlite3
if [ -z "${VW_PGPASS:-}" ]; then
warn "vaultwarden: VW_PGPASS unset in /etc/restic/dbcreds.env — skipping"
elif ! command -v pg_dump >/dev/null 2>&1; then
warn "vaultwarden: pg_dump not installed — skipping (apt install postgresql-client)"
else
log "skip vaultwarden .backup: sqlite3 not in container (raw volume still included via restic)"
log "dumping vaultwarden postgres (external: ${VW_PGHOST}:${VW_PGPORT:-5432})"
PGPASSWORD="$VW_PGPASS" pg_dump \
-h "$VW_PGHOST" -p "${VW_PGPORT:-5432}" \
-U "$VW_PGUSER" -d "$VW_PGDB" \
-Fc --clean --if-exists \
> "$STAGE/vaultwarden.pg_dump" \
|| warn "vaultwarden pg_dump failed"
fi
else
log "skip vaultwarden: container not present"
fi
# ---------- gitea (native `gitea dump`) ---------------------------------------
# `gitea dump` produces a single ZIP with the DB dump, repo trees, config,
# LFS objects and attachments. The in-container command knows its own DB
# creds (from GITEA__database__* env vars), so no creds needed here.
#
# Size scales with the repo tree. Trim with --skip-lfs-data,
# --skip-repository, --skip-attachment-data if the dump gets too large.
if docker inspect gitea >/dev/null 2>&1; then
log "dumping gitea (gitea dump, zip)"
if docker exec -u git gitea sh -c \
'rm -f /tmp/gitea-dump.zip && gitea dump -c /data/gitea/conf/app.ini -f /tmp/gitea-dump.zip' \
>/dev/null 2>&1; then
docker cp gitea:/tmp/gitea-dump.zip "$STAGE/gitea-dump.zip" \
&& docker exec -u git gitea rm -f /tmp/gitea-dump.zip \
|| warn "gitea dump copy/cleanup failed"
else
warn "gitea dump command failed"
fi
else
log "skip gitea: container not present"
fi
# ---------- openwebui (local SQLite × 2) --------------------------------------
# Two SQLite databases: main app (/app/backend/data/webui.db) and the
# ChromaDB vector store (.../vector_db/chroma.sqlite3). Uses SQLite's
# .backup command for a consistent snapshot if sqlite3 is available in
# the container; falls back to restic's volume-level capture otherwise.
OWUI_CONTAINER=openwebui-open-webui-1
if docker inspect "$OWUI_CONTAINER" >/dev/null 2>&1; then
if docker exec "$OWUI_CONTAINER" sh -c 'command -v sqlite3 >/dev/null 2>&1'; then
log "dumping openwebui sqlite (webui.db + chroma.sqlite3) via .backup"
for pair in \
"/app/backend/data/webui.db:webui.db" \
"/app/backend/data/vector_db/chroma.sqlite3:chroma.sqlite3"; do
src=${pair%:*}; dst=${pair#*:}
if docker exec "$OWUI_CONTAINER" sqlite3 "$src" ".backup /tmp/$dst" 2>/dev/null; then
docker cp "$OWUI_CONTAINER:/tmp/$dst" "$STAGE/openwebui.$dst" \
&& docker exec "$OWUI_CONTAINER" rm -f "/tmp/$dst" \
|| warn "openwebui copy/cleanup failed for $dst"
else
warn "openwebui .backup failed for $src (missing file or locked?)"
fi
done
else
warn "openwebui: sqlite3 not in container — relying on restic volume-level capture"
fi
else
log "skip openwebui: container not present"
fi
# ---------- summary -----------------------------------------------------------
size=$(du -sh "$STAGE" 2>/dev/null | awk '{print $1}')
count=$(find "$STAGE" -type f | wc -l)