Self-hosting
Backups and restore
Two verified backup strategies — a portable logical dump, or a full volume snapshot. Pick one, don't mix them.
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 |
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
Run Method A from cron and ship the files off the box:
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.