Skip to content

Database

PostgreSQL holds everything Axium persists: users, roles, providers and their encrypted credentials, models, proxy apps, API keys, the response cache and daily statistics. The core and the ui keep no state of their own.

Connection

DATABASE_URL=postgresql://axium:secret@axium-db:5432/axium-api
Pool per core instance min 0, max 10 connections
Startup wait up to 30 s, one SELECT 1 per second, then the process exits
TLS whatever the DSN says (?sslmode=require); the driver is pg

Multiply the pool maximum by the number of core replicas (plus 8 per replica if the PostgreSQL observability destination points to the same server) and make sure max_connections on the server allows it with headroom.

Cloud SQL and other Unix sockets

DATABASE_URL=postgresql://axium:secret@localhost/axium-api?host=/cloudsql/PROJECT:REGION:INSTANCE

The localhost authority is mandatory filler: the connection goes through the socket in host=, but a DSN with an empty authority makes the driver fall back to a different database engine and silently try localhost:5432.

Supported versions

  • PostgreSQL 17 is what development and CI run against, and what the installers deploy.
  • The schema needs PostgreSQL 13 or newer: native gen_random_uuid(), GENERATED ALWAYS AS … STORED columns, GROUPING SETS, INET, JSONB with GIN indexes and transaction-level advisory locks.
  • No extensions are required. No pgcrypto, no uuid-ossp.

Managed services (Cloud SQL, RDS, Azure Database, Neon, Supabase, …) work as long as the user can create schemas in the database.

Permissions

The user in DATABASE_URL must be able to:

  • CREATE SCHEMA in the database (GRANT CREATE ON DATABASE "axium-api" TO axium;)
  • create tables, types, functions and triggers in those schemas

It creates the schemas core, llm, rm and stats. With OBSERVABILITY_TYPE=postgres it also creates telemetry in whichever database OBSERVABILITY_DB_POSTGRESQL points to.

Schema installation

On every start, after connecting, the core checks the schema and acts on what it finds:

State Meaning Action
not_installed No Axium schema at all Installs the complete schema in one transaction and seeds the super-admin role and the admin@axium.local user. Log: [install] schema version N installed.
installed, current version Normal case Nothing. Log: [install] schema version N is current.
installed, older version Image newer than the database Applies the bundled migrations one version at a time, each in its own transaction. Log: [install] schema migrated 1 -> 3. A failed migration exits the process rather than serve an unknown schema.
installed, newer version Database newer than the image Exits with The database is at schema version N but this build ships M. Update the API to the latest version.
partial Axium schemas exist but no version table Refuses to touch it. Logs the failure and keeps running so /v1/install/status can be queried. Needs a human.

Installation and migrations take a transaction-level advisory lock, so several replicas can start at the same time against an empty database and only one installs.

A failed first installation does not stop the process: the routes come up and almost everything answers a database error. That is why /v1/install/status, not /v1/health, is the check that matters. See Health checks.

Checking the version

curl -s http://localhost:3000/v1/install/status
# {"status":true,"data":{"state":"installed","version":3}}

Or, authenticated, GET /v1/diagnostics, which also reports the version the running build expects.

Manual installation

POST /v1/install installs the schema on demand when the state is not_installed. It answers 409 CONFLICT once installed and 500 on a partial database. Both /v1/install routes are public and stay open forever, so restrict them at the ingress after installing; see Security.

Time zone matters

Daily statistics use CURRENT_DATE and budget windows use date_trunc('day' | 'week' | 'month', now()), both evaluated with the PostgreSQL server's time zone. Set it deliberately:

ALTER DATABASE "axium-api" SET timezone TO 'UTC';

Housekeeping the core does not do

  • Response cache. rm.request_io keeps one row per (proxy app, request hash) when the cache policy is strict. Expired rows are not purged automatically; use DELETE /v1/proxy-apps/cache/expired or DELETE /v1/proxy-apps/:id/cache/expired from a cron job.
  • Traces. telemetry.proxy_traces has no retention. See PostgreSQL destination.

Backups

See Backup & restore. Remember that a database backup is only usable together with the MASTER_ENCRYPTION_KEY it was written under.