Back up and restore Repod¶
Repod's relational data (users, manifests index, inventory, install jobs,
package search, SSH ssh_known_hosts fingerprints) lives in PostgreSQL
(DATABASE_URL, postgres_data volume). The filesystem under /repos/
holds everything else: package artifacts, manifests JSON, repo metadata
trees, GPG keyring, CVE caches, and audit logs. This guide explains what to
back up, how to run and schedule backup.sh, how to copy backups offsite,
and how to perform a full restore.
1. What to back up¶
| Data | Location | Importance | Notes |
|---|---|---|---|
| Application database | PostgreSQL (DATABASE_URL, postgres_data volume) |
Critical | Users, manifests index, inventory, install jobs, package search, TOFU SSH fingerprints. Back up with pg_dump -F c (custom format, restorable with pg_restore). |
| Settings | /repos/settings.json |
Critical | LDAP, OIDC, CVE policy, scheduler, mirror config. |
| GPG keyring | /repos/gnupg/ |
Critical | Private signing key. Loss means generating a new key and reconfiguring every client machine. |
| Audit logs | /repos/audit/*.jsonl |
High | Append-only JSONL files, one per day. Needed for compliance and incident investigation. |
| Security/CVE data | /repos/security/ |
High | RSSI CVE review decisions and KEV/EPSS caches. |
| Package manifests | /repos/manifests/ |
Medium | JSON metadata for each package. Can be rebuilt by re-indexing the package pool, but this takes time. |
| Package pool | /repos/pool/ |
Medium | .deb/.rpm/.apk canonical store. Large, but included by default by backup.sh. Can be rebuilt by re-uploading or re-importing packages from upstream. |
GPG keyring loss is painful
If you lose the GPG keyring without a backup, you must generate a new signing key and distribute it to every client machine that trusts your repository. This is a significant operational burden in large fleets. Treat the gnupg/ directory as critical infrastructure.
2. Using backup.sh¶
The backup.sh script is included at the root of the Repod repository. It
runs pg_dump against DATABASE_URL (custom format, -F c) and archives it
together with the critical /repos/ paths into a timestamped .tar.gz file.
Basic usage:
By default, the archive is written to ./backups/:
backups/repod_backup_20260601_020000.tar.gz
├── postgres.dump ← pg_dump -F c (all relational data)
├── pool/
├── settings.json
├── audit/
├── security/
├── manifests/
└── gnupg/
Configuration via environment variables:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
— | Required. PostgreSQL connection string. The script fails (fail()) if unset or if pg_dump is not installed — it never produces an archive without a database dump. |
BACKUP_DIR |
./backups |
Directory where archive files are written. |
REPOS_DIR |
./repos |
Directory holding the non-relational /repos/ data. |
BACKUP_RETENTION_DAYS |
30 |
Archives older than this many days are deleted automatically. Set to 0 to disable pruning. |
Example with custom paths:
DATABASE_URL=postgresql://repod:CHANGE_ME@localhost:5432/repod \
BACKUP_DIR=/mnt/nas/repod-backups \
BACKUP_RETENTION_DAYS=90 \
./backup.sh
Dry run — print what would be backed up without creating an archive:
Connecting to PostgreSQL from the host
The db service (PostgreSQL) is not published to the host by default.
Either temporarily publish container port 5432, or run pg_dump inside
the repod-db container and copy the dump out before archiving:
Legacy SQLite installs
If /repos/auth/users.db still exists (a pre-PostgreSQL-migration
install that hasn't been cleaned up), backup.sh also backs it up via
sqlite3 .backup for safety. Current installations do not have this file.
Built-in alternative
Repod also ships an admin-triggered backup that produces the same
archive format via the API (POST /api/v1/backup/) or Settings →
Backups, plus a daily scheduled job (backup_daily). See
Backup & Restore (operations) for
details on the integrated mechanism and restore procedure.
3. Scheduling with cron¶
Run backups automatically by adding an entry to the crontab of the user who owns the Repod data:
Add a daily backup at 02:00 UTC, logging output to a file:
# Repod daily backup — 02:00 UTC
0 2 * * * cd /opt/repod && DATABASE_URL=postgresql://repod:CHANGE_ME@localhost:5432/repod \
BACKUP_DIR=/mnt/nas/repod-backups BACKUP_RETENTION_DAYS=60 \
./backup.sh \
>> /var/log/repod-backup.log 2>&1
Verify the cron job is registered:
Check the log after the first scheduled run:
Tip
Rotate the log file with logrotate to prevent it from growing unbounded. Create /etc/logrotate.d/repod-backup with a weekly rotation and 4-week retention policy.
4. Offsite copy¶
A backup that lives on the same host as the data it protects is not a real backup. Copy archives to a separate location after each run.
# Add this after ./backup.sh in your cron job, or in a wrapper script
rsync -avz --delete \
/mnt/nas/repod-backups/ \
nas-user@nas.example.com:/volume1/backups/repod/
Use SSH key authentication so this runs unattended. Restrict the NAS user to SFTP and the backup directory.
Tip
For S3-compatible targets, enable object versioning or object lock on the bucket so that a ransomware event on the Repod host cannot overwrite your offsite copies.
5. Restore procedure¶
Follow these steps to restore Repod from a backup archive. Read through the entire procedure before you start.
Step 1 — Stop the stack
Step 2 — Identify and extract the archive
ls -lht /mnt/nas/repod-backups/
# Pick the most recent clean backup, e.g.:
ARCHIVE=repod_backup_20260601_020000.tar.gz
mkdir -p /tmp/repod-restore
tar -xzf /mnt/nas/repod-backups/$ARCHIVE -C /tmp/repod-restore
ls /tmp/repod-restore/
Step 3 — Restore the PostgreSQL database
docker compose up -d db
sleep 5 # wait for PostgreSQL to accept connections
# --clean --if-exists drops and recreates objects safely on an empty database
BACKUP_NAME="${ARCHIVE%.tar.gz}"
docker exec -i repod-db pg_restore -U repod -d repod --clean --if-exists \
< "/tmp/repod-restore/$BACKUP_NAME/postgres.dump"
Step 4 — Copy /repos/ data back
The volume mount paths depend on your docker-compose.yaml. Adjust the destination paths to match your setup:
RESTORE_DIR="/tmp/repod-restore/$BACKUP_NAME"
# Settings
cp "$RESTORE_DIR/settings.json" /opt/repod/repos/settings.json
# Audit logs (merge, not overwrite, to preserve any logs written since the backup)
cp -n "$RESTORE_DIR"/audit/*.jsonl /opt/repod/repos/audit/
# GPG keyring
rm -rf /opt/repod/repos/gnupg/
cp -r "$RESTORE_DIR/gnupg/" /opt/repod/repos/gnupg/
chmod 700 /opt/repod/repos/gnupg/
# Manifests, security data, package pool
cp -r "$RESTORE_DIR/manifests/." /opt/repod/repos/manifests/
cp -r "$RESTORE_DIR/security/." /opt/repod/repos/security/
cp -r "$RESTORE_DIR/pool/." /opt/repod/repos/pool/
Warning
Set the correct ownership on restored files before starting the containers. If the backend container runs as a non-root user (UID 1000 by default), ensure the restored files are owned by that UID: chown -R 1000:1000 /opt/repod/repos/
Step 5 — Start the stack and re-initialize repo metadata
The APT/RPM/APK repository metadata trees (dists/, rpm/, apk/) are not
part of the backup — they are regenerated from pool/ and the restored
database:
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YourPassword"}' | jq -r .access_token)
curl -X POST http://localhost:8000/api/v1/distributions/init \
-H "Authorization: Bearer $TOKEN"
Step 6 — Verify
# Check all containers are healthy
docker compose ps
# Confirm the API responds
curl -s http://localhost:8000/health | jq .
# Confirm packages are visible
curl -s http://localhost:8000/api/v1/packages/?distribution=jammy | jq '.total'
# Test apt update on a client machine
sudo apt update
6. Testing restores¶
A backup you have never tested is a backup you cannot trust.
Test restores quarterly using this checklist:
- Spin up a separate VM or container with a clean Docker installation.
- Copy the most recent backup archive to the test VM.
- Follow the restore procedure above on the test VM.
- Confirm the API returns the expected package count.
- Confirm
apt update(ordnf/zypper/apk update) succeeds against the test instance. - Confirm the web UI is accessible, login works, and settings look correct.
- Document the time taken from archive copy to verified restore.
Record the results in your operations runbook. If a restore takes longer than your RTO (recovery time objective), investigate whether the archive size, network speed, or procedure can be optimised.
7. GPG keyring warning¶
Lost GPG backup = mandatory key rotation
If your restore fails because the GPG keyring backup is missing or corrupted, you cannot recover the original signing key. You must:
- Generate a new GPG key via Settings → GPG → Generate key.
- Distribute the new public key to every client machine.
- Remove the old key from every client machine's trusted keyring (
/etc/apt/trusted.gpg.d/,rpm --import,/etc/apk/keys/, depending on format).
This is a significant effort in large fleets. See Rotate GPG signing keys for the full procedure. Preventing this situation by verifying your gnupg/ backup is included in every archive is strongly recommended.