Per-Service Restore Procedures¶
How to recover each backed-up service from restic snapshots.
For active backup alerts, start with Backup alert triage to identify whether the failure is metrics collection, systemd scheduling, local snapshots, copy targets, or restore validation.
Prerequisites¶
- Run the snippets in a root Bash shell on the Linux recovery host, normally
infra-services; Phoenix uses its own host. Start with
umask 077. - Use a reviewed checkout at
/opt/homelab, Python 3 with PyYAML (host packagepython3-yaml), andresticonPATH. - Set both
RESTIC_REPOSITORYandRESTIC_PASSWORD_FILEin that shell. Access to/etc/restic/passwordis required; do not print credentials. - For offsite restores, recover B2 credentials in
/etc/restic/b2.env. - Secret rendering requires SOPS and the safe rendering contract, including supported string values, required keys and trusted directory writers. The default automation key remains root-only; render and Compose use this same root context. Failed rendering must stop recovery before any subsequent deploy. Source tests do not establish live key access, client compatibility or recovery.
- Each
/tmp/restore-SERVICEtarget below must be absent, not an empty directory. Its parents must already exist without symlinks. Do not pre-create the target, reuse a previous attempt, or delete evidence to make the example work. If it exists, choose a new absolute target and update every downstream reference.
General Restore Pattern¶
The first recovery-contract repair (REC-001 / INC-002 / OPS-043) is source work
in PR #469, not a deployment or
restore-drill result. The new producer uses one restic invocation for all
manifest paths, tagged homelab-path-set-v1. Older snapshots remain split.
The tag describes a path set, not application consistency; unreadable files,
exclusions, live database writes, and stale logical exports still need review.
Use backups/restore-snapshot.py to plan and stage recovery:
- Review
restic snapshots --jsonfor the intended repository, host, times and roots. Select full snapshot IDs, not an implicit newest snapshot. - Run the helper without
--restore: it reads metadata and preflights the target, then emits a JSON plan. It does not create the target or restore data. - Review the plan; repeat the same arguments with
--restore. The helper creates a private mode-0700 target, restores there only, and checks expected roots. - Validate all staged paths and the application recovery set before any service stop, deletion, transfer, or copy into live storage. Preserve staging and failure evidence; a retry requires a new absent target.
- Only a separately reviewed, owner-approved application recovery plan may authorize cutover. The helper performs no hooks, promotion, cleanup or live copy.
Safety gate: root-path completeness is not proof of a valid backup. The application-specific commands below are retained recovery context, not certified by this path-set repair. Remaining application recovery defects are not fixed here. Review exact paths, database versions, logical imports, checksums, secrets, mounts and isolated drill results before using those commands. Do not run a page end-to-end as a script or stop/delete/copy anything merely because staging passed.
The runtime manifest is services/<name>/backup.yml, or the appropriate
backups/external/*.yaml for an external dataset. Snippets use absolute manifest
paths so a previous cd cannot select the wrong manifest. The helper requires
selected roots to exactly match the manifest: no missing, extra, repeated or
ancestor/descendant-overlapping roots.
For legacy split snapshots, explicitly repeat --snapshot for the reviewed,
disjoint roots from the same hostname. Their union must exactly match the
manifest. Record the times and assess cross-snapshot consistency yourself; the
helper does not establish a shared point in time. For example, after reviewing
OneUptime's two legacy path snapshots, plan only:
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/oneuptime
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json
read -r -p "Reviewed PostgreSQL-path snapshot ID: " POSTGRES_SNAPSHOT_ID
read -r -p "Reviewed ClickHouse-path snapshot ID: " CLICKHOUSE_SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/oneuptime/backup.yml \
--snapshot "$POSTGRES_SNAPSHOT_ID" --snapshot "$CLICKHOUSE_SNAPSHOT_ID" \
--target /tmp/restore-oneuptime
Use the same two IDs with --restore only after reviewing the plan. Do not combine
snapshots whose paths overlap, even if their times appear compatible. If a current
manifest differs from the historical roots, stop and review the historical recovery
contract rather than weakening the manifest merely to pass preflight.
--latest is scheduled verification only: the globally newest snapshot must
be unique and complete by itself. There is no fallback to an older complete
snapshot and no automatic assembly of split snapshots. Newest-time ties fail.
Restic's default retention grouping is host,paths. Legacy split path groups
remain separate from the new complete-path group; new backups do not automatically
retire those old groups. No automatic deletion or retention-group change is
authorized by this repair. Check local/offsite capacity, verify new backups and
application restores, then seek separate approval for any cleanup.
| Service | Tier | Restore note |
|---|---|---|
| Komodo | 1 | Mongo dump directory |
| Traefik | 1 | Static config + ACME cert cache |
| OneUptime | 1 | PostgreSQL + ClickHouse logical backups and SOPS encryption key |
| PostgreSQL Companion (external) | 1 | companion, tdler, reddit_explorer, globals, and row-count evidence |
| Paperless | 1 | PostgreSQL 16 logical generations + data/NFS media/inbox/export; database smoke is not full recovery |
| Firefly III | 1 | Postgres dump + firefly_pgdata / firefly_upload volumes |
| Rexit | 1 | Private archive with manifest, SQLite, gallery, and media validation |
| OneUptime incident adapter | 1 | Authoritative correlation DB; adapter recovery |
| OneUptime remediation broker | 1 | Audit, reservations and kill switch; broker runbook |
| Order Ledger | 1 | Consistent SQLite snapshot; see section below |
| Reddit Explorer | mixed | PostgreSQL via external cluster backup; media/stills are regeneratable; service guide |
| SonarQube | 2 | Dedicated host volumes; host guide |
| Paperless–Stirling Bridge | 2 | Persistent document-ID audit history; application and pipelines from Git |
| PR Dashboard | 3 | Replaceable state; service guide |
| Monitoring canary | 3 | Stateless; service guide |
| ARA | 2 | SQLite Docker volume |
| Homepage | 2 | Generated config; rerun generator if easier |
| AdGuard | 2 | adguard-conf only; no work/query history. Archived unbound.conf is not mounted by Compose |
| Monitoring | 2 | Grafana custom data outside provisioning |
| LiteLLM | 2 | PostgreSQL 16 logical generations for spend logs and key metadata; historical raw PGDATA is unproven |
| Stirling-PDF | 2 | Configs, tessdata, pipeline volumes |
| Phoenix | 2 | Phoenix Postgres/data volumes on LXC 124 |
| Wazuh | retired | Do not restore. Historical snapshots stay in the existing repositories |
| Authentik outpost | 3 | Stateless; recreate from Authentik + SOPS token |
Restoring from B2 (offsite-enabled jobs)¶
If the local repo is lost, use the same manifest and staging checks against the
B2 repository. Tier 1 normally has offsite copies; HAOS is an explicitly enabled
tier-2 exception. Repository names for external jobs include external-, but
manifest filenames do not. This example plans an external Authentik restore:
source /etc/restic/b2.env
read -r -p "Recovery B2 bucket name: " B2_BUCKET
export RESTIC_REPOSITORY="b2:${B2_BUCKET}:external-authentik-db"
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json
read -r -p "Reviewed offsite snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/authentik-db.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-authentik || exit 1
After reviewing the JSON plan, stage only:
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/authentik-db.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-authentik --restore || exit 1
Choose IDs from the offsite repository itself, not assumed local IDs. Validate all staging before following the application recovery context below.
Komodo (tier 1)¶
Restic stores Komodo's dated database backups from /etc/komodo/backups.
Copying those files back and starting Core does not import the database.
Use the explicit Komodo CLI restore operation against a reviewed target database.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/komodo
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/komodo/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-komodo || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/komodo/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-komodo --restore || exit 1
Select the dated backup directory inside the restored etc/komodo/backups/
tree. Follow Komodo's database restore procedure:
run km database restore with a /backups mount and the explicit
KOMODO_CLI_DATABASE_TARGET_* settings. Restore into an isolated empty database
first and verify resources and users. Restoring into a populated database can
retain old records; it is not an implicit replacement operation.
The CLI restore and a full Komodo recovery were not executed during the 2026-10-24 documentation audit. Do not treat this source review as a restore drill. Only after all staging and the isolated database restore are validated may the owner approve stopping Core/Periphery and selecting the recovered database. These application steps are not certified by the path-set repair. After that separate cutover, start Core and Periphery:
set -euo pipefail
cd /opt/homelab/services/komodo || exit 1
docker compose --env-file compose.env up -d
Verify OIDC login, servers, stacks and ResourceSync before re-enabling deployment procedures. After a host Docker restart, recreate Periphery as described in the stale socket runbook.
Traefik (tier 1)¶
Backs up the static config directory and acme.json (Let's Encrypt certs).
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/traefik
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/traefik/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-traefik || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/traefik/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-traefik --restore || exit 1
Stop here: validate all staged config and certificate data, then obtain application cutover approval. The following live steps are not certified by this repair.
set -euo pipefail
# Stop Traefik only after the validation and approval gate
cd /opt/homelab/services/traefik || exit 1
# Render preflight BEFORE any down/config/certificate mutation; stop on failure.
python3 /opt/homelab/scripts/render-sops-env.py \
--source .env.sops.yaml --output .env \
--rename CF_DNS_API_TOKEN=CLOUDFLARE_DNS_API_TOKEN \
--require CLOUDFLARE_DNS_API_TOKEN || exit 1
docker compose down
# Replace config
cp -a /tmp/restore-traefik/opt/homelab/services/traefik/config/* ./config/
# The certificate cache is a named volume, not ./acme.json.
docker volume create traefik_traefik-certs
docker run --rm -v traefik_traefik-certs:/certs \
-v /tmp/restore-traefik/var/lib/docker/volumes/traefik_traefik-certs/_data:/backup:ro \
alpine:3.22 sh -c 'cp -a /backup/. /certs/ && chmod 600 /certs/acme.json'
# Restart
docker compose up -d
Verify: curl -I https://homepage.infra.realemail.app returns a valid TLS
cert. If acme.json was lost, Traefik re-issues certs automatically (may take
a few minutes; watch for Let's Encrypt rate limits if you've done this multiple
times in a week).
OneUptime (tier 1)¶
OneUptime requires a matched PostgreSQL dump, ClickHouse backup set, and SOPS
ENCRYPTION_SECRET. Follow the dedicated
OneUptime backup and restore runbook. Restore
into isolated volumes first; start notification and remediation workflows only
after incidents, timelines, encrypted values, and readiness are verified.
Rexit (tier 1)¶
Validate a snapshot in isolation before replacing the live private archive:
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/rexit
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/rexit/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-rexit || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/rexit/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-rexit --restore || exit 1
python3 /opt/homelab/backups/restore-drills/rexit.py \
/tmp/restore-rexit/srv/rexit
Only after all staging and the archive drill validate, and the owner approves cutover, consider these application steps; the path-set repair does not certify them:
set -euo pipefail
cd /opt/homelab/services/rexit || exit 1
sudo systemctl stop rexit-export.timer
sudo systemctl stop rexit-export.service
docker compose stop studio
sudo rsync -a --delete /tmp/restore-rexit/srv/rexit/ /srv/rexit/
sudo chown -R 65532:65532 /srv/rexit
docker compose up -d studio
sudo systemctl start rexit-export.timer
Confirm the Authentik-protected gallery loads, then run
systemctl start rexit-export.service only if an immediate incremental refresh
is wanted. For total local-repository loss, use the B2 procedure above with
the rexit repository.
Paperless (tier 1)¶
REC-004 in PR #474 replaces hot
PGDATA backup/recovery with PostgreSQL 16 logical generations. This is a source
contract, not evidence of deployment or a successful application
restore. The manifest
retains paperless_data, /mnt/whrrr-scans/media, /mnt/whrrr-scans/inbox,
/opt/homelab/services/paperless/export and the untouched legacy
/opt/homelab/services/paperless/backups directory. New logical generations live
under /var/backups/postgres-logical/paperless, outside the shared checkout;
new snapshots no longer include paperless_pgdata. Tier 1 and B2 offsite stay unchanged.
The shared producer
defaults to an offline plan with --service paperless; the scheduled pre-hook
explicitly invokes python3 with --backup and
DOCKER_HOST=unix:///run/docker.sock. Each completed private, immutable generation
under /var/backups/postgres-logical/paperless contains paperless.dump,
paperless.dump.sha256 and manifest.json. Metadata binds the service, database/user,
PostgreSQL server/client major 16, capture time, filename, byte count and SHA-256.
When supplied, a validated systemd INVOCATION_ID is bound as invocation_id.
Exports require root. Backup-client's logical-postgres.yml provisions the
namespace and service directories as root:root mode 0700 before deploying units;
foreign-owned namespaces and symlinks are refused. The producer requires those
parents to exist and never changes existing directory permissions. Shared checkout
group-write normalization is why new generations cannot live beside the legacy
dump. The producer, pruner and verifier require Python 3.11+; the producer and
pruner also need host PyYAML. Normal role apply must ensure
Python 3/PyYAML and pass a read-only version/import check before units; check
mode cannot certify missing dependencies. Require full backup convergence with
mounts, repositories and credentials ready; a systemd-only refresh is valid only
after those base prerequisites, not as bootstrap. The source host's observed
Python 3.12.3 is prerequisite metadata, not database-query, export or recovery evidence.
Bounded storage and scheduled retention¶
The bounded-storage follow-up in PR #474 enforces local storage limits, superseding the initial PR's unbounded retention and lack of headroom enforcement. Owner policy review is required before merge and scheduling: merge/convergence now enables future scheduled local deletion. No live operation or deployment acceptance is claimed here.
The fixed storage policy retains seven newest validated completed generations per service, by generation ordering, not seven days; retries can shorten local time coverage. At 14 generations, new exports refuse without deleting anything. Paperless custom dumps are capped at 64 MiB (LiteLLM 512 MiB), catalog scratch at 32 MiB and each checksum/manifest at 64 KiB. Before Docker queries or staging writes, preflight budgets the maximum dump + catalog + 2 × 64 KiB metadata and 5 inodes.
Available bytes must meet max(ceil(15% of total bytes), 8 GiB) + 16 GiB plus
prospective bytes; available inodes must meet max(ceil(5% of total inodes), 100,000)
plus prospective inodes. Checks recur during streaming and allocation; unavailable
or invalid accounting refuses writes. The shared lock serializes cooperating
producers/pruners only: no preallocation or quota constrains unrelated writers.
Failed .staging-*, incomplete .deleting-*, unknown/foreign/corrupt/oversize
entries and unsafe links block new exports and pruning, never automatic deletion
or repair. Both private marker patterns are excluded from restic. Preserve evidence
and request review; do not remove it or override limits to force a capacity pass.
The dedicated pruner
with --service paperless defaults to a read-only filesystem plan of the actual
namespace, without Docker/systemd calls or writes. It is not the producer's offline
policy plan, a capacity certificate or a standalone operator cleanup recipe.
Explicit --prune is only for the direct Python systemd ExecStartPost, after
successful NAS backup/forget and required Paperless B2 copy/forget. It requires
matching INVOCATION_ID/unit InvocationID, this process as current ControlPID,
activating/start-post, and exactly one matching manifest generation that is the
newest. All generations are validated before any deletion; manual exports
without invocation binding cannot authorize cleanup. Earlier required failures
prevent pruning. Role gates require boolean local_generation_retention: true, no
fetch/post-hook or success-code overrides, and Paperless offsite: true with a
nonempty B2 bucket even in check mode. Reviewed unit ordering and successful exit
codes do not prove payload completeness or offsite/application recovery.
Across both services the hard-ceiling dump payload is 14 × 576 MiB = 7.875 GiB,
plus bounded metadata/catalog and filesystem overhead. Restic retention and
historical host,paths groups are unchanged; local retention does not remove dump
bytes already in snapshots or add automatic legacy-data/historical-snapshot cleanup.
The full operating contract
records exact accounting and authorization boundaries.
PostgresLogicalBackupRunFailed is a new critical / page alert only for the
Paperless/LiteLLM jobs on infra-services, including pre-hook and NAS/B2/pruner
failures. It uses the existing page route; source configuration is not delivery
acceptance, and OPS-042 remains open. Follow
the failure triage, not a
forced cleanup or repeated-export workaround.
Stage a reviewed recovery set¶
Use the prerequisites above, Python 3.11+ and a full reviewed checkout, including
the shared Python modules; copying just the service or one helper is insufficient. Recover
restic/offsite credentials separately from application secrets. These examples
stage data only; they neither decrypt/render .env nor authorize service changes.
Budget the complete snapshot, not seven dumps: latest snapshots can contain 14 generations before post-backup pruning. Paperless staging can duplicate up to 896 MiB of generation payload plus metadata/filesystem overhead and all other captured roots (data, media, inbox, export, legacy backups), alongside live data and verifier memory/tmpfs. The operational reserve is not a restore-capacity guarantee.
Historical roots differ: the commands below use the new manifest. For an older snapshot containing PGDATA, supply a separately reviewed historical manifest to both guarded staging calls instead. For legacy split snapshots, explicitly select all reviewed same-host, disjoint IDs whose union matches that historical manifest. Do not edit/weaken the new manifest, omit roots, or treat a staged hot PGDATA tree as a proven physical backup. Preserve historical snapshots and evidence.
umask 077
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/paperless
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed full snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/paperless/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-paperless || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/paperless/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-paperless --restore || exit 1
Validate all staged data, media, inbox and export roots and their recovery-time relationship to the selected database. Select a completed generation explicitly, not whichever dump is newest or happens to exist in the checkout. Keep its dump, sidecar and manifest together, private, stable and free of symlink traversal.
Plan and run an isolated database smoke test¶
The verifier
checks the selected staged artifact, not ./backups/paperless.dump in the
current working directory. Without --verify it only validates local files and
emits a plan: no Docker calls, image pull, database import or mutation. Only
custom-format PGDMP archives are accepted. The owner must vet and separately
preload an exact PostgreSQL 16 name@sha256:digest image before explicit
verification; do not substitute a mutable tag or invented digest. The image must
support the verifier's postgres user, standard entrypoint and PG16 layout; see
the linked contract for the image checks.
read -r -p "Absolute staged generation path ending in /paperless.dump: " PAPERLESS_DUMP
case "$PAPERLESS_DUMP" in
/tmp/restore-paperless/var/backups/postgres-logical/paperless/*/paperless.dump) ;;
*) echo "Stop: select a generation from the reviewed staging target" >&2; exit 1 ;;
esac
read -r -p "Vetted preloaded PostgreSQL 16 image (name@sha256:digest): " POSTGRES_IMAGE
python3 /opt/homelab/backups/verify-postgres-backup.py \
--service paperless --dump "$PAPERLESS_DUMP" --image "$POSTGRES_IMAGE" || exit 1
After reviewing the plan, approve the trusted local daemon and memory/tmpfs
capacity. Require an explicit DOCKER_HOST=unix:///absolute/socket with safe
ownership and no symlink traversal; review and unset conflicting Docker context/TLS
overrides. The full endpoint and image requirements are in the logical backup
contract linked above. Only then run:
read -r -p "Type VERIFY to approve a disposable database smoke test: " VERIFY_APPROVAL
test "$VERIFY_APPROVAL" = VERIFY || exit 1
python3 /opt/homelab/backups/verify-postgres-backup.py \
--service paperless --dump "$PAPERLESS_DUMP" --image "$POSTGRES_IMAGE" --verify || exit 1
Explicit verification uses a fresh owned PostgreSQL 16 container with fixed
1 GiB data tmpfs / 2 GiB memory, --network=none, no published ports, no host data
mounts and no application writers. Even dumps within the producer limit can expand
beyond these bounds: the fixed 1 GiB smoke is not capacity certification and
there is no sizing CLI override. It validates the archive/catalog and imports with
pg_restore --exit-on-error --single-transaction --no-owner --no-acl, then requires
a positive ordinary application-table count. Expected rows and original grants
are not checked. Cleanup is limited to that exact owned temporary
container, never backups, staged artifacts or live volumes. A passing result is a
database smoke test, not full Paperless recovery. The older weekly generic
file-count result was not application-restore certification; this change adds no
automatic verifier wiring.
Historical fixed dump¶
An old backups/paperless.dump without generation metadata may use the verifier's
legacy mode only with an independently trusted expected SHA-256. For example,
derive it from the exact reviewed file streamed directly from its authenticated
restic snapshot, with pipeline error checking and recorded snapshot/path provenance.
Never hash the unknown staged copy to bless it, manufacture a manifest/sidecar, or
bypass existing invalid metadata. Review the PostgreSQL version and archive too.
The historical file stages at
/tmp/restore-paperless/opt/homelab/services/paperless/backups/paperless.dump, but
inherited group-write permissions or foreign ownership can make the verifier reject
that tree. Do not change permissions/ownership in the original staging. Obtain
separate owner approval to copy the dump and all existing adjacent metadata
into a new root-owned private validation directory outside the checkout and live/
backup roots, with trusted, non-symlink parents. Use a root:root mode-0700
directory and new owner-only regular files; preserve the original tree, bytes,
ownership/modes and provenance as evidence. Copy bytes unchanged, not via hard
links or symlink traversal; record the reviewed source-to-copy mapping privately.
Every existing manifest.json and .sha256 must accompany the copy unchanged,
even if invalid. Never omit unreadable/invalid metadata, remove a manifest to
force legacy mode, or compute the expected digest from the untrusted copy. Stop
on unsupported layouts or incomplete copies. If a manifest exists, use the normal
manifest-bound verifier without the legacy override and let invalid metadata fail.
Only if the reviewed original truly lacked a manifest may the fixed historical dump
use this filesystem-only legacy plan, selecting the approved private copy explicitly:
read -r -p "Absolute paperless.dump path in the approved private validation copy: " LEGACY_PAPERLESS_DUMP
read -r -p "Independently trusted expected SHA-256 (64 hex characters): " LEGACY_SHA256
read -r -p "Vetted preloaded PostgreSQL 16 image (name@sha256:digest): " POSTGRES_IMAGE
python3 /opt/homelab/backups/verify-postgres-backup.py \
--service paperless --dump "$LEGACY_PAPERLESS_DUMP" \
--image "$POSTGRES_IMAGE" --legacy-expected-sha256 "$LEGACY_SHA256" || exit 1
Only after the same owner/image/daemon/capacity gates may that exact legacy command
be repeated with --verify. Missing trustworthy provenance is a stop condition,
not permission to fall back to raw PGDATA.
Separate production cutover gate¶
Require an owner-approved plan for a fresh persistent database, matching PostgreSQL/application versions, private secret recovery/rendering, data/media/inbox and export reconciliation, durable Compose/storage/backup mappings, and rollback. The disposable verifier database is not a production target. Preserve original volumes, NFS data, snapshots and failed staging; do not clear or overwrite them.
Keep all application writers off until successful import and checks: Paperless web/consumer/background tasks, Paperless-GPT, Stirling pipelines, the Paperless–Stirling bridge and other scanner/API/NFS writers. Account for restart/deployment automation. Do not use a blanket Compose start before import, blind database deletion, or a raw-volume/start-and-then-import fallback. NFS mount/ownership checks and a coherent database/media/inbox recovery point remain required; REC-033 is still open.
Before reopening writers, complete the approved isolated application checks for expected document counts, originals/thumbnails/downloads, search and permissions, plus the rollback rehearsal. Restic access, SOPS secret recovery/rendering, database smoke, application integrity and production approval remain separate gates. Real PostgreSQL 16 export/import, application/media acceptance, rollback rehearsal and required offsite recovery remain open; source validation does not close them.
Firefly III (tier 1)¶
Backs up a Postgres custom-format dump written by the backup pre-hook to
/opt/homelab/services/firefly/backups/firefly.dump (user/db firefly), plus
Docker volumes firefly_pgdata (Postgres 18 data at /var/lib/postgresql) and
firefly_upload. App URL is https://firefly.infra.realemail.app; the data
importer is at https://firefly-import.infra.realemail.app. Containers:
firefly, firefly-db, firefly-cron, firefly-importer.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/firefly
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/firefly/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-firefly || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/firefly/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-firefly --restore || exit 1
Stop here: validate every staged root and an isolated logical database restore. The following database replacement and attachment steps need separate owner review; this repair does not certify them or authorize destructive SQL.
set -euo pipefail
cd /opt/homelab/services/firefly || exit 1
# Explicit manual opt-in; preflights Python/shared helper before secret access.
# Default automated Firefly consumers retain the shell/SOPS backend.
# Do not stop or start after render failure.
bash /opt/homelab/services/firefly/scripts/render-env.sh --restricted || exit 1
docker compose down
# Service name is db; container name is firefly-db
docker compose up -d db
# Wait until healthy, then ensure an empty target database if needed:
# docker exec -i firefly-db psql -U firefly -d postgres -c \
# "DROP DATABASE IF EXISTS firefly; CREATE DATABASE firefly OWNER firefly;"
docker cp /tmp/restore-firefly/opt/homelab/services/firefly/backups/firefly.dump \
firefly-db:/tmp/firefly.dump
# Or from the local pre-hook path if already on disk:
# docker cp ./backups/firefly.dump firefly-db:/tmp/firefly.dump
docker exec -i firefly-db pg_restore -U firefly -d firefly --clean --if-exists \
/tmp/firefly.dump
# Optional: restore uploaded attachments if they matter
cp -a /tmp/restore-firefly/var/lib/docker/volumes/firefly_upload/_data/. \
/var/lib/docker/volumes/firefly_upload/_data/
docker compose up -d
Verify: Open https://firefly.infra.realemail.app, confirm accounts and
transactions. Importer: https://firefly-import.infra.realemail.app.
ARA (tier 2)¶
SQLite database with Ansible run history. Regeneratable but useful to keep.
backup.yml selects
/var/lib/docker/volumes/ara_ara-data/_data; Compose mounts ara-data at
/opt/ara. The repository does not establish the SQLite filename or its
relative layout. Review the actual staged data and matching application settings;
never guess a database filename.
The bounded REC-002 preparation procedure is source under review in
PR #473, not evidence of deployment
or an application recovery test. Read the
recovery-volume preparation contract
before proceeding. Run only in a root Bash shell on the Linux recovery host
with the full reviewed checkout at /opt/homelab, not on the editor workstation.
First select and stage an explicit reviewed snapshot using the existing path-set rules. The staging root must be a new absent absolute path; preserve prior attempts.
umask 077
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/ara
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed full snapshot ID: " SNAPSHOT_ID || exit 1
read -r -p "New absent absolute staging root: " STAGING_ROOT || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/ara/backup.yml \
--snapshot "$SNAPSHOT_ID" --target "$STAGING_ROOT" || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL || exit 1
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/ara/backup.yml \
--snapshot "$SNAPSHOT_ID" --target "$STAGING_ROOT" --restore || exit 1
Stop and review the full staged recovery set. The only copy source is
$STAGING_ROOT/var/lib/docker/volumes/ara_ara-data/_data, not the staging wrapper.
Keep that trusted tree and its parents stable throughout validation and preparation.
Missing, malformed, symlinked or unsupported data is a stop condition, not permission
to create empty directories, follow links or rewrite the backup to pass checks.
In the same shell, provide the reviewed database path relative to that _data
directory and a new unused name starting with ara-recovery-. Follow the
contract's path, name and stable-tree constraints. The owner must vet and separately
preload a digest-pinned utility image with /bin/sh and GNU cp 9.5+
supporting --update=none-fail, with no declared VOLUME. No image or digest is
supplied by this procedure.
read -r -p "Reviewed database path relative to staged _data: " ARA_DATABASE || exit 1
read -r -p "New unused ara-recovery- name: " RECOVERY_VOLUME || exit 1
read -r -p "Vetted preloaded copy image (name@sha256:digest): " COPY_IMAGE || exit 1
python3 /opt/homelab/backups/prepare-recovery-volume.py \
--service ara --staging-root "$STAGING_ROOT" --volume "$RECOVERY_VOLUME" \
--copy-image "$COPY_IMAGE" --ara-database "$ARA_DATABASE" || exit 1
Without --prepare, this is a filesystem-only JSON plan: no Docker calls,
copy or volume creation. It verifies layout and a regular file's SQLite header,
not SQLite integrity, schema compatibility or usable ARA history. A plan does
not prove the target is absent or the image is loaded. Review its exact source,
database path, image and destination before approving preparation only. The owner
must also review the local daemon endpoint: an existing non-symlink Unix socket
with non-symlink parents. Docker context/TLS overrides must be unset as described
in the contract; the helper fails closed rather than choosing a different daemon.
read -r -p "Reviewed local Docker endpoint (unix:///absolute/socket): " DOCKER_HOST || exit 1
export DOCKER_HOST
read -r -p "Type PREPARE after owner approval of this exact plan: " PREPARE_APPROVAL || exit 1
test "$PREPARE_APPROVAL" = PREPARE || exit 1
python3 /opt/homelab/backups/prepare-recovery-volume.py \
--service ara --staging-root "$STAGING_ROOT" --volume "$RECOVERY_VOLUME" \
--copy-image "$COPY_IMAGE" --ara-database "$ARA_DATABASE" --prepare || exit 1
Preparation creates only a new labelled, unused volume, verifies ownership and
emptiness, and copies through a strict read-only source bind with --pull=never
and --network=none. It never stops/starts ARA, overwrites/deletes an existing
volume or cuts over. Preserve any failed/partial destination; do not reuse it.
After failure or interruption, check for an ongoing copy before any retry/manual
action. Keep ara_ara-data unchanged for rollback.
Preparation is not recovery acceptance. Separately approve isolated SQLite
integrity and application checks, including any journal/WAL files, compatible ARA
version/settings, copied content/metadata fidelity, ownership, API/UI access and
expected historical runs. Do not
validate by starting the production service on the new volume. Before production
cutover, the owner must approve the results, maintenance and rollback plan, and a
reviewed GitOps change to both services/ara/compose.yml's volume mapping and
services/ara/backup.yml's absolute data path. An ad-hoc Compose override can be
reverted by the next deployment while backups keep reading the old volume. The
preparer does not change either file or make its temporary volume a production
default. Verify the resulting backup coverage after any separately approved
cutover, and retain the original volume until rollback retention is approved.
Homepage (tier 2)¶
Config is generated from inventory — backup is convenience only.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/homepage
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/homepage/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-homepage || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/homepage/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-homepage --restore || exit 1
Validate all staged config before any copy. Regeneration is a separate alternative, not a helper action; review its output before deploying it.
set -euo pipefail
# Instead of copying restored config, regenerate from reviewed inventory:
cd /opt/homelab || exit 1
uv run python inventory/generators/render-homepage.py
AdGuard (tier 2)¶
backup.yml protects config only:
/var/lib/docker/volumes/adguard_adguard-conf/_data and the archived
/opt/homelab/services/adguard/unbound.conf. AdGuardHome.yaml belongs in the
config directory. The adguard-work volume, including query history, is not
backed up. Do not clear, replace or claim to restore it from this recovery set.
Current Compose does not mount unbound.conf; copying the archive back into
the checkout would not restore effective Unbound configuration.
The bounded REC-003 procedure is source under review in
PR #473, not evidence of deployment
or an application recovery test. Read the
recovery-volume preparation contract.
Use a root Bash shell on the Linux recovery host, the full reviewed checkout
at /opt/homelab, and a new absent absolute staging root. Preserve prior attempts.
Select a complete explicit snapshot. For legacy split snapshots, apply the
existing path-set rules: repeat --snapshot for both
reviewed same-host roots in both helper calls below, with no missing, extra or
overlapping roots. The archived Unbound file still belongs to that staging set,
even though it is not mounted or selected for volume preparation.
umask 077
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/adguard
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed complete-path-set full snapshot ID: " SNAPSHOT_ID || exit 1
read -r -p "New absent absolute staging root: " STAGING_ROOT || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/adguard/backup.yml \
--snapshot "$SNAPSHOT_ID" --target "$STAGING_ROOT" || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL || exit 1
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/adguard/backup.yml \
--snapshot "$SNAPSHOT_ID" --target "$STAGING_ROOT" --restore || exit 1
Stop and validate all staged paths and the DNS recovery set. Keep the full
trusted staging tree and its parents stable throughout validation and preparation.
The only copy source is
$STAGING_ROOT/var/lib/docker/volumes/adguard_adguard-conf/_data; never substitute
the staging wrapper, work directory or archived Unbound file. Missing, malformed,
symlinked or unsupported data requires review, not automatic fixes or an empty
replacement config.
In the same shell, select a new unused name starting with adguard-conf-recovery-
and follow the contract's path, name and stable-tree constraints. The owner must
vet and separately preload a digest-pinned image with /bin/sh and
GNU cp 9.5+ supporting --update=none-fail, with no declared VOLUME.
Do not substitute an unreviewed tag or invented digest.
read -r -p "New unused adguard-conf-recovery- name: " RECOVERY_VOLUME || exit 1
read -r -p "Vetted preloaded copy image (name@sha256:digest): " COPY_IMAGE || exit 1
python3 /opt/homelab/backups/prepare-recovery-volume.py \
--service adguard --staging-root "$STAGING_ROOT" --volume "$RECOVERY_VOLUME" \
--copy-image "$COPY_IMAGE" || exit 1
Without --prepare, the helper emits a filesystem-only JSON plan, with no
Docker calls or mutation. It checks layout and a parseable YAML mapping in
AdGuardHome.yaml, not AdGuard schema validity, DNS semantics or recoverability.
It does not establish target availability or image preload. Review the exact
source, image and destination before owner approval of preparation only. Review
the local daemon endpoint too: an existing non-symlink Unix socket with non-symlink
parents. Docker context/TLS overrides must be unset as described in the contract;
the helper refuses them rather than choosing another daemon.
read -r -p "Reviewed local Docker endpoint (unix:///absolute/socket): " DOCKER_HOST || exit 1
export DOCKER_HOST
read -r -p "Type PREPARE after owner approval of this exact plan: " PREPARE_APPROVAL || exit 1
test "$PREPARE_APPROVAL" = PREPARE || exit 1
python3 /opt/homelab/backups/prepare-recovery-volume.py \
--service adguard --staging-root "$STAGING_ROOT" --volume "$RECOVERY_VOLUME" \
--copy-image "$COPY_IMAGE" --prepare || exit 1
Preparation creates only a new labelled, unused volume after target checks,
verifies ownership and emptiness, and copies through a strict read-only source
bind with --pull=never and --network=none. It does not stop/start AdGuard or
Unbound, overwrite/delete volumes, restore work data or change production mounts.
Preserve failed/partial destinations and the original adguard_adguard-conf and
adguard_adguard-work volumes for rollback; do not reuse a failed destination.
After failure or interruption, check for an ongoing copy before any retry/manual
action.
Preparation is not recovery acceptance. Separately approve copied content and
metadata fidelity checks and an isolated test of compatible AdGuard config,
upstream 172.21.0.3, rewrites, filtering and DNS resolution without binding the
production listener. Review the missing work-data
impact and the actual Compose/image-provided Unbound configuration; an archived
file is not evidence of effective settings. Before production cutover, approve
test results, DNS continuity and rollback, and reconcile both the GitOps
volume mapping in services/adguard/compose.yml and the absolute config-volume
path in services/adguard/backup.yml. An ad-hoc override risks the next deployment
reverting the mapping while backups keep reading the old volume. The preparer
changes neither file and does not select a new production default.
After a separately approved cutover, verify client resolution through
192.168.6.17:53, the Authentik-protected UI, rewrites against inventory and backup
coverage of the active config volume. Keep the original volumes until rollback
retention is approved; this procedure authorizes no cutover or cleanup.
Monitoring (tier 2)¶
Grafana user customizations outside code-managed provisioning and the GitHub Actions collector SQLite state used for restart-safe event deduplication.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/monitoring
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/monitoring/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-monitoring || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/monitoring/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-monitoring --restore || exit 1
Stop here: validate both staged volumes and their SQLite databases in isolation. Do not stop services or delete/copy data until all validation passes and the owner approves an application recovery plan. These live-copy steps are not certified here.
set -euo pipefail
# Replace Grafana data volume only after the validation and approval gate
cd /opt/homelab/services/monitoring || exit 1
docker compose stop grafana github-actions-collector
docker run --rm \
-v monitoring_grafana-data:/data \
-v /tmp/restore-monitoring/var/lib/docker/volumes/monitoring_grafana-data/_data:/backup:ro \
alpine sh -c "rm -rf /data/* && cp -a /backup/* /data/"
docker run --rm \
-v monitoring_collector-data:/data \
-v /tmp/restore-monitoring/var/lib/docker/volumes/monitoring_collector-data/_data:/backup:ro \
alpine sh -c "rm -rf /data/* && cp -a /backup/* /data/"
docker compose --env-file .env up -d grafana github-actions-collector
Note: Dashboards provisioned from monitoring/grafana/dashboards/ are
restored automatically on Grafana startup. Only user-created dashboards and
annotations need the Grafana volume restore. If no collector backup exists,
start it with an empty volume; the initial lookback repopulates state but can
temporarily re-observe completed jobs.
LiteLLM (tier 2)¶
REC-005 in PR #474 replaces hot
PGDATA backup/recovery with PostgreSQL 16 logical generations for spend logs,
virtual keys and key metadata. This is a source contract, not a completed deployment
or application restore. The
manifest
now protects /var/backups/postgres-logical/litellm instead of
/var/lib/docker/volumes/litellm_litellm-db-data/_data. Tier 2 remains local-only;
this change adds no offsite copy. Provider usage may reconstruct some billing
history, not virtual-key identities or configuration. Provider/master/database
secrets still require separate private SOPS recovery and rendering.
Regenerate .env from recovered SOPS using the render-only
LiteLLM deployment-input refresh,
not the Setup section's blanket Compose start. Preserve the existing raw
POSTGRES_PASSWORD; the renderer derives POSTGRES_PASSWORD_URLENCODED for the
proxy URL while the database receives the raw credential. Do not store the derived
key in SOPS, reuse an old .env without it, or rotate the password to pass
validation. Before live cutover, privately verify exact credential byte/string
equivalence through Compose and the decoded URL, and verify database authentication.
This representation change does not authorize ALTER ROLE or a password rotation.
The shared producer
defaults to an offline plan with --service litellm; the scheduled pre-hook
explicitly invokes python3 with --backup and
DOCKER_HOST=unix:///run/docker.sock. Each completed private, immutable generation
under /var/backups/postgres-logical/litellm, outside the shared checkout, contains
litellm.dump, litellm.dump.sha256 and manifest.json, binding service,
database/user, PostgreSQL server/client major 16, capture time, filename, size and
SHA-256; a valid supplied systemd INVOCATION_ID is also bound as invocation_id.
Exports require root and pre-provisioned root:root mode-0700
namespace/service directories. Backup-client's logical-postgres.yml provisions
them before units and refuses foreign-owned namespaces and symlinks; the producer
never changes existing directory permissions. The producer, pruner and verifier
require Python 3.11+; the producer and pruner also need host PyYAML. Normal role
apply must ensure Python 3/PyYAML and pass a read-only
version/import check before units; check mode cannot certify missing dependencies.
Require full backup convergence with mounts, repositories and credentials ready.
A systemd-only refresh is valid only after those base prerequisites, not as bootstrap.
Read-only host metadata observations are not database-query, export or restore evidence.
The bounded-storage follow-up implements bounded storage and scheduled retention, not the initial PR's unbounded local growth or absent headroom enforcement. Owner policy review is required before merge and scheduling: merge/convergence enables future scheduled local deletion. No live operation is performed here.
LiteLLM keeps the seven newest validated completed generations, not seven days;
at 14, exports stop without deleting data. Dumps are capped at 512 MiB,
catalog scratch at 32 MiB, each metadata file at 64 KiB. Preflight budgets
the maximum dump + catalog + 2 × 64 KiB metadata and 5 inodes. The same
max(ceil(15% of total bytes), 8 GiB) + 16 GiB byte floor/reserve and
max(ceil(5% of total inodes), 100,000) inode floor apply plus prospective
allocation, before export and during writes. Invalid accounting and failed
.staging-*/.deleting-*, unknown/foreign/corrupt/oversize entries or unsafe links
block new exports and pruning; they are never automatically deleted or repaired.
The lock coordinates cooperating producers/pruners, not unrelated writers or quotas.
Do not remove evidence or override checks to force a capacity pass.
The dedicated pruner's --service litellm default is a read-only filesystem plan,
not a cleanup recipe. Explicit --prune is only the direct systemd ExecStartPost
after successful NAS backup/forget (Paperless additionally requires B2 copy/forget),
with matching invocation, current ControlPID, activating/start-post, and exactly
one invocation-bound newest generation. Failed required prior stages prevent it.
Restic retention and historical host,paths groups are unchanged; no automatic
legacy-data or historical-snapshot cleanup is added. See the
logical backup contract.
For the new two-job critical/page PostgresLogicalBackupRunFailed alert, follow
the failure triage. The existing
page route is source configuration, not live delivery acceptance; OPS-042 remains open.
Stage a reviewed recovery set¶
Use the prerequisites above, Python 3.11+ and a full reviewed checkout, not
standalone copied scripts. Recover restic access separately; staging/verification does not restore
application secrets or render .env. Latest snapshots can contain 14 generations
before pruning, up to 7 GiB of LiteLLM dump payload plus metadata/filesystem
overhead. Budget the full staging copy alongside live data and verifier memory/tmpfs;
the two-service live dump ceiling of 7.875 GiB is not a total recovery-space bound.
Historical roots differ: older snapshots contain raw PGDATA, not these logical
generations. Use their separately reviewed historical manifest with guarded
restore-snapshot.py staging, and explicit same-host disjoint IDs for any split set.
Do not weaken the new manifest to accept the old roots. Historical hot raw files
are unproven physical backups: this repair neither validates them nor invents
a logical dump for them. If no trusted logical generation exists, stop for a
separate recovery assessment; preserve the files and snapshots as evidence.
The following commands are for a reviewed snapshot matching the new manifest:
umask 077
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/litellm
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed full snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/litellm/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-litellm || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/litellm/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-litellm --restore || exit 1
Validate all staging, then select one completed generation explicitly. Keep its dump, sidecar and manifest together, private, stable and free of symlink traversal; never substitute a local checkout dump or an implicit newest file.
Plan and run an isolated database smoke test¶
The verifier
validates the selected staged file. Its default is filesystem-only: no Docker
calls, pulls, imports or mutation. Only custom-format PGDMP archives are accepted.
Choose an owner-vetted PostgreSQL 16 image with an exact name@sha256:digest reference
and separately preload it on the intended trusted local daemon before explicit
verification. It must support the verifier's postgres user, standard entrypoint
and PG16 layout; see the linked contract for the image checks.
read -r -p "Absolute staged generation path ending in /litellm.dump: " LITELLM_DUMP
case "$LITELLM_DUMP" in
/tmp/restore-litellm/var/backups/postgres-logical/litellm/*/litellm.dump) ;;
*) echo "Stop: select a generation from the reviewed staging target" >&2; exit 1 ;;
esac
read -r -p "Vetted preloaded PostgreSQL 16 image (name@sha256:digest): " POSTGRES_IMAGE
python3 /opt/homelab/backups/verify-postgres-backup.py \
--service litellm --dump "$LITELLM_DUMP" --image "$POSTGRES_IMAGE" || exit 1
After plan review, require owner approval of the daemon and memory/tmpfs capacity.
Set an explicit reviewed DOCKER_HOST=unix:///absolute/socket with safe ownership
and no symlink traversal; review and unset conflicting Docker context/TLS overrides.
Follow the full endpoint/image requirements in the linked logical backup contract,
then explicitly request verification:
read -r -p "Type VERIFY to approve a disposable database smoke test: " VERIFY_APPROVAL
test "$VERIFY_APPROVAL" = VERIFY || exit 1
python3 /opt/homelab/backups/verify-postgres-backup.py \
--service litellm --dump "$LITELLM_DUMP" --image "$POSTGRES_IMAGE" --verify || exit 1
This creates a fresh owned PostgreSQL 16 container with fixed 1 GiB data tmpfs /
2 GiB memory, --network=none, no ports, no host data mounts and no application
writers. Even dumps within the producer limit can expand beyond these bounds; the
fixed 1 GiB smoke is not capacity certification, and there is no sizing CLI override.
Archive checks, pg_restore --exit-on-error --single-transaction --no-owner --no-acl
and a positive ordinary application-table count are a database smoke test, not
full LiteLLM recovery; expected rows and original grants are not checked. Cleanup removes only the exact owned
temporary container, never backups, staged files or live volumes. No automatic
verifier wiring is added; a generic weekly file count is not application-restore
certification, and LiteLLM remains outside the tier-1 weekly rotation.
Separate production cutover gate¶
An owner-approved cutover must use a fresh persistent database with compatible
PostgreSQL/application versions, separately recovered/rendered secrets, reviewed
Compose/storage/backup mappings and a rollback plan preserving the old data.
The verifier's disposable database is never promoted. Keep the proxy, background
jobs and all other application writers off until successful import and checks;
account for restart/deployment automation. Do not clear live PGDATA, blindly drop
a database, or start the full Compose stack before import. The render-only secret
refresh above is a prerequisite, not permission to run Setup's blanket start or
reopen writers; an old .env missing the generated URL password fails closed.
Require isolated application checks of expected virtual-key metadata/authorization,
spend/budget history, /v1/models, Admin UI and metrics before approved reopening.
A nonempty database or HTTP 200 alone is insufficient. Do not expose key values
in evidence. Restic access, secret recovery/rendering, database smoke, application
validation and production acceptance remain distinct gates. Real PostgreSQL 16
export/import, isolated application acceptance and rollback rehearsal remain open.
LiteLLM gains no offsite coverage; required Paperless offsite recovery remains separate.
Stirling-PDF (tier 2)¶
Restores configs, OCR tessdata, and pipeline volumes. Uploaded PDFs are not retained by design.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/stirling-pdf
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/stirling-pdf/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-stirling || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/stirling-pdf/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-stirling --restore || exit 1
Stop here: validate all staged volumes before any stop/delete/copy. The following application recovery context needs separate owner approval and is not certified here.
set -euo pipefail
cd /opt/homelab/services/stirling-pdf || exit 1
docker compose down
for vol in configs tessdata pipeline; do
docker run --rm \
-v "stirling-pdf_${vol}:/data" \
-v "/tmp/restore-stirling/var/lib/docker/volumes/stirling-pdf_${vol}/_data:/backup:ro" \
alpine sh -c "rm -rf /data/* && cp -a /backup/. /data/"
done
docker compose up -d
Verify: Open https://stirling.infra.realemail.app (Authentik) and confirm
saved pipelines/settings.
Phoenix (tier 2)¶
Phoenix runs on prox LXC 124, not infra-services. Restore on that host.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/phoenix
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/phoenix/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-phoenix || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/phoenix/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-phoenix --restore || exit 1
Stop here: validate every staged root and database consistency in isolation. These raw-volume/application steps are not certified by the path-set repair; obtain owner approval before any live stop/delete/copy.
set -euo pipefail
cd /opt/homelab/services/phoenix || exit 1
docker compose down
docker run --rm \
-v phoenix_phoenix-db-data:/data \
-v /tmp/restore-phoenix/var/lib/docker/volumes/phoenix_phoenix-db-data/_data:/backup:ro \
alpine sh -c "rm -rf /data/* && cp -a /backup/. /data/"
docker run --rm \
-v phoenix_phoenix-data:/data \
-v /tmp/restore-phoenix/var/lib/docker/volumes/phoenix_phoenix-data/_data:/backup:ro \
alpine sh -c "rm -rf /data/* && cp -a /backup/. /data/"
docker compose up -d
Verify: https://phoenix.infra.realemail.app loads through Authentik and
LiteLLM's full trace test can query the litellm project.
Wazuh (retired)¶
The stack and its Docker volumes were removed. Do not restore it. Historical restic snapshots remain in the existing repositories and were not forgotten.
Order Ledger (tier 1)¶
Backs up /var/lib/homelab/orders. The pre-hook writes a consistent SQLite
copy to orders.backup.sqlite3. Live orders.sqlite3 files and
gmail_token.json are excluded. The Google OAuth client is SOPS state under
/etc/homelab/orders, not this restic path.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/orders
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/orders/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-orders || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/services/orders/backup.yml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-orders --restore || exit 1
Stop here: validate all staging, including the SQLite backup, before any stop or live copy. The following application recovery context needs separate owner review; this path-set repair does not certify database replacement or token recovery.
set -euo pipefail
cd /opt/homelab/services/orders || exit 1
docker compose --env-file compose.env stop
sudo install -d -o 10001 -g 10001 -m 0750 /var/lib/homelab/orders
sudo cp -a /tmp/restore-orders/var/lib/homelab/orders/orders.backup.sqlite3 \
/var/lib/homelab/orders/orders.sqlite3
sudo chown 10001:10001 /var/lib/homelab/orders/orders.sqlite3
sudo env ANSIBLE_CONFIG=/opt/homelab/infra/ansible/ansible.cfg \
ansible-playbook -i /opt/homelab/infra/ansible/inventory/generated.yml \
/opt/homelab/infra/ansible/playbooks/site.yml --limit infra-services \
--tags orders,orders-secrets \
-e ansible_connection=local
docker compose --env-file compose.env up -d
Sign in through Authentik and use Connect Gmail again. The refresh token
is not in the backup. Confirm orders-bot becomes healthy after the next
Telegram poll.
Authentik outpost (tier 3)¶
The infra-services outpost is stateless. Its token is SOPS-managed, and the authoritative provider/outpost state lives in the Authentik server on saltierpoop.
set -euo pipefail
cd /opt/homelab/services/authentik-outpost || exit 1
python3 /opt/homelab/scripts/render-sops-env.py \
--source .env.sops.yaml --output .env --require AUTHENTIK_TOKEN || exit 1
docker compose up -d
If the token is invalid or lost, recreate it from the Authentik admin flow in
authentik-infra-admin-setup.md, update
.env.sops.yaml, and redeploy the outpost.
External Services (not on infra-services)¶
Saltbox Secrets (saltierpoop)¶
The encrypted sources are secrets/appliances/saltierpoop/accounts.sops.yaml
and settings.sops.yaml. The destinations are /srv/git/saltbox/accounts.yml
and /srv/git/saltbox/settings.yml, as selected by
saltierpoop.yml.
Re-apply the saltbox-host secrets tasks after recovering the host's age key.
The obsolete roles/saltbox-host/files/accounts.sops.yaml and /opt/saltbox/
paths are not used. Homelab does not restore or redeploy Saltbox containers.
Authentik DB (saltierpoop)¶
The external-authentik-db snapshot contains a gzip-compressed pg_dumpall
from the someone PostgreSQL role. Its captured path is
/var/backups/external-staging/authentik-db/authentik.sql.gz, not the remote
host's temporary dump path.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/external-authentik-db
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/authentik-db.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-authentik || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/authentik-db.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-authentik --restore || exit 1
gzip -t /tmp/restore-authentik/var/backups/external-staging/authentik-db/authentik.sql.gz
Validate every staged root and that dump against an isolated compatible PostgreSQL
instance first. Do not transfer data, stop writers, or import into production before
all staging validates and the owner approves a separate application recovery plan.
The following import context is not certified by the path-set repair.
For a live recovery, transfer the reviewed dump to saltierpoop, stop Authentik
application writers through Saltbox, and restore to the intended database
cluster. A cluster-wide SQL dump includes roles and databases; review conflicts
before applying it to a populated cluster. Do not feed compressed bytes to psql.
The import form, once the recovery target is prepared, is:
read -r -p "Absolute path to the validated Authentik dump on this host: " AUTHENTIK_DUMP
gzip -dc "$AUTHENTIK_DUMP" \
| docker exec -i authentik-postgres psql -X -v ON_ERROR_STOP=1 -U someone -d postgres
Restart Authentik through Saltbox and verify login, OIDC and outpost health. This audit checked the fetch script and manifest, not a live Authentik restore.
PostgreSQL Companion (LXC 102)¶
The external-postgresql-companion repo contains custom-format dumps for
companion, tdler, and reddit_explorer, cluster globals, expected application-table row
counts, and SHA256SUMS. Its primary encrypted repo is on the Whrrr NAS and,
as tier 1, is also copied to B2.
# Restore from the primary NAS repository.
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/external-postgresql-companion
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/postgresql-companion.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-postgresql-companion || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/postgresql-companion.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-postgresql-companion --restore || exit 1
# Validate without opening ports or contacting the live PostgreSQL LXC.
sudo /opt/homelab/backups/restore-drills/postgresql-companion.sh \
/tmp/restore-postgresql-companion/var/backups/external-staging/postgresql-companion
For total-loss recovery, use the offsite pattern
with repository suffix external-postgresql-companion and absolute manifest
/opt/homelab/backups/external/postgresql-companion.yaml; review IDs in B2, then
stage into an absent /tmp/restore-postgresql-companion target. Do not reuse a local attempt.
The weekly restore-test includes this isolated PostgreSQL 18 container drill;
the new helper wiring is source work, not evidence of a deployed or successful run.
Only after all staging and the drill validate should an operator seek approval for
a live-cluster restore. The drill removes its temporary container and does not
contact 192.168.6.252; the staging helper never cleans up recovery evidence.
HAOS Config¶
Full snapshots (~2 GiB). NAS (/mnt/infra-backups/homelab-restic) is the
primary restic repo; B2 is a safety net only (keep-last 2 on B2; weekly
full + daily incrementals). Local staging under /var/backups/external-staging
is cleared after a successful backup.
From NAS (primary):
export RESTIC_REPOSITORY=/mnt/infra-backups/homelab-restic/external-haos-config
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/haos-config.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-haos || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/haos-config.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-haos --restore || exit 1
From B2 (total-loss / offsite DR):
source /etc/restic/b2.env
read -r -p "Recovery B2 bucket name: " B2_BUCKET
export RESTIC_REPOSITORY="b2:${B2_BUCKET}:external-haos-config"
export RESTIC_PASSWORD_FILE=/etc/restic/password
restic snapshots --json || exit 1
read -r -p "Reviewed offsite snapshot ID: " SNAPSHOT_ID
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/haos-config.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-haos || exit 1
read -r -p "Type STAGE after reviewing the JSON plan: " STAGING_APPROVAL
test "$STAGING_APPROVAL" = STAGE || exit 1
python3 /opt/homelab/backups/restore-snapshot.py \
--manifest /opt/homelab/backups/external/haos-config.yaml \
--snapshot "$SNAPSHOT_ID" --target /tmp/restore-haos --restore || exit 1
NAS and B2 are alternatives: /tmp/restore-haos must be absent for either attempt.
Validate all staged files and the HAOS archive before any upload or application
restore; root existence does not certify the archive. After separate owner approval,
restore the snapshot .tar via the HA Supervisor API or manually upload through
the HAOS UI at Settings > System > Backups. This application step is not certified
by the path-set repair.