The gateway uses PostgreSQL for request logs, user accounts, API keys, its settings, and every admin/runtime change that outlives a restart. This page covers what is stored, how the schema comes into existence, and how to back it up, restore it, and reset it.
No. DB_ENABLED defaults to true, but a gateway started with DB_ENABLED=false
routes requests normally — it simply has no request history, no user accounts, no
API-key issuance, and no admin surfaces backed by stored settings. It reads
every setting from the environment instead.
The distinction matters when reading /health, because the two "no database"
states are not the same answer:
{"status": "healthy", "routes_configured": 3, "database_configured": false, "database_connected": false}
{"status": "unhealthy", "reason": "database_unavailable_at_startup", "database_configured": true, "database_connected": false}The first is a deployment that asked for no database. The second asked for one and did not get it, and reports unhealthy so a load balancer takes it out of rotation.
Read by Settings in apps/backend/serving/config/settings.py, from .env or
the process environment. They are the settings that stay in the environment,
because the backend needs them before it can read anything from the database.
| Variable | Default | Notes |
|---|---|---|
DB_ENABLED |
true |
Any of false / 0 / no disables the database entirely. |
DB_HOST |
localhost |
The Compose stack overrides this to postgres inside the backend container. |
DB_PORT |
5432 |
In Compose this is the host port mapping; the container always talks to 5432. |
DB_NAME |
hybridinference |
Required by Compose (DB_NAME must be set in .env file). |
DB_USER |
postgres |
Required by Compose. |
DB_PASSWORD |
(empty) | Required by Compose. |
Three more settings govern what the database keeps. They are stored in it, under Privacy on the admin console's Configuration tab:
| Setting | Default | Notes |
|---|---|---|
DB_STORE_FULL_CONTENT |
false |
Whether prompts and responses are stored verbatim; applies after a backend restart. See Request logging and privacy. |
ERASURE_FENCE_SECRET |
(empty) | Secret for the records that keep deleted accounts deleted. It cannot be changed once set; see Secrets for deleting accounts. |
ERASURE_FENCE_PROTOCOL_READY |
false |
Turns on permanent account deletion. See Secrets for deleting accounts. |
Each gateway process opens two connection pools to it (sizes and reasons in
apps/backend/serving/storage/pools.py): a general pool of up to 10
connections for everything, and a request pool of up to 4 reserved for the
lookups a request makes before it is served — API-key authentication and spend,
session checks, quotas, and the runtime settings read on the way. Slow work on
the general pool (reports, exports, rollups) cannot hold those up, and a request
that cannot get a request-pool connection within 3 seconds is answered with a
503 and Retry-After instead of waiting; a statement on the request pool is
stopped after 5 seconds. On the general pool, users' reads of their own request
history (/user/recent-requests, /user/usage) hold at most 4 connections at
once, and those reads and the Responses API's loads and saves wait at most 5
seconds for a connection before the same 503. With the alert-history pool's 4,
a process holds at most 18 connections; size max_connections for that times
the number of gateway processes, plus the tools you run beside them.
The bundled stack (deploy/docker/docker-compose.yml) runs postgres:16,
initialised with -E UTF8 --locale=C.UTF-8, and publishes it on
127.0.0.1:${DB_PORT:-5432} — loopback only. Reach it from another machine with
an SSH tunnel, not by widening that binding.
The schema is versioned. The migrations in apps/backend/migrations/ create and
change every table, and a deploy applies them as a step of its own, with the
image it is about to start, before it restarts the gateway:
make migrate # the bundled Compose stack
docker compose up -d --no-recreate --wait postgres # the same, by hand:
docker compose run --rm --no-deps backend python -m migrations upgrade
cd apps/backend && uv run python -m migrations upgrade # a checkoutThe command reads the gateway's own DB_* settings, and does nothing when
DB_ENABLED is off. make up and make build run it before they start the
services. python -m migrations status shows the database's revision next to
the code's.
The gateway itself creates no tables. When it starts, it compares the revision the database records with its own:
- No revision recorded, or an earlier one of its own: it refuses to start and names the command above.
- Its own revision: it starts.
- A revision it does not know, so a newer one, as after a deploy that restored the previous image: it starts, with a warning, as long as the database still supports its code. A revision that removes something older code uses records the oldest code it still supports, and older code refuses to start there.
The first migration of a database that was running before versioning,
0001_baseline, needs no preparation. It runs the code that used to build the
schema at startup, now frozen in apps/backend/migrations/baseline/: it
creates what the database lacks and records the revision. On a database the
previous release already started on, it changes nothing and takes no strong
table lock.
Two properties are worth knowing before you operate this:
A migration that cannot get its lock stops instead of waiting. Each
statement waits at most 3 seconds for a table lock. A long reader such as a
nightly pg_dump makes the migration fail and roll back; the command exits with
status 75 and lists the sessions holding locks. Nothing switches over: the
running gateway keeps serving, and the same command can simply be run again
later. Avoid the backup window for a release that changes the schema; on a
database already at the latest revision, the command only reads the version.
One migration runs at a time. A second run against the same database waits for the first to finish, and fails if that takes too long, rather than migrating alongside it.
To change the schema, add a revision in apps/backend/migrations/versions/.
A revision must leave the previous release working, because that release keeps
serving while it runs and is what a failed deploy restores. Large indexes on
api_logs are not migrations: they are built in the background
(apps/backend/serving/storage/perf_metrics_index.py), since building them
would block request logging for hours. The rules are in the design record,
docs/agents/specs/2026-11-02-versioned-schema-migrations-design.zh.md.
Each revision says which kind it is. compatibility = "expand" means code of
earlier revisions keeps working on its result; "contract" means it does not,
and a contract raises the compatibility floor with raise_compat_floor. A test
fails until the line is set. A revision that has run on a shared database is
never edited or reverted: fix it with a new one.
When an admin permanently deletes a user, the gateway also records a keyed
fingerprint of that account in the erasure_fence table, so a request-log
write that was already on its way cannot put the user's rows back. Two
settings govern this:
ERASURE_FENCE_SECRETis the key for those fingerprints. The first time the gateway starts against a database, it records a fingerprint of this secret — even before anything has been deleted — and from then on it refuses to start if the secret differs. Left empty, it usesAPI_KEY_SECRETinstead, which the backend generates into the database at that first start unless the environment supplies one, and that value is the one recorded. To give the fence its own secret, setERASURE_FENCE_SECRETto a random value in.envbefore the first start; the backend copies it into the database then. After that neither secret can be changed: the Configuration tab refuses both, and showsERASURE_FENCE_SECRETlocked once the fence usesAPI_KEY_SECRET. AnERASURE_FENCE_SECRETadded to the environment later is not copied in either, with a warning in the log; but while the variable is set, the fence uses it and the backend refuses to start, so remove it. If you hit a mismatch — say, after restoring a backup next to different secrets — restore the original value; never delete fence rows or their metadata to get past it.ERASURE_FENCE_PROTOCOL_READY=trueturns permanent deletion on. Leave itfalseuntil every process that writesapi_logsruns a version that checks the fence.
| Group | Tables | Holds |
|---|---|---|
| Request history | api_logs, api_stats_hourly, provider_hourly_stats, provider_hourly_coverage, request_volume_hourly, user_usage_daily, user_usage_daily_coverage, daily_activity, admin_view_results, admin_view_state |
One row per request (model, provider, tokens, latency, TTFT, status, cost) plus hourly and daily rollups used by the dashboards, the admin Users table and the user dashboard's usage figures, and the last computed result of the admin console's slowest views |
| Settings | app_config |
The settings on the admin console's Settings tab, secrets included; see Settings and first-run setup |
| Accounts and auth | users, organizations, org_members, projects, api_keys, auth_sessions, browser_sessions, browser_session_accounts, login_events, email_verification_tokens, password_reset_tokens, identity_auth_codes |
User records, each account's personal organization, projects and API keys with their own budgets and model allowlists, refresh sessions (each deleted a day after it expires or is signed out) and the accounts signed in on each browser, sign-in history |
| Admin actions | admin_audit_log, signup_allowed_domains, site_settings, site_updates, email_broadcasts, email_broadcast_recipients |
Audited admin changes, signup policy, runtime settings and the first-run setup marker, announcements, broadcast delivery state |
| Runtime routing overrides | provider_definitions, provider_api_keys, provider_route_configs, provider_route_candidates, provider_weight_overrides, disabled_providers, disabled_provider_env_keys, provider_env_key_min_roles, model_visibility_overrides, model_concurrency_exemptions |
Everything the admin console can change about routing without editing YAML — see Runtime configuration from the admin console |
| Cost and quota | user_daily_cost, api_key_daily_cost, spend_counters |
Per-user daily spend used for quota enforcement, the same per API key for the console's per-key spend, and counters for each project scope by day and month, which project budgets enforce |
| RouteWise | routewise_probe_samples, routewise_probe_leases |
Latency probe samples and the lease that stops two workers probing at once |
| Responses API | openai_responses |
Stored /v1/responses state, when content storage is enabled, kept for 30 days from each response's creation; a conversation is stored only while its history is no larger than REQUEST_MAX_BODY_MB |
| Batch inference | batch_jobs, batch_items, batch_attempts, batch_idempotency, batch_files, batch_file_chunks, batch_webhooks, batch_webhook_events, batch_model_limits, batch_user_state, batch_interactive_load, batch_read_leases |
Batches of all three batch APIs with their requests, attempts and results until BATCH_RETENTION_DAYS after they finish; uploaded input files and result files; idempotency keys; webhooks and their deliveries; the per-model batch concurrency caps; and the worker's scheduling state and download leases. See Running batch inference |
| Geo analytics | geo_hourly_coverage, geo_hourly_demand |
Hourly per-country request and token counts; aggregate only, no IP addresses stored |
| Agent grants | agent_grants |
Short-lived, model-scoped capabilities this gateway mints for an external agent control plane |
| Account deletion | erasure_fence |
Keyed fingerprints of permanently deleted accounts; see Secrets for deleting accounts |
\dt on a live database lists every table.
app_configholds one row per stored setting:key(the environment-variable name),value(in the string form that variable takes, such astrue,8ora,b,c),secret,source—admin,setup,env_importorgenerated— andupdated_at/updated_by. Secret values are stored in plaintext; the admin API never returns them. A row wins over the environment even when its value is empty. See Settings stored in the database.site_settingsholds the runtime switches of the admin Settings tab and, once the first administrator exists, the rowsetup_completed_at, which marks first-run setup as done. The application never clears it. While setup is pending, the rowsetup_codeholds the one-time code the backend prints in its log, so read access to the database is enough to create the first administrator; completing setup deletes it.users.emailis empty for the administrator that first-run setup creates; that account signs in withusers.login_name, which is unique regardless of case. Every account from signup has an email address.
DB_STORE_FULL_CONTENT defaults to false, and that default is not a redaction
of the stored text — the text is never written. It is a setting under
Privacy on the admin console's Settings tab, and a change applies
after a backend restart. With it off, api_logs.prompt,
api_logs.response and api_logs.request_payload are inserted as NULL, and
/v1/responses state is not persisted.
Derived, non-content columns are recorded either way, because the dashboards read
them instead of de-TOASTing payloads: token counts, cost, latency and TTFT, the
conversation shape (num_turns, num_user_turns, num_tool_calls), and a
fingerprint of the newest user message (last_user_msg_chars,
last_user_msg_entropy, last_user_msg_hash).
Turning it on stores full prompts and responses. Weigh that against your users' expectations before you do.
A row's size stays bounded even then. A request's conversation (its messages,
system prompt, tool definitions and input) is stored up to 4 Mi characters,
more than any context window holds. The other fields of its body share
64 Ki characters: each is stored whole while they fit, smallest first, and left
out when it does not, so a field the gateway ignores cannot fill the table. A
refused request (a 4xx other than 499) keeps a summary of each of them, of at
most 64 Ki characters. metadata.payload_truncated marks a row from which
anything was cut.
api_logs.session_id groups one conversation's requests together. A client can
set it with the gateway's X-Session-ID header, but coding agents do not send
that header. Each carries a session id of its own instead, and the gateway
reads whichever one the request has:
| Read from | Sent by |
|---|---|
X-Session-ID header |
anything speaking the gateway's own contract; wins whenever present |
session-id / thread-id headers (the session_id / conversation_id spellings too) |
Codex CLI, which stamps its run on every request |
x-session-affinity / x-opencode-session headers |
OpenCode and its Kilo Code fork, which send the affinity header beside X-Session-ID on any provider they do not recognise as their own, and x-opencode-session on one they do |
metadata.session_id or client_metadata.session_id in the request body |
a client that labels the session where it labels everything else; Codex uses client_metadata |
x-claude-code-session-id header |
Claude Code, on every request |
metadata.user_id in the request body |
Claude Code, which packs the device, account and run into Anthropic's one user_id field. Two shapes — see below |
Since version 2.1.78, Claude Code sends metadata.user_id as a JSON object —
{"device_id": …, "account_uuid": …, "session_id": …} — and before that as a
user_<hash>_account_<uuid>_session_<uuid> string. The gateway reads both.
It does not use parent_session_id, which a subagent run carries: that names
the session that started the subagent, not this one.
Some OpenCode versions and the Kilo Code fork send no session id on their newer
request path. OpenCode fixed this upstream (sst/opencode#43188); until Kilo
Code takes the same fix, its requests are recorded without a session.
The admin console's Recent Requests view shows the session under each row's client, and clicking it filters the list to that one conversation.
metadata.session_id_source on the same row names which of those sources the
session came from. Every source is client-supplied, and
nothing is authorized, billed or rate-limited by a session id; a declaration
over 128 characters, or one carrying control characters, is dropped rather than
recorded. Like the derived columns above, the session is recorded whether or not
DB_STORE_FULL_CONTENT is on: it is a label the client put on the request, not
part of the conversation.
The standard lifecycle commands create a private dump and verify it in a throwaway database server:
make backup BACKUP=/private/path/gateway.dump
make restore-check BACKUP=/private/path/gateway.dumpThe dump includes every application document and secret in app_config.
Without BACKUP it goes to .ops/<distribution>/gateway.dump, which neither Git
nor a Docker build includes. The restore check never touches the deployment's
database server: it restores the dump into a throwaway PostgreSQL container of
the same image, with its own volume and internal network, runs this release's
migrations on the copy, so a backup taken before an upgrade passes as it would
restore, and removes the container, volume and network afterwards. It needs
free disk where Docker keeps volumes, normally the disk of the deployment's
database: about twice the database's size plus 1 GiB, or it refuses to start.
The commands below remain available when a manual database restore is required.
pg_dump in custom format, straight out of the container:
docker exec hybridinference-postgres \
pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > hybridinference-$(date +%F).dumpDo not add -t to docker exec: allocating a TTY corrupts the binary stream.
Restore into an existing, running database:
docker exec -i hybridinference-postgres \
pg_restore -U "$DB_USER" -d "$DB_NAME" --clean --if-exists < hybridinference-2026-01-01.dumpStop the backend with docker stop hybridinference-backend before restoring
over a live database; make down would stop Postgres too.
The backup contains every stored setting in plaintext — provider keys, the
SMTP password, and the JWT_SECRET_KEY and API_KEY_SECRET that sign
sessions and protect API keys — as well as hashed API keys and, if
DB_STORE_FULL_CONTENT was ever on, user prompt content. Whoever holds it can
use your provider keys, decrypt users' stored API keys and sign administrator
tokens, so store it as carefully as the credentials themselves.
Deleting the database is more awkward than it looks, because the Postgres volume
is declared external: in deploy/docker/docker-compose.yml:
volumes:
postgres_data:
external: true
name: hybridinference_postgres_datadocker compose down --volumes does not delete an external volume. Nor does
make down, which passes no --volumes at all. A reset that relies on either
silently leaves every row in place. Delete the volume by name:
make down
docker volume rm hybridinference_postgres_data # destroys all data
make up # recreates an empty volumemake up depends on the docker-volumes target, which recreates the named
volume if it is missing, so the stack comes back on an empty database and the
startup initialisers rebuild the schema. The backend then generates new secrets
unless the environment supplies them, and prints a setup code for a new
administrator. Take a dump first if there is any chance you want the data
back.
The runnable example (make demo-reset DISTRIBUTION=example) uses its own,
example-scoped volume and cannot delete hybridinference_postgres_data.
docker exec -it hybridinference-postgres psql -U "$DB_USER" -d "$DB_NAME"Useful starting points: \dt for the table list and \d api_logs for the request
log's columns. make ps shows the published bindings — Postgres should read
127.0.0.1:..., and pgAdmin shows none unless you published it, when it reads
127.0.0.1:... too; anything else means the database is listening beyond this
host.
The Compose stack ships a pgAdmin service for people who prefer a GUI. It is
profile-gated — nothing starts it unless the admin profile is named — and
it is entirely optional; psql above does everything.
The profile must be on every Compose command, not just the first:
make up COMPOSE_PROFILES=admin
make down COMPOSE_PROFILES=adminOmitting it does not fail loudly, it just does the wrong thing in both
directions: a plain make up starts every other service and skips pgAdmin, and a
plain make down leaves the pgAdmin container running while removing everything
around it (Compose then reports the network as still in use). Pass
COMPOSE_PROFILES=admin on both halves of a restart.
Two separate gates protect pgAdmin, and by default both are on:
- Reached through the console at
/pgadmin/, the request is gated on an admin session by a Next.js route handler (apps/frontend/src/app/pgadmin/[[...path]]/route.ts), which asks the backend to verify the caller. By default this is the only way in: pgAdmin publishes no host port. PGADMIN_CONFIG_SERVER_MODEdefaults toTrue, so pgAdmin asks for its own login,PGADMIN_EMAIL/PGADMIN_PASSWORD. Set the password in.envbefore pgAdmin first starts. There is no fallback: without one pgAdmin refuses to set up its volume and says so indocker logs hybridinference-pgadmin.Falseserves pgAdmin with no login of its own.
pgAdmin keeps the login it first started with in its volume and ignores these
settings afterwards. A volume first started with no password, under the
earlier Compose fallback, signs in as admin@local.dev / admin: change that
password in pgAdmin. One first started with server mode off has no login at
all, so once server mode is on nobody can sign in: reset the volume as
described below, which removes only pgAdmin's saved connections.
For an SSH tunnel, PGADMIN_PUBLISH=1 publishes pgAdmin on the host's
loopback, on port PGADMIN_PORT (5050 unless set). Pass it on every command
that touches pgAdmin, as with the profile. That port skips the console's gate
and every account on the host can reach it, so pgAdmin's own login is the
only gate there: do not combine it with server mode off.
make up COMPOSE_PROFILES=admin PGADMIN_PUBLISH=1
ssh -L 5050:127.0.0.1:5050 <user>@<your-gateway-host>pgAdmin's master password prompt never appears:
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED is pinned to "False" in the Compose
file with no environment variable to change it.
Servers→ right-click →Register→Server.- General: any name.
- Connection: host
postgres, port5432, maintenance databaseDB_NAME, usernameDB_USER, passwordDB_PASSWORD— the container-internal values, not the host port mapping.
Its saved connections live in hybridinference_pgadmin_data, an ordinary
project-local volume (not external, unlike the Postgres one):
make down COMPOSE_PROFILES=admin
docker volume rm hybridinference_pgadmin_data # destroys saved connections only
make up COMPOSE_PROFILES=admin