Database

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.

Is a database required?

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.

Connection settings

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.

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.

How the schema is created

The application creates and migrates its own schema at startup, and a restart leaves in place whatever already exists; there is no separate migration tool to run. Every statement is CREATE TABLE IF NOT EXISTS, so the builders below overlap harmlessly where two of them define the same table:

Code

Creates

AppConfigStore.ensure_schema in apps/backend/serving/config/app_config_store.py

app_config, first of all, because the rest of startup reads its settings

ensure_api_logs_schema in apps/backend/serving/storage/log_schema.py

api_logs and api_stats_hourly, with their columns and indexes

DatabaseLogger._create_tables in apps/backend/serving/storage/database.py

Calls the above, then the auth/admin tables

PostgresOperationalStore.initialize in apps/backend/serving/storage/postgres_operational.py

The operational tables (settings, overrides, provider registry)

ResponseStore.initialize in apps/backend/serving/storage/responses_store.py

openai_responses

apps/backend/serving/grants.py and apps/backend/serving/admin/geo_demand_rollup.py

agent_grants; the geo_hourly_* rollup tables

Pointing a gateway at an empty database is therefore all the “migration” there is: start it and the tables appear.

Two properties are worth knowing before you operate this:

A restart only changes what is missing. Each startup first checks which columns and indexes already exist and issues only the ALTER/CREATE INDEX statements that are actually missing, so a steady-state restart takes no strong table locks. This matters because ALTER TABLE acquires ACCESS EXCLUSIVE before Postgres evaluates IF NOT EXISTS, and a queued exclusive lock parks every reader behind it.

A migration that cannot get its lock is deferred, not fatal. The DDL phase runs under a 3-second lock_timeout and raises SchemaLockUnavailable rather than waiting; the caller retries it in the background and startup proceeds. The usual lock holder is a long-running pg_dump, which can hold ACCESS SHARE over api_logs for hours. If you take backups on a schedule, expect an occasional deferred-migration line in the log after a deploy that adds a column.

If you are contributing a column to api_logs, add it in log_schema.py only; that is the one place the request log’s schema is defined.

Secrets for deleting accounts

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_SECRET is 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 uses API_KEY_SECRET instead, 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, set ERASURE_FENCE_SECRET to a random value in .env before the first start; the backend copies it into the database then. After that neither secret can be changed: the Configuration tab refuses both. 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=true turns permanent deletion on. Leave it false until every process that writes api_logs runs a version that checks the fence.

What the tables hold

Group

Tables

Holds

Request history

api_logs, api_stats_hourly, provider_hourly_stats

One row per request (model, provider, tokens, latency, TTFT, status, cost) plus hourly rollups used by the dashboards

Settings

app_config

The settings on the admin console’s Configuration tab, secrets included; see Settings and first-run setup

Accounts and auth

users, api_keys, auth_sessions, browser_sessions, browser_session_accounts, login_events, email_verification_tokens, password_reset_tokens, identity_auth_codes

User records, hashed API keys and their quotas, refresh sessions 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

Per-user daily spend used for quota enforcement

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

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.

Settings and first-run setup

  • app_config holds one row per stored setting: key (the environment-variable name), value (in the string form that variable takes, such as true, 8 or a,b,c), secret, source — admin, setup, env_import or generated — and updated_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_settings holds the runtime switches of the admin Settings tab and, once the first administrator exists, the row setup_completed_at, which marks first-run setup as done. The application never clears it. While setup is pending, the row setup_code holds 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.email is empty for the administrator that first-run setup creates; that account signs in with users.login_name, which is unique regardless of case. Every account from signup has an email address.

Request logging and privacy

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 Configuration 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.

Which session a request belongs to

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.

Backup

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).dump

Do 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.dump

Stop 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.

Reset

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_data

docker 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 volume

make 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.

Inspecting the database

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 and pgAdmin should both read 127.0.0.1:...; anything else means the database is listening beyond this host.

Optional: pgAdmin

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.

Starting and restarting it

The profile must be on every Compose command, not just the first:

make up   COMPOSE_PROFILES=admin
make down COMPOSE_PROFILES=admin

Omitting 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.

Authentication

Two separate gates can protect pgAdmin, and by default only the console’s is on:

  • PGADMIN_CONFIG_SERVER_MODE defaults to False, which serves pgAdmin with no login of its own. Set it to True in .env and pgAdmin asks for PGADMIN_EMAIL / PGADMIN_PASSWORD (which themselves default to admin@local.dev / admin — change them before enabling this).

  • 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. Reached directly on its published port (127.0.0.1:${PGADMIN_PORT:-5050}, loopback only), that gate does not apply — use an SSH tunnel:

    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.

Registering the database

  1. Servers → right-click → Register → Server.

  2. General: any name.

  3. Connection: host postgres, port 5432, maintenance database DB_NAME, username DB_USER, password DB_PASSWORD — the container-internal values, not the host port mapping.

Resetting pgAdmin

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