Operations
Backup and restore
Ragmux keeps all of its state in one PostgreSQL database plus one secret. Back up both, and you can rebuild a gateway on any machine.
Updated:
Ragmux keeps all of its state in one PostgreSQL database plus one secret. Back up both, and you can rebuild a gateway on any machine. This guide covers what to back up, the bundled scripts, scheduled backups, physical backups, the restore runbook and how to rehearse a disaster.
What is state
| Where | What |
|---|---|
PostgreSQL (ragmux-data volume, /data/pg, with the default all-in-one file; pgdata volume with docker-compose.split.yml) |
users and sessions, model connections (provider API keys, AES-256-GCM encrypted), RAG stores, documents (the uploaded files as bytea), chunks, one chunk_embeddings_<dims> table per embedding width (HNSW vector indexes), projects and their hashed sk-proj-… keys, request logs, usage counters, login attempts, audit log, schema_migrations |
SECRET_KEY |
the 32-byte key (64 hex characters) that decrypts the provider API keys stored in the database. The database records which key that is (instance_settings), and a restore with the wrong one fails at start rather than silently |
/data/ragmux/secret.key (all-in-one, only when SECRET_KEY is unset) |
the generated fallback key; it lives in the same volume as the database, so a volume backup covers it, a pg_dump does not |
With the split layout the gateway container itself is stateless: it can be deleted and
recreated at any time. With the all-in-one layout the container is disposable too, as
long as the ragmux-data volume stays.
Since 0.4 a wrong key stops the start instead of every provider call. The database
holds a canary in instance_settings that only the key it was written with can open, so
a dump restored onto a host with a different SECRET_KEY fails at boot with
SECRET_KEY does not match the one this database was written with (key source: …)
rather than looking healthy until the first chat request. Put the matching key back — see
SECRET_KEY.
Without the key at all, the fastest way back is a database whose canary you own:
restore the dump, drop the canary row (DELETE FROM instance_settings WHERE key = 'secret_key_canary') so the next start seals a new one, and then re-enter every provider
API key in the dashboard (Models -> Edit) — users, projects, documents and vectors all
come back, only the encrypted credentials are lost. Keep the key in a
secret manager (or your deployment’s env store) and in the backup bundle, encrypted
at rest or in a separate location from the dumps. scripts/backup.sh can write it next
to the dump with INCLUDE_SECRET_KEY=1 (see below).
GET /admin/api/system reports what a backup will contain, with sample values —
migrations_version and version are whatever the running build reports:
{
"backup": {"tables": 1, "documents_bytes": 1048576, "last_migration_at": "2026-09-18T12:34:41Z"},
"database": {"postgres_version": "17.11", "pgvector_version": "0.8.6", "migrations_version": N, "size_bytes": 8787635},
"secret_key_source": "env",
"version": "<x.y.z>"
}
backup.tables is the number of chunk_embeddings_<dims> tables, documents_bytes the
sum of the stored document contents; the same numbers are shown on the dashboard
overview. ragmux -version prints the binary version.
Logical backups with pg_dump
The recommended format is pg_dump -Fc (custom format): compressed, verifiable with
pg_restore --list, and restorable selectively. By hand, with the default all-in-one
container (Postgres is reached over the container-local unix socket as postgres):
docker compose exec -T -u postgres ragmux pg_dump -Fc --no-owner --no-privileges -d ragmux \
> backups/ragmux-$(date +%Y%m%d-%H%M%S).dump
and with the split stack:
docker compose -f docker-compose.split.yml exec -T postgres \
pg_dump -Fc --no-owner --no-privileges -U ragmux -d ragmux \
> backups/ragmux-$(date +%Y%m%d-%H%M%S).dump
Notes:
- The dynamically created
chunk_embeddings_<dims>tables are ordinary tables and are included automatically; so are their HNSW indexes (rebuilt on restore, which is the slowest part of a large restore). - The dump contains
CREATE EXTENSION vector. The target server must have pgvector available (pgvector/pgvector:pg17does; on managed Postgres enable it first). pg_dumpruns in a consistent snapshot, so you can take it while the gateway is running. Documents that are being ingested at that moment are restored in theirpending/processingstate; the gateway claims them again after a restart, once any lease recorded in the dump has expired.- Use the same major version of
pg_dump/pg_restoreas the server (17). Running the tools inside the container that holds Postgres (ragmuxin the all-in-one layout,postgresin the split one) guarantees that.
Restoring a ParadeDB dump onto a plain pgvector server
A dump taken from one of the ParadeDB variants carries
pg_search objects that a pgvector/pgvector:pg17 target cannot create. pg_restore
reports each one and exits 1, even though every ragmux table restores:
ERROR: extension "pg_search" is not available
ERROR: relation "paradedb._typmod_cache" does not exist
ERROR: access method "bm25" does not exist
Note what that list means: dropping idx_chunks_bm25 before the dump is not enough on
its own. The BM25 index is only one of the entries — the paradedb schema, the
CREATE EXTENSION pg_search, its comment and the extension’s own _typmod_cache table
are dumped too, and a restore that only lost the index still fails.
The recipe that works is a TOC filter. pg_dump has no --exclude-index, so list the
archive, drop the entries the target cannot create, and restore through the edited list.
First find them. Do not assume the list is the ParadeDB ones: every extension present
in the source database is in the dump, and the split ParadeDB image
(docker-compose.paradedb.yml) ships more than pg_search — postgis,
postgis_topology, postgis_tiger_geocoder, pg_ivm, pg_stat_statements and
fuzzystrmatch are all installed in its default database, none of which a
pgvector/pgvector:pg17 target has. The all-in-one :<version>-paradedb image installs
only vector and pg_search, so there the ParadeDB filter alone is complete.
pg_restore -l ragmux.dump | grep 'EXTENSION -'
Compare that against the target (SELECT name FROM pg_available_extensions;) and filter
on what is missing. For an all-in-one ParadeDB dump that is:
pg_restore -l ragmux.dump > full.list
grep -viE 'pg_search|paradedb|bm25' full.list > filtered.list
pg_restore -U ragmux -d ragmux --no-owner -L filtered.list ragmux.dump
For a split ParadeDB dump, add the rest (postgis|pg_ivm|pg_stat_statements|fuzzystrmatch| tiger|topology|spatial_ref_sys). Check the filter before running it: grep matches
anywhere in the TOC line, so a pattern like bm25 or paradedb would also drop a table
or index of your own whose name contains it, silently. diff full.list filtered.list
shows exactly what is being dropped.
A filtered restore finishes with no errors and exit 0. The database is complete:
chunks.content and chunks.tsv are what the lexical half reads, and idx_chunks_bm25
is a derived object that ragmux rebuilds lazily — on a ParadeDB server, on the first
hybrid search of a store using the pg_search backend.
Nothing else needs changing. A rag_stores row keeps search_backend = "pg_search"
through the restore and the gateway falls back to pgvector for it — hits still come
back, the search response names the backend that really ran, and one warning per store
is logged (see Search backends). Restoring the same dump back
onto a ParadeDB server later finds the setting still there and rebuilds the index; on a
large corpus that first search is the one that pays for the build, so consider building
it ahead of time (see the upgrade notes in CHANGELOG.md).
Roles are cluster-global and are never in a database dump, so create ragmux_app on the
target before restoring a split-layout dump, whichever recipe you use.
Scripts
Both scripts live in scripts/, run with bash, print -h help, exit 0 on success,
1 when the dump/restore itself fails and 2 for usage or configuration errors. They
never print DATABASE_URL, passwords or SECRET_KEY, run with umask 077 so every file
they create is private to the invoking user, and never put a credential on a command
line (restore.sh sends the login body through stdin and the bearer token through a
private header file, so neither shows up in ps or shell history).
Where they connect
- If
DATABASE_URLis set andpg_dump/pg_restoreare onPATH, the scripts use the local tools against that URL (external or managed Postgres, or the binary without Compose). - Otherwise they use
docker compose. Run them from the directory holdingdocker-compose.yml, or setCOMPOSE_FILE(COMPOSE_FILE=docker-compose.split.ymlfor the split stack). The Compose project is chosen the usual way:-p NAME,COMPOSE_PROJECTor the standardCOMPOSE_PROJECT_NAMEenvironment variable, whichdocker composehonours by itself. The layout is detected from the project (LAYOUT=auto): when it has a<POSTGRES_SERVICE>service (postgres) the scripts rundocker compose exec -T postgres …as before (split); otherwise they treat the<RAGMUX_SERVICE>container (ragmux) as the all-in-one image and run the tools inside it as thepostgresuser over the unix socket (aio).LAYOUT=split|aiooverrides the detection.
scripts/backup.sh
scripts/backup.sh # -> ./backups/ragmux-YYYYmmdd-HHMMSS.dump
BACKUP_DIR=/mnt/backups KEEP_DAYS=30 INCLUDE_SECRET_KEY=1 SECRET_KEY_DIR=/mnt/keys scripts/backup.sh
make backup # same as scripts/backup.sh
| Variable | Default | Meaning |
|---|---|---|
BACKUP_DIR |
./backups |
output directory (created with mode 700 if missing) |
KEEP_DAYS |
14 |
delete ragmux-*.dump / .key files older than N days; 0 keeps everything |
LAYOUT |
auto |
split or aio to skip the detection described above |
POSTGRES_SERVICE, RAGMUX_SERVICE |
postgres, ragmux |
Compose service that runs Postgres (split) / the all-in-one container |
POSTGRES_DB |
ragmux |
database |
POSTGRES_USER |
ragmux (split), postgres (aio) |
superuser the dump runs as |
DATABASE_URL |
unset | use local pg_dump against this URL instead of Compose |
INCLUDE_SECRET_KEY |
0 |
1 writes SECRET_KEY (from the environment, else from .env) to SECRET_KEY_DIR/<name>.key with mode 600 |
SECRET_KEY_DIR |
BACKUP_DIR |
where the .key file goes. Keep it apart from the dumps: a dump and its key on the same disk let anyone who reads that disk decrypt every provider credential. The script warns when both land in the same directory |
COMPOSE_PROJECT / -p |
unset | Compose project name |
What it does: pg_dump -Fc --no-owner --no-privileges into <name>.dump.partial,
pg_restore --list on the result (a corrupt or truncated file fails here and is
deleted), rename to <name>.dump, print the size, then rotate. The final line on
stdout is the path of the new dump, so it composes with other tooling:
f=$(scripts/backup.sh) && aws s3 cp "$f" s3://my-bucket/ragmux/
scripts/restore.sh
scripts/restore.sh backups/ragmux-20260918-153455.dump # prints the plan, exit 2
scripts/restore.sh --yes backups/ragmux-20260918-153455.dump # runs it
ADMIN_USER=admin ADMIN_PASSWORD=… scripts/restore.sh --yes … # also reports migrations_version
make restore FILE=backups/ragmux-20260918-153455.dump YES=1
| Variable | Default | Meaning |
|---|---|---|
LAYOUT |
auto |
split or aio to skip the layout detection |
POSTGRES_SERVICE, RAGMUX_SERVICE |
postgres, ragmux |
Compose services |
POSTGRES_DB |
ragmux |
database |
POSTGRES_USER |
ragmux (split), postgres (aio) |
superuser the restore runs as |
DATABASE_URL |
unset | use local pg_restore against this URL instead of Compose |
STOP_CMD, START_CMD |
unset | in DATABASE_URL mode: shell commands run before and after the restore (e.g. systemctl stop ragmux) |
APP_ROLE |
ragmux_app |
role that receives ownership of the restored tables (step 4) |
RAGMUX_URL |
http://localhost:8765 |
where to poll /healthz |
WAIT_TIMEOUT |
120 |
seconds to wait for the gateway |
ADMIN_USER, ADMIN_PASSWORD |
unset | when both are set, log in and print migrations_version, vector table count and document bytes from /admin/api/system |
Steps, in order:
- Verify the file with
pg_restore --list(in the all-in-one layout through a throwawaydocker compose run --rm --entrypoint pg_restore ragmux --list, before anything is stopped). - Stop the gateway (
docker compose stop ragmux; inDATABASE_URLmode runSTOP_CMDor warn). This matters:--cleandrops tables and blocks on open connections. All-in-one: stopping theragmuxservice stops the embedded Postgres too, so the script then starts a one-off container on the same volume that runs Postgres alone:docker compose run -d --rm --no-deps ragmux postgres-only, and waits forpg_isreadyinside it (up to 90 s). pg_restore --clean --if-exists --no-owner --no-privileges -d ragmux, throughdocker compose exec -T postgres(split) ordocker exec -i -u postgres <one-off>(aio). Every object in the dump is dropped and recreated, so the target does not need to be empty.- Hand the restored tables to the application role (
APP_ROLE, defaultragmux_app), see Ownership below. - Start the gateway (
docker compose up -d ragmux;START_CMDin direct mode). All-in-one: the one-off container is stopped first (docker stop, a cleanpg_ctl stop -m fast), then the normal service comes up. Migrations run at startup. - Poll
/healthzand, if admin credentials are set, print the system info.
When the restore fails the gateway is left stopped (and, all-in-one, the one-off container is stopped as well) so you can investigate before migrations run on a half-restored schema. Done by hand, the all-in-one sequence is:
docker compose stop ragmux
cid=$(docker compose run -d --rm --no-deps ragmux postgres-only)
until docker exec -u postgres "$cid" pg_isready -q; do sleep 1; done
docker exec -i -u postgres "$cid" pg_restore --clean --if-exists --no-owner --no-privileges -d ragmux < <dump>
docker stop "$cid" # clean Postgres shutdown; the container removes itself
docker compose up -d ragmux # the entrypoint re-runs 01-ragmux.sql: tables handed to ragmux_app
Ownership. pg_restore runs as the superuser (POSTGRES_USER) with --no-owner,
so every restored table belongs to the superuser, while the gateway connects as the
least-privilege role ragmux_app (see Database privileges).
Reads would fail and the next migration’s ALTER TABLE too, so right after the restore
the script changes the owner of every table and sequence in public to APP_ROLE
(default ragmux_app) and grants it ALL on them. The step is skipped with a note when
the role does not exist (a deployment that still uses the superuser DATABASE_URL), and
in DATABASE_URL mode it needs psql on PATH (otherwise a warning tells you to run it
by hand: ALTER TABLE ... OWNER TO ragmux_app for each table, or simply re-run
docker/postgres-init/01-ragmux.sql, whose last block does the same). The all-in-one
entrypoint runs that SQL on every start anyway, so a restore done by hand there is
fixed up by the next docker compose up -d.
Extension errors. The dump contains DROP EXTENSION IF EXISTS vector and
CREATE EXTENSION vector. On the bundled Postgres (either layout) the restore runs as the superuser,
so both succeed silently. On servers where the extension was created by a superuser and
the restore runs as a plain role (managed Postgres, shared clusters) the drop fails and
the create then reports extension "vector" already exists. pg_restore marks any failed statement with a
non-zero exit, so the script reads its stderr instead of using --exit-on-error:
errors mentioning extension are printed as ignored, any other pg_restore: error:
line fails the restore with exit 1, and the gateway is left stopped so you can
investigate without it re-running migrations on a half-restored schema.
Leftover tables. --clean only drops what the dump contains. If the target
already had a chunk_embeddings_<dims> table for a width the dump does not include,
that table survives, empty of anything the restored stores reference. It is harmless;
drop it by hand if you want a tidy schema.
Scheduled backups (Compose profile)
Both Compose files ship a backup service under the backup profile that runs
prodrigestivill/postgres-backup-local:17:
docker compose --profile backup up -d # starts ragmux (and postgres) plus backup
docker compose --profile backup exec backup /backup.sh # trigger a run right now
With the default all-in-one file the backup container reaches the embedded Postgres
over the unix socket shared through the ragmux-pgsocket volume (POSTGRES_HOST=/var/run/postgresql,
user postgres, trust authentication; the POSTGRES_PASSWORD the image insists on is a
placeholder). With the split file it connects over TCP with POSTGRES_PASSWORD.
It dumps ragmux on SCHEDULE (default @daily, cron syntax accepted) into
./backups/{last,daily,weekly,monthly}/ragmux-<stamp>.sql.gz and keeps
BACKUP_KEEP_DAYS=7, BACKUP_KEEP_WEEKS=4, BACKUP_KEEP_MONTHS=6 of history. Override
these and BACKUP_SCHEDULE in .env. The image’s own healthcheck listens on 8081
inside the container (not published).
These files are plain SQL, gzip-compressed, not the custom format restore.sh
expects. Restore one with psql after stopping the gateway; split stack:
docker compose -f docker-compose.split.yml stop ragmux
gunzip -c backups/daily/ragmux-20260918.sql.gz \
| docker compose -f docker-compose.split.yml exec -T postgres psql -v ON_ERROR_STOP=0 -U ragmux -d ragmux
docker compose -f docker-compose.split.yml up -d ragmux
All-in-one (a one-off Postgres on the volume, as restore.sh does it):
docker compose stop ragmux
cid=$(docker compose run -d --rm --no-deps ragmux postgres-only)
until docker exec -u postgres "$cid" pg_isready -q; do sleep 1; done
gunzip -c backups/daily/ragmux-20260918.sql.gz \
| docker exec -i -u postgres "$cid" psql -v ON_ERROR_STOP=0 -d ragmux
docker stop "$cid"
docker compose up -d ragmux
The scheduled dumps use CREATE EXTENSION IF NOT EXISTS, but they do not DROP existing
tables, so restore them into an empty database (docker compose down -v first, or a
fresh CREATE DATABASE). Point the backups directory (or an rsync/object-storage job
reading it) at storage that is not on the same disk as the database volume.
Physical backups and point-in-time recovery
Logical dumps give you an RPO equal to the dump interval. If losing more than a few
minutes of request logs, uploads or key rotations is unacceptable, add continuous WAL
archiving with pgBackRest or
wal-g: a periodic base backup plus every WAL segment,
restorable to any second. This is worth it when the database is large (dumps take
long and rebuild HNSW indexes on restore), when ingestion is continuous, or when the
gateway’s request log is your audit trail. It needs a sidecar or a custom Postgres
image with the tool installed and access to the data directory; keep SECRET_KEY in the
same disaster-recovery bundle exactly as with dumps.
Managed Postgres (Amazon RDS/Aurora, Cloud SQL, Azure Database, Neon, Supabase,
Crunchy Bridge…): use the provider’s automated snapshots and PITR; they cover the
chunk_embeddings_* tables like any other. Still take a periodic pg_dump for
off-provider copies and for moving between providers, and still keep SECRET_KEY
outside the provider. Enable the vector extension on the target before restoring.
Restore runbook
Prerequisites: a dump, the matching SECRET_KEY, the Ragmux image version you intend
to run, and a Postgres 17 with pgvector (the all-in-one image brings its own).
- Stop the gateway so nothing writes during the restore:
docker compose stop ragmux(all-in-one: this stops the embedded Postgres too;restore.shbrings a Postgres-only container up for the restore). - Restore:
scripts/restore.sh --yes <dump>(it stops and starts the gateway for you in either layout), or by hand as shown underscripts/restore.sh: split stackdocker compose exec -T postgres pg_restore --clean --if-exists --no-owner --no-privileges -U ragmux -d ragmux < <dump>followed by the ownership hand-over the script performs (re-runningdocker/postgres-init/01-ragmux.sqlas in Database privileges does it); all-in-one, thepostgres-onlysequence, after which the entrypoint’s own start does the hand-over. - Start with the right
SECRET_KEYin.env:docker compose up -d ragmux. Migrations run automatically under an advisory lock.- Restoring an older dump into a newer Ragmux is supported:
schema_migrationscomes from the dump and only the missing versions are applied on start. - Restoring a newer dump into an older Ragmux is not: the old binary does not know the newer schema. Upgrade the image first.
- Restoring an older dump into a newer Ragmux is supported:
- Verify (or let the script do the first three):
curl -fsS http://localhost:8765/healthzreturns{"status":"ok",…}.- Log in to
/admin/with a known user. GET /admin/api/system:database.migrations_versionmatches the app’s expected version,backup.tablesandbackup.documents_byteslook like the source.- Models tab:
api_key_maskedis shown and Test chat succeeds for one connection (this provesSECRET_KEYis right). - RAG store: run a search in the dashboard and get hits.
- Projects: the
api_key_prefixvalues are the ones your clients use; make one realPOST /v1/chat/completionswith an existingsk-proj-…key.
- Take a fresh backup of the restored system so the chain of dumps continues.
Disaster-recovery drill
Rehearse on a scratch project at least quarterly and before every major upgrade:
-
scripts/backup.shon production (or fetch the latest scheduled dump). - On a clean machine or with a different Compose project name
(
COMPOSE_PROJECT_NAME=ragmux-drill, differentports:),docker compose up -d(a freshragmux-datavolume; the dump may come from either layout). -
scripts/restore.sh --yes <dump>with the productionSECRET_KEY. - Run the verification checklist above; record how long the restore took (your RTO).
- Restore once without the key to see the decryption error, so the team knows what “lost the key” looks like and where the key lives.
-
docker compose down -vthe drill stack.
| Setup | Typical RPO | Typical RTO | Notes |
|---|---|---|---|
Manual scripts/backup.sh |
since last run | minutes | fine for evaluation and small teams |
backup profile, @daily |
up to 24 h | minutes | set BACKUP_SCHEDULE="0 */6 * * *" for 6 h |
backup profile + off-site copy |
up to one interval | tens of minutes | survives loss of the host |
| WAL archiving (pgBackRest / wal-g) | seconds to minutes | tens of minutes | large databases, continuous ingestion |
| Managed Postgres PITR | provider SLA (usually minutes) | provider SLA | plus a periodic pg_dump off-provider |
Rules of thumb:
- Back up before every upgrade.
docker compose pull && scripts/backup.sh && docker compose up -d. Migrations are forward-only. - Keep at least 7 daily, 4 weekly and 6 monthly dumps, and one copy off the host.
- Alert when the newest dump is older than twice the schedule.
- Keep dumps from before a
SECRET_KEYrotation together with the old key.
Moving between layouts
The all-in-one volume (ragmux-data, /data/pg) and the split stack’s pgdata volume
have different names and directory structures, and nothing converts one into the other:
moving between the layouts (including from the 0.3.0 two-container docker-compose.yml
to the default single-container one) is a dump and a restore.
- Take a dump on the old layout:
scripts/backup.sh(it detects the layout; add-f docker-compose.split.yml/COMPOSE_FILEwhen the split file is the old one). - Stop the old stack:
docker compose [-f docker-compose.split.yml] down(keep the volume until the new layout is verified). - Start the new layout on a fresh volume with the same
SECRET_KEYin.env:docker compose [-f docker-compose.split.yml] up -d(the split file also needsPOSTGRES_PASSWORDandRAGMUX_DB_PASSWORD). scripts/restore.sh --yes <dump>, then the verification checklist above.- Once verified, remove the old volume (
docker volume rm <project>_pgdataor<project>_ragmux-data).
A deployment that used the secret.key fallback (all-in-one without SECRET_KEY) must
copy /data/ragmux/secret.key out of the volume first
(docker compose cp ragmux:/data/ragmux/secret.key ./secret.key) and set its content as
SECRET_KEY on the new layout.
Rotating SECRET_KEY
Rotate the key when it may have leaked, when someone who knew it leaves, or on a
schedule. ragmux rotate-key re-encrypts every stored provider API key in one
transaction, verifies each row decrypts with the new key before committing and prints
the number of rows changed. It needs the current key in the environment
(SECRET_KEY, SECRET_KEY_FILE or the secret.key fallback) and DATABASE_URL.
-
Take a backup (
scripts/backup.sh) and keep the old key with it. -
Stop the gateway so nothing writes a credential with the old key meanwhile:
docker compose stop ragmux. -
Generate the new key and run the rotation with the old one still configured:
NEW_KEY=$(openssl rand -hex 32) docker compose run --rm ragmux rotate-key --new "$NEW_KEY" # rotate-key: re-encrypted 3 model connection(s); start the gateway with the new SECRET_KEYIn the all-in-one layout that one-off container starts the embedded Postgres on the volume for the duration of the command and shuts it down cleanly afterwards (the stopped service must not be running at the same time, hence step 2).
With the binary:
SECRET_KEY=<old> DATABASE_URL=… ragmux rotate-key --new "$NEW_KEY". -
Put the new key into
.env(or the secret file) and start the gateway:docker compose up -d ragmux. -
Verify: Test chat on a model connection succeeds. If the gateway was started with the wrong key it refuses to start at all (
SECRET_KEY does not match the one this database was written with); put the right key back, nothing was lost.
Rows are only rewritten when every one of them decrypts with the current key and
re-decrypts with the new one; otherwise the command exits 1 and the database is
unchanged. The same transaction re-seals the instance_settings canary, so the first
start after a rotation accepts the new key and refuses the old one. Dumps taken before
the rotation still need the old key.
Since 0.2.3 each stored key is also bound to its connection id (key_version = 1 in
model_connections), so a ciphertext moved to another row does not decrypt. Dumps taken
with an earlier version restore fine: the gateway re-seals every key_version = 0 row
with the same SECRET_KEY on its first start and logs how many it changed. That start
fails with upgrade stored provider keys when the key does not match the dump; put the
right key back and start again.
Security notes
Dumps contain the encrypted provider API keys, every uploaded document, password
hashes, the audit log and request logs (which include model names, token counts and
error messages, but not prompts or completions). Treat them like the database itself:
encrypt at rest (age, gpg, or an encrypted bucket), restrict who can read the
backups directory (backup.sh creates it with mode 700 and every file with 600),
and do not store <name>.key next to the dump on the same unencrypted disk unless the
whole disk is encrypted: point SECRET_KEY_DIR somewhere else. When you copy the key
file elsewhere, keep its permissions.