What a backup is
A backup ispg_dump --format=custom --no-owner --no-privileges --compress=6 output,
encrypted with AES-256-GCM and written to APP_BACKUP_DIR. Omniflow shells out to
pg_dump and pg_restore rather than reimplementing them, so a backup is always
restorable with standard PostgreSQL tooling even if Omniflow itself will not start.
Encryption is framed rather than a single sealed blob, so a multi-gigabyte dump never has
to fit in memory:
omniflow-<kind>-<UTC timestamp>.dump.enc, for example
omniflow-scheduled-20260811T030000Z.dump.enc. kind is scheduled or manual and
nothing else — the column has a check constraint, so a third kind needs a migration. The
name is unique and the file is created exclusively, so a collision fails rather than
overwrites.
Who takes one
The operator panel is read-only about backups. There is no panel route and no
/v1/admin route that takes or restores one; the only two write paths in the product
are the worker’s schedule and the bot’s /ops surface. That is deliberate — a restore
is destructive, and it lives behind an explicitly enumerated list of Telegram accounts
rather than behind a permission that six roles could be granted.Enabling backups
The bot and worker images ship the matching PostgreSQL 18 client tools. In the compose
stack both mount a shared
backup-data volume at /var/lib/omniflow/backups, so
rebuilding a container never discards backups.
The database password never reaches the child process’s argv, where any local user could
read it from the process list. The DSN is decomposed into PGHOST, PGPORT,
PGDATABASE, PGUSER, PGPASSWORD, and PGSSLMODE in the child’s environment instead.
Verification
A backup is recorded ascompleted only after it has been read back: the file is
decrypted end to end and its SHA-256 digest compared with the digest computed while
writing. A backup that fails either check is deleted and recorded as failed with a
classified error code.
This runs on every backup, not only on demand. An unverified backup is a guess.
Operating from Telegram
Telegram carries operator notifications and backup/restore actions only. Every other administrative control belongs in the web panel./ops — not a refusal, which
would confirm the command exists. The command is also ignored outside a private chat with
the bot.
/ops shows the five most recent backups with file name, status (with the error code when
one is recorded), size, and UTC timestamp, plus:
- 💾 Back up now — takes a manual backup immediately. If one is already running it answers “A backup is already running” rather than starting a second.
- ♻️ Restore latest… — offered only when a completed backup exists. A two-step confirmation: the first tap only renders the warning, and the second carries the backup identifier the operator saw.
- 🔄 Refresh — re-renders the list.
backup.created for a manual backup, then
backup.restore_requested and backup.restored around a restore. The restore’s reason is
recorded as “restore requested from Telegram by operator <id>”.
Restoring
A restore verifies the backup first, then runspg_restore --clean --if-exists --no-owner --no-privileges --single-transaction with the
decrypted stream on stdin. It is deliberately blunt, which is why it insists the caller
has confirmed the destructive intent and recorded who asked.
It refuses rather than half-running in these cases:
Before restoring:
- Stop
api,bot, andworker. A live worker will keep provisioning against state that is about to be replaced. - Take a fresh backup of the current database, even if you believe it is broken. It is the only way back from a mistaken restore.
- Confirm the target backup’s status is
completed.
- Start
workerfirst and watch/readyzand the fulfillment topic. - Start
apiandbot. - Reconcile with Remnawave. Provisioning that happened after the backup point is still live in the panel but absent from Omniflow. Scheduled drift detection reports the mismatches within fifteen minutes, and each one is resolvable from the drift view.
- Re-check pending payments. A payment that settled after the backup point is picked up by the reconciliation poller or by a provider webhook retry. Both are idempotent, so nothing is charged or credited twice.
/ops, so its log is where
a failure is described even though the on-screen message mentions the worker.
Restoring without the bot
The worker binary doubles as a decryption filter, because disaster recovery must not depend on the rest of the stack being able to start. It needs nothing but the binary, the key, and the file:--decrypt-backup must be the first argument, and the key is read from the
environment before any configuration is loaded — no database, no Valkey, and no Remnawave
credential is required. It fails with APP_BACKUP_ENCRYPTION_KEY is required to decrypt a backup or APP_BACKUP_ENCRYPTION_KEY must be base64-encoded 32 bytes, and on a wrong key
or a damaged file it fails loudly rather than emitting partial output.
Atlas records applied migrations in its own
atlas_schema_revisions schema rather than
in public. A dump taken by Omniflow carries the whole database, so an ordinary restore
brings that history with it. If you rebuild a database by other means, carry that schema
across too, or the migrate service will try to re-apply history and the diagnostics
bundle will report zero applied migrations.Retention and pruning
Each backup row is stamped withretain_until at creation, APP_BACKUP_RETENTION_DAYS
into the future. After every scheduled run the worker prunes up to 100 expired backups:
the file is deleted and the row is marked pruned. The row is kept, so the history of
which backups existed stays auditable after the files are gone.
A file that cannot be removed is logged and skipped, and its row keeps its status, so the
next pass tries again rather than losing track of it.
Reviewing history
A backup nobody has ever restored is a backup nobody knows works, which is why the
restore history sits beside the backup list rather than on a separate screen.
Upgrading Omniflow
Migrations are forward-only and applied by themigrate service before any application
process starts.
- Take a backup and confirm it is
completed. - Pull the new images. Pin an explicit tag; floating tags make a rollback ambiguous.
- Run
docker compose up -d. Themigrateservice runsatlas migrate apply, and every other service waits for it to complete successfully. - Watch
/readyzon each process.
The compose
migrate service uses a hard-coded connection string and does not read
APP_DATABASE_URL. If you changed the database credentials, change them in
compose.yaml as well or the migrator will connect to the wrong database.Rolling back
Omniflow does not ship down-migrations. A schema rollback is a restore:- Stop
api,bot, andworker. - Restore the backup taken before the upgrade.
- Pin the previous image tags and start the stack.
- Reconcile with Remnawave as described above.
Upgrading PostgreSQL
Omniflow supports PostgreSQL 18. To move between major versions:- Stop
api,bot, andworker; leavepostgresrunning. - Take a backup and verify it.
- Stop
postgresand start the new major version against an empty data directory. - Restore the backup.
- Start the application processes.
pg_upgrade is the
alternative if you prefer an in-place upgrade, but the dump-and-restore path is the one
this backup format is designed for. Remember that pg_dump and pg_restore must match
the server major version; the shipped images pin the PostgreSQL 18 client tools for
exactly this reason.
Disaster-recovery drill
Run this quarterly. An untested backup is an assumption.completed backup, decrypts it through worker --decrypt-backup, restores it into a
throwaway PostgreSQL instance on the stack’s own network, compares row counts, opens a
sealed value with APP_DATA_ENCRYPTION_KEY, and removes the instance again. It never
writes to the live database, and it exits non-zero when the drill fails, so a scheduled run
means something.
Pass COMPOSE when the stack uses override files, and TABLES to compare a different set:
The sealed-value step is the one most often skipped and the one that most often fails: a
database restored without its data-encryption key loads perfectly and has unreadable
secrets. The script opens a sealed customer-OIDC client secret in a throwaway container, so
it needs nothing installed on the host. An installation with no OIDC provider configured has
no sealed value for it to check, and it says so rather than passing quietly — check a TOTP
secret or a provider credential by hand before trusting that backup.
Record the date and the outcome.
Beyond the database: full disaster recovery
Everything above answers “PostgreSQL is lost or corrupted.” A host-level disaster is a wider question, and the answer to most of it is: nothing here needs a special procedure, because PostgreSQL is the only durable store — but two things outside it still need a plan.Losing the whole host
Apg_dump restore brings back every row. It does not bring back what only ever lived
outside the database:
.env, or whatever holds it in your secrets manager.APP_DATA_ENCRYPTION_KEY. This is the one to get right before anything else. Every sealed value — TOTP secrets, OIDC client secrets, provider credentials, customer contact values — is permanently unopenable without it, and there is no re-encryption path. See Security.APP_BACKUP_ENCRYPTION_KEY, stored separately from the backups it decrypts — a backup that carries both is a single artefact that opens itself.APP_OPERATOR_TOKEN, since nothing issues a new one; a lost token means re-bootstrapping the bearer surface.- The reverse-proxy configuration and TLS material in front of the stack, which lives in your own estate rather than in Omniflow. See Reverse proxy.
- DNS, pointed at whatever host replaces the lost one.
Losing Valkey
This needs no restore at all. Valkey holds cache entries, rate-limit counters, locks, and single-use replay tokens — nothing durable — so a lost Valkey instance is a fresh one started empty. See Deployment. Automatic maintenance mode detection watches Valkey along with PostgreSQL and Remnawave, so an outage pauses new purchases after the same failure streak as any other dependency, rather than customers hitting rate-limit and replay-guard failures inconsistently mid-checkout.RPO and RTO
Stated from what is actually configurable, not a promised figure this page cannot keep:- Recovery point objective is governed by
APP_BACKUP_INTERVAL_HOURS— the default is 24 hours between scheduled backups, so up to that much recent activity can be lost in the worst case. Shrink the interval for a tighter RPO; a manual backup from/opsbefore a risky operation shrinks it to zero for that moment. - Recovery time objective is dominated by restore time, which scales with database size, plus the reconciliation window after the stack comes back — Remnawave drift resolves within fifteen minutes of the scheduled reconcile pass, and in-flight payments settle on the next reconciler tick. There is no fixed number to promise here; size your own drill’s duration as the baseline for your installation.
Related
Deployment
Compose topology, volumes, and the upgrade path these procedures assume.
Troubleshooting
What each backup error code means and where to read the log.