Backups
What to back up in a Lahijan install, how to do it with the shipped scripts and plain Docker commands, and the order to restore in.
A Lahijan install keeps state in PostgreSQL, the SeaweedFS data volume, the Incus directory on the host and the environment file. This page lists each one, shows how to copy it, and gives the order to restore in. Test a full restore on a spare machine before you depend on it.
All commands run from the install directory (/opt/lahijan by default).
What to back up
| Item | Location | Covered by scripts/backup.sh |
|---|---|---|
| Lahijan database (users, tenants, audit log, billing, jobs, plugin modules) | lahijan database in container lahijan-prod-postgres |
Yes |
| PowerDNS zones and records | pdns database |
Yes |
| SeaweedFS metadata and S3 identities | seaweed database |
Yes |
| PostgreSQL roles | cluster globals | Yes (pg_dumpall --roles-only) |
| Object data | Docker volume lahijan-prod-seaweedfs |
No, see the warning below |
| Plugin volume | Docker volume lahijan-prod-plugins |
Yes |
| Incus instances, images, storage pools and database | host directory /var/lib/incus |
Yes, when run as root |
| Secrets and settings | deployments/.env.prod |
No |
| Caddy snippets | deployments/caddy/conf.d/ |
No |
| Config file, if you mount one | for example deployments/lahijan.yaml |
No |
| TLS certificates | volumes lahijan-prod-caddy-data, lahijan-prod-caddy-config |
No (Caddy issues new ones) |
| Metrics and dashboards | volumes lahijan-prod-prometheus, lahijan-prod-grafana |
No |
Caution
Without LAHIJAN_AUTH_SECRETS_ENCRYPTIONKEY you cannot read encrypted columns (TOTP secrets, external identity tokens, stored credentials) in a restored database. Without LAHIJAN_AUTH_SIGNING_KEY every session and email link becomes invalid. Keep a copy of .env.prod in a separate, secure place from the backup archives.
The backup script
scripts/backup.sh --install-dir /opt/lahijanOptions: --install-dir DIR (default /opt/lahijan) and --out FILE to choose the archive path. The script:
- Dumps the three databases with
pg_dump -Fc --no-owner --no-privilegesasPOSTGRES_SUPERUSER, plus the roles. - Archives a SeaweedFS volume and
lahijan-prod-pluginswith a temporaryalpine:3.22container. - Archives
/var/lib/incusfrom the host. It skips this if the directory does not exist and continues with a warning iftarfails. - Writes a
MANIFESTwith the image tags and database names. - Bundles everything as
lahijan-backup-<UTC stamp>.tar.gzinLAHIJAN_BACKUP_LOCAL_DIR(default./backups). - Uploads the archive with
aws s3 cpwhenLAHIJAN_BACKUP_S3_BUCKETis set, then prunes local and uploaded archives older thanLAHIJAN_BACKUP_RETENTION_DAYS(default 14).
Run it daily from root's crontab:
0 2 * * * /opt/lahijan/scripts/backup.sh --install-dir /opt/lahijan >> /var/log/lahijan-backup.log 2>&1Warning
The script reads object data from a volume named lahijan-prod-seaweed, but the compose file names it lahijan-prod-seaweedfs. Docker creates an empty volume under the wrong name, so the seaweedfs.tgz in the archive is empty and your objects are not backed up. Until the script is fixed, back up the object data volume yourself as shown below.
The script copies SeaweedFS and Incus data while the services run. For a consistent copy of those, stop the services first (next section).
Manual commands
PostgreSQL
set -a; . deployments/.env.prod; set +amkdir -p backupsfor db in "$LAHIJAN_DATABASE_NAME" "$PDNS_DATABASE_NAME" "$SEAWEED_DATABASE_NAME"; do docker exec lahijan-prod-postgres pg_dump -U "$POSTGRES_SUPERUSER" -d "$db" -Fc > "backups/$db.dump"donedocker exec lahijan-prod-postgres pg_dumpall -U "$POSTGRES_SUPERUSER" --roles-only > backups/roles.sqlpg_dump gives a consistent snapshot while the stack runs.
SeaweedFS object data
Stop the SeaweedFS services so no volume file changes during the copy, archive the volume, then start them again:
docker compose --env-file deployments/.env.prod -f deployments/docker-compose.prod.yml \ stop seaweed-s3 seaweed-filer seaweed-volume seaweed-master docker run --rm -v lahijan-prod-seaweedfs:/data:ro -v "$PWD/backups":/out alpine:3.22 \ tar -C /data -czf /out/seaweedfs.tgz . docker compose --env-file deployments/.env.prod -f deployments/docker-compose.prod.yml \ start seaweed-master seaweed-volume seaweed-filer seaweed-s3The file metadata that goes with this data is in the seaweed database. Back up both at the same time.
Incus
Incus state is in /var/lib/incus on the host. For a consistent copy, stop the incus service (plan for instance downtime), archive the directory as root and start it again:
docker compose --env-file deployments/.env.prod -f deployments/docker-compose.prod.yml stop incussudo tar -C /var/lib/incus -czf backups/incus.tgz .docker compose --env-file deployments/.env.prod -f deployments/docker-compose.prod.yml start incusWith a large dir storage pool this archive can be big. Users can also back up their own instances from the dashboard; see Snapshots and backups.
Configuration and certificates
cp deployments/.env.prod /secure/location/lahijan.env.prodtar -czf backups/caddy-conf.d.tgz deployments/caddy/conf.ddocker run --rm -v lahijan-prod-caddy-data:/data:ro -v "$PWD/backups":/out alpine:3.22 \ tar -C /data -czf /out/caddy-data.tgz .Caddy data is optional: without it Caddy requests new certificates, which counts against Let's Encrypt rate limits if you restore often.
Restore
Restore in this order:
- Host and code. Prepare the host as for a new install, clone the repository and check out the same release as the backup (
lahijan_image_taginMANIFEST). - Secrets. Put back the original
deployments/.env.prod. The signing and encryption keys must be the ones used when the backup was taken. - Databases. Restore
lahijan,pdnsandseaweed. - Object data. Restore the
lahijan-prod-seaweedfsvolume. - Plugins and Incus. Restore
lahijan-prod-pluginsand/var/lib/incus. - Start.
docker compose ... up -d. Themigratejob then brings the schema up to the checked-out release if needed.
scripts/restore.sh handles steps 3 to 5 from an archive made by backup.sh, with one gap for object data described below:
sudo scripts/restore.sh --install-dir /opt/lahijan --backup backups/lahijan-backup-20260101T020000Z.tar.gzIt stops every service except PostgreSQL, drops and recreates the three databases, restores the roles and each dump, replaces the plugin volume and, when run as root, replaces /var/lib/incus. It asks you to type the value of LAHIJAN_PUBLIC_HOST to confirm; --yes skips the prompt. When it finishes, it prints the command to start the stack again. The first-run admin bootstrap is skipped because users already exist, so sign in with the restored accounts.
Warning
Like the backup script, restore.sh writes object data into lahijan-prod-seaweed instead of lahijan-prod-seaweedfs. Restore the object volume yourself before starting the stack:
docker run --rm -v lahijan-prod-seaweedfs:/data alpine:3.22 sh -c 'rm -rf /data/* /data/.[!.]* 2>/dev/null; true'docker run --rm -v lahijan-prod-seaweedfs:/data -v "$PWD/backups":/out:ro alpine:3.22 \ tar -C /data -xzf /out/seaweedfs.tgzAfter the stack is up, check that it is healthy and that DNS, buckets and instances are all there:
docker inspect --format '{{json .State.Health.Status}}' lahijan-prod-appdocker compose --env-file deployments/.env.prod -f deployments/docker-compose.prod.yml exec incus incus list --all-projects