Self-hosting
Backups and restore
Automatic daily encrypted snapshots are built in. Two manual methods cover migrations and pre-upgrade snapshots.
Automatic backups (built in, on by default)
Every instance ships a backup service (pg_dump + restic) that needs no
configuration: daily at 02:00 UTC it snapshots the database, uploads, state,
and secrets into the encrypted backups volume, keeping the last 3 daily
snapshots plus 1 weekly and pruning the rest. Snapshots are encrypted at rest
(AES-256) with a password generated into the secrets volume on first boot.
| What | Default |
|---|---|
| Schedule | Daily 02:00 UTC (BACKUP_SCHEDULE) |
| Destination | Local backups volume (BACKUP_REPOSITORY) |
| Retention | 3 daily + 1 weekly (BACKUP_KEEP_DAILY, BACKUP_KEEP_WEEKLY) |
| Contents | Database dump, uploads, state, secrets, Caddyfile, bot policy |
docker compose exec backup backup-now # run one now (e.g. before upgrading)
docker compose exec backup restic snapshots # list snapshots
./backup/restore.sh # restore the latest snapshot
./backup/restore.sh 562229b2 # restore a specific snapshot
Going off-site (R2, S3, MinIO, B2)
Point the repository at any S3-compatible endpoint — only the endpoint and keys change, nothing else. No vendor SDK is involved and the format stays restic’s open one, so moving providers later is the same one-line change.
BACKUP_REPOSITORY=s3:https://<account-id>.r2.cloudflarestorage.com/omicron-backups
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
BACKUP_S3_REGION defaults to auto (what R2 wants); set a real region for
AWS. Then docker compose up -d and one backup-now to confirm a snapshot
lands. All variables are listed under Environment variables.
Manual methods
The automatic snapshots cover routine protection. The two manual methods below remain useful for host migrations and for a byte-for-byte copy before a risky upgrade. Both are verified end to end: seed, back up, destroy, restore, data intact.
All state lives in named volumes; nothing important is inside a container image.
| Volume | Holds | In backups? |
|---|---|---|
pgdata |
All database content | Yes (Method B) or via dump (Method A) |
uploads |
User-uploaded media | Always |
secrets |
Generated DB password + bootstrap session secret | Method B only |
state |
UI-rotated session secret, if any | Method B only |
caddy_data |
Let’s Encrypt certificates | Optional |
redis_data |
Transient queue state | Never |
backups |
Automatic restic snapshots (above) | N/A — this is the backup |
redis_data is deliberately excluded: it holds only pending jobs and in-flight
federation deliveries, which Redis re-drains after a restart. No durable content
lives there.
Commands assume the default Compose project name omicron (the directory name),
so volumes are omicron_pgdata, omicron_uploads, and so on. Swap the prefix if
you run with -p <name>. Everything works the same under podman — substitute
the command.
Both methods below are verified end to end: seed, back up, destroy, restore, data intact.
Method A — logical dump (portable, recommended)
Portable across Postgres major versions and safe to run on a live instance. On
restore, the instance generates a fresh database password and session secret
— so secrets is not backed up — which means everyone is signed out once.
That is expected for a restore or migration.
Back up
# SQL dump + the two file volumes that hold real content.
docker compose exec -T postgres \
pg_dump -U omicron -Fc omicron > omicron-db-$(date +%F).dump
for v in uploads caddy_data; do
docker run --rm -v omicron_$v:/v:ro -v "$PWD":/backup alpine:3 \
tar czf /backup/omicron-$v-$(date +%F).tgz -C /v .
done
Restore onto a fresh host
docker compose up -d postgres # regenerates secrets, inits an empty DB
sleep 5
docker compose exec -T postgres \
pg_restore -U omicron -d omicron --clean --if-exists < omicron-db-YYYY-MM-DD.dump
for v in uploads caddy_data; do
docker run --rm -v omicron_$v:/v -v "$PWD":/backup alpine:3 \
sh -c "rm -rf /v/* && tar xzf /backup/omicron-$v-YYYY-MM-DD.tgz -C /v"
done
docker compose up -d
Do not restore an old secrets tarball here — its db_password will not
match the freshly initialised pgdata. caddy_data is optional; skipping it just
re-issues certificates on first request.
Method B — full volume snapshot (simple, version-locked)
Copies every volume as-is, so pgdata and its matching secrets stay consistent
and sessions survive. Tied to the same Postgres major version, and you must
stop the stack first so the database files are copied at rest.
Back up
docker compose stop
for v in pgdata uploads state secrets caddy_data; do
docker run --rm -v omicron_$v:/v:ro -v "$PWD":/backup alpine:3 \
tar czf /backup/omicron-$v-$(date +%F).tgz -C /v .
done
docker compose start
Restore onto a fresh host
for v in pgdata uploads state secrets caddy_data; do
docker run --rm -v omicron_$v:/v -v "$PWD":/backup alpine:3 \
sh -c "tar xzf /backup/omicron-$v-YYYY-MM-DD.tgz -C /v"
done
docker compose up -d
Restore the whole set or none of it.
Choosing between them
| Method A | Method B | |
|---|---|---|
| Runs on a live instance | Yes | No — requires a stop |
| Portable across Postgres versions | Yes | No |
| Sessions survive restore | No | Yes |
| Backup contains your secrets | No | Yes |
| Simplicity | One dump + two tarballs | Five tarballs |
Method A for routine backups and host migrations. Method B when you want a byte-for-byte snapshot before a risky upgrade.
Automating it
The built-in daily snapshots above are the automation — no cron to write. If
you disabled the sidecar (BACKUP_ENABLED=false), Method A from host cron is
the fallback:
0 4 * * * cd /opt/omicron && docker compose exec -T postgres \
pg_dump -U omicron -Fc omicron > /var/backups/omicron-$(date +\%F).dump
Test your restores
A backup you have never restored is a hypothesis. Restore into a throwaway VM or
a second Compose project (-p omicron-test) at least once, and confirm you can
sign in and read a post.
Found a mistake?Edit this page on GitHub.