Backup & restore
The complete state of a Schleuse instance is two things: the database and the /app/data directory (the app_data volume). Back up both and you can restore the IdP anywhere.
What to back up
| What | Where | Why it matters |
|---|---|---|
| Database | Postgres, or schleuse.db inside /app/data (SQLite) | users, credentials, roles, app metadata, OIDC tokens, audit log, email codes |
data/jwt-secret.txt | /app/data | HMAC secret for session cookies |
data/jwt-private-key.pem | /app/data | RSA key for OIDC ID-token / JWKS signing |
Don't lose the keys
Key resolution is env var > persisted file > auto-generate. If a restored instance is missing jwt-private-key.pem, it silently generates a new one — which changes the JWKS kid (all issued ID/JWT-access tokens fail verification) and changes every confidential client secret (they're derived as sha256(key + clientId)). A missing jwt-secret.txt logs everyone out. Always back up /app/data, or set NUXT_JWT_SECRET / NUXT_JWT_PRIVATE_KEY explicitly so the values are reproducible from your secret store.
Your config is env-only (.env / compose), so version-control or secret-store those separately — they aren't in /app/data.
The database does not grow without bound. The OIDC token table holds one row per access token, refresh token, authorization code, session and grant, and used to keep expired rows forever — which mattered most for a service client, since client_credentials mints a token per call and never reuses one. Expired rows are now deleted hourly, so dump size tracks live state rather than lifetime issuance. Nothing to configure, and nothing you can lose by restoring an older dump: an expired token is already unusable.
Backup
Database — Postgres
# dump (compressed)
docker exec schleuse-db pg_dump -U schleuse_user schleuse | gzip > schleuse-$(date +%F).sql.gzA nightly cron is enough for most deployments:
0 3 * * * docker exec schleuse-db pg_dump -U schleuse_user schleuse | gzip > /backups/schleuse-$(date +\%F).sql.gzThe file is live (WAL mode), so don't cp it under load and don't tar the whole volume — that captures stale -wal/-shm files that corrupt the snapshot. Use SQLite's online backup to a single clean file. The app image is a minimal Node image (no sqlite3 CLI), so run it from a throwaway alpine container on the same app_data volume, writing straight to the host:
docker run --rm -v app_data:/data -v "$PWD:/out" alpine \
sh -c "apk add -q sqlite && sqlite3 /data/schleuse.db \".backup '/out/schleuse-$(date +%F).db'\""Simplest alternative — stop the app and copy the file directly:
docker compose stop app
docker run --rm -v app_data:/data -v "$PWD:/out" alpine cp /data/schleuse.db /out/schleuse-$(date +%F).db
docker compose start appThe keys (/app/data)
The signing keys are tiny static files — back them up separately (for Postgres the volume holds only these):
docker run --rm -v app_data:/data -v "$PWD:/out" alpine \
tar czf /out/keys-$(date +%F).tgz -C /data jwt-secret.txt jwt-private-key.pemRestore
Restore onto a fresh host (or after data loss). Stop the app first so nothing writes mid-restore.
- Stop the app:
docker compose stop app(ordownif recreating). - Restore the keys into the volume:bash
docker run --rm -v app_data:/data -v "$PWD:/in" alpine \ tar xzf /in/keys-YYYY-MM-DD.tgz -C /data - Restore the database:
- SQLite — copy the clean snapshot in as
schleuse.dband make sure no stale WAL files come with it:bashdocker run --rm -v app_data:/data -v "$PWD:/in" alpine \ sh -c "rm -f /data/schleuse.db /data/schleuse.db-wal /data/schleuse.db-shm; cp /in/schleuse-YYYY-MM-DD.db /data/schleuse.db" - Postgres:bash
gunzip < schleuse-YYYY-MM-DD.sql.gz | docker exec -i schleuse-db psql -U schleuse_user schleuse
- SQLite — copy the clean snapshot in as
- Fix ownership — the
alpinehelper writes files asroot, but the app runs as the non-rootappuser (UID 100 in the published image) and must own its data or it can't write the database ("readonly database"):bashdocker run --rm -v app_data:/data alpine chown -R 100:101 /data - Start the app:
docker compose up -d app. Migrations run automatically on start. - Verify (see below).
Migrations are forward-only
Restoring an older DB into a newer image is fine — migrations auto-apply on startup. Restoring a newer DB into an older image is not supported, so restore into the same or a newer version.
Verify a restore
# 1. App healthy + expected version
curl -s https://auth.example.com/api/ready # {"status":"ready","database":"connected",...}
# A restored database may still need migrations — a brief 503 with
# "database":"migrating" first is expected. Wait for "ready".
# 2. Signing key preserved — the kid must match the pre-restore value
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[0].kid'Then do a real login and one OIDC authorization flow. A matching kid is the proof your RSA key survived — if it changed, you restored without the keys and every relying party will reject tokens until you fix it.
Test your restore
A backup you've never restored is a hope, not a backup. On a schedule, restore the latest archive into a throwaway staging instance and confirm: /api/ready, a login, and an OIDC flow. Catch a broken backup before you need it.
Related
- Deployment → Volumes and Secret rotation — the volume layout and per-secret rotation steps.
- Security → Incident response — what to do when a secret or key is compromised (rather than lost).