Backup¶
The minimum backup of install-design.md, as built. One script, scripts/ihc-backup.sh, run on a schedule by a small container, writing an encrypted restic repository and a status file the portal reads.
What a run does¶
- Takes the lock (
<destination>/.lock, an atomicmkdir). A second run while one is live refuses to start. - Starts a hard timeout of
BACKUP_MAX_RUN_MINUTES. A run past it is killed with every child process (pg_dump, restic), recorded asfailedwith the reason, and the lock is released for tomorrow. - Opens the restic repository
<destination>/resticwithBACKUP_ENCRYPTION_KEY, creating it on the first run, and clears restic locks a killed run may have left. - For the shared Postgres and for Authentik's Postgres:
pg_dump -Fcof every non-template database andpg_dumpall --globals-only(roles). Each dump streams frompg_dumpthrough a sha256 counter straight intorestic backup --stdin. Nothing is staged on disk. - Backs up the named volumes
gitea-data,proxy-data,proxy-letsencrypt,authentik-media,authentik-templates,authentik-certs, andgarage-datawhen thestorageprofile is on. They are read live, read-only. The databases are not among them: those are the dumps. Garage's metadata database ingarage-datais copied live too; Garage also writes a consistent copy of it every 6 hours undermeta/snapshots/, which a restore can start from if the live copy does not open. - Backs up the install directory (
config.yaml,.env,compose.yml,state.json,inventory.json), withoutbackups/andlocal/. The.envholds every generated secret, so it only exists inside the encrypted repository. - Applies retention per item (
restic forget --group-by host,tags --prune):BACKUP_RETENTION_DAILY/WEEKLY/MONTHLY, 7/4/12 by default. Each dump, the volumes and the config are their own group, so one item never pushes another out. - Writes the manifest
reports/runs/<run_id>.json(every item with its snapshot id; dumps with sha256 and size) and the status file. The last 100 manifests are kept.
The destination is a host directory: the interactive installer asks for it right after the Gitea organization; in a config file it is backup.destination; IHC_BACKUP_DESTINATION at install time wins over both; it is kept as BACKUP_DESTINATION in .env; default <install-dir>/backups. Point it at another disk or mount: preflight warns when it is on the same disk as the installation, and warns when it is not writable.
The status file¶
<destination>/reports/latest-status.json, rewritten at the start and at the end of every run. This is the portal's contract:
{
"schema_version": 1,
"status": "success",
"run_id": "20261019T020000Z",
"started_at": "2026-10-19T02:00:00Z",
"finished_at": "2026-10-19T02:01:12Z",
"duration_seconds": 72,
"snapshot_id": "<restic snapshot id of the last item>",
"error": null,
"repository": "/backups/restic",
"schedule": "0 2 * * *",
"timezone": "UTC",
"max_run_minutes": 240,
"max_age_hours": 26,
"manifest": "runs/20261019T020000Z.json",
"manifest_sha256": "<sha256 of the manifest>",
"items": 9,
"bytes_dumped": 1843200
}
status is running, success or failed. A failed run carries error in words: the timeout, the step that failed, or "its process disappeared" when the container was stopped mid-run (the next run or status notices the dead lock and records it). reports/latest-success.json is a copy of the newest successful status; alarm on the age of its finished_at against max_age_hours, not on latest-status.json alone.
Commands¶
From the host, with the install directory:
scripts/ihc-backup.sh --install-dir /opt/inhousecloud run
scripts/ihc-backup.sh --install-dir /opt/inhousecloud verify --latest [--json]
scripts/ihc-backup.sh --install-dir /opt/inhousecloud status [--json]
On the host the script only forwards to the backup container (docker compose exec, or a one-off run when the container is stopped), so the host needs no restic and no Postgres client.
runexits non-zero on failure. The failure is also in the status file.verify --latest --quickis whatihc-doctorruns: the same checks without restoring the dumps (the snapshots must exist, andrestic checkmust pass), a few seconds on a small repository.verify --latestis smoke test 14. It checks that the newest run succeeded and is younger thanmax_age_hours, that the manifest matches the sha256 in the status file, and that the repository opens with the key. It then checks that every snapshot in the manifest exists, restores every dump throughrestic dumpto compare its sha256 with the one recorded at backup time, and runsrestic check.statusprints the status file plus the age of the newest success and exits non-zero when it is stale or failed.
Scheduling¶
The installer uses a sidecar container, backup in docker/compose.yml, the Compose profile backup, on unless backup.enabled: false. Its image (docker/backup/Dockerfile: Alpine, restic, the Postgres client matching POSTGRES_IMAGE) runs busybox crond with BACKUP_SCHEDULE in BACKUP_TZ. The job's output goes to the container log (docker compose logs backup).
Why not the host's cron: it works on Linux without root, but it would need restic and a Postgres client on the host, or a Docker call from cron, and on macOS cron jobs need Full Disk Access for many paths. The sidecar is the same on both and needs nothing on the host.
The host-cron alternative, if you prefer it, is one line in your user's crontab (crontab -e). Do not also leave the sidecar's schedule on, or the lock will refuse the second run.
0 2 * * * /path/to/inhousecloud/scripts/ihc-backup.sh --install-dir /opt/inhousecloud run >> /opt/inhousecloud/backups/reports/cron.log 2>&1
Restoring¶
Run these inside the container, where restic and the key are present:
docker compose --project-directory /opt/inhousecloud -f /opt/inhousecloud/compose.yml exec backup sh
restic snapshots --tag item:shared-postgres__gitea.dump # pick a snapshot
restic dump <snapshot> /shared-postgres__gitea.dump > /tmp/gitea.dump
Then restore into a scratch database to look, or into the real one with Gitea stopped:
PGPASSWORD="$POSTGRES_PASSWORD" createdb -h postgres -U "$POSTGRES_USER" gitea_restore
PGPASSWORD="$POSTGRES_PASSWORD" pg_restore -h postgres -U "$POSTGRES_USER" -d gitea_restore --no-owner /tmp/gitea.dump
Volumes and the config: restic restore <snapshot> --target /tmp/restore --include /volumes/gitea-data. Copying them back into a named volume is done with the service stopped.
Losing .env is recoverable only if you kept BACKUP_ENCRYPTION_KEY elsewhere: .env itself is inside the repository it unlocks.
Next¶
Not built yet, in the order they matter:
- Encrypted off-site copy (
backup.remote):restic copyto a second repository, S3-compatible or rclone, with its own key. A box that dies with its disks takes the local repository with it. - Monthly restore rehearsal (
backup.restore_test): restore one database per month into an isolated Postgres container, compare row counts, and restore everything once a year. Todayverifyproves the bytes are there, not that the restore works. - The portal's backup card reading
latest-status.jsonand alarming on age. That belongs to the portal project; the file is ready. - Consistent volume snapshots (stopping Gitea for the length of its volume backup, or
gitea dump), when a box has enough write traffic for a live copy to matter.