PostgreSQL
The server supports two datastores and a deployment uses exactly one. MongoDB is selected by
MONGODB_URI; PostgreSQL is selected by POSTGRES_URL. Everything above the storage layer is
identical: the same endpoints, the same admin console, the same agent surface. In practice the
choice usually comes down to which database your organisation already runs well.
Setting up the database and schema
Section titled “Setting up the database and schema”-
Create a database and a role that can create tables in it.
CREATEis needed at provisioning time and also while the server runs: creating a project in the console provisions that project’s table and its unique-email constraint at that moment, so a project added later is constrained exactly like one that came with the schema. -
Set the connection string. The database name is part of the URL, so
DATABASE_NAMEis not used:Terminal window POSTGRES_URL=postgres://user:password@host:5432/oauthISSUER=https://auth.example.com -
Provision the schema, before the first start:
Terminal window bun run db:setup:pgThis creates every table and index the release declares, seeds the reserved administrator records, and provisions an initial RS256 signing key. It is idempotent: running it again reports no work. It is also how a newly declared table reaches an existing database after an upgrade.
-
Start the server. It refuses to start if the database is unreachable or if its schema is not the one this release expects, instead of accepting a request and discovering that afterwards.
The shortest version: the shipped Compose file
Section titled “The shortest version: the shipped Compose file”The repository ships a Compose file for PostgreSQL. It is probably the fastest way to see the server on PostgreSQL:
docker compose -f docker-compose.postgres.yml upIt runs three services: the database, a one-shot db:setup:pg that must finish first, and the
server. The host port is 5433, so it does not collide with a PostgreSQL you already run.
docker-compose.yml beside it is the MongoDB equivalent. Run one or the other, never both.
Checking a database without changing it
Section titled “Checking a database without changing it”bun run db:setup:pg --check--check reports every table or index that is missing and changes nothing. Run it in a deployment
pipeline before a release, and after one if something looks wrong.
Upgrading: setup, then migrate
Section titled “Upgrading: setup, then migrate”Two steps, in this order, on every upgrade:
bun run db:setup:pg # adds anything newly declaredbun run db:migrate # applies any schema migrations the release declaresdb:migrate reports current and exits when there is nothing to do, which is the ordinary case.
--plan lists what would run without running it, and exits non-zero when work is outstanding, so a
pipeline can gate on it.
Keyring
Section titled “Keyring”The server encrypts the secrets it stores, such as application secrets and private signing keys, with a keyring that you hold outside the database. Encryption works the same on PostgreSQL as on MongoDB. The keyring covers what it encrypts, how to supply it, how to rotate it and what to do if a key is lost.
Two points matter for a PostgreSQL deployment:
- Never store the keyring in PostgreSQL, in this database or another table beside it. It is a
deployment secret, like
POSTGRES_URL. Supply it asKEYRINGor as a keyring file atKEYRING_FILE. - Back it up apart from the database. The secrets in a
pg_dumpor a managed snapshot cannot be read without the keyring. Keep the keyring where your other deployment secrets live, not next to the dumps.
The shipped docker-compose.postgres.yml mounts a foxauth-keyring volume at /app/keyring on the
server, so first-run setup can keep a keyring there.
Deploy with Docker Compose explains how to move it into .env
for production.
What differs from MongoDB
Section titled “What differs from MongoDB”Nothing an application or an end user can observe. Two operational differences are worth knowing:
- The server reclaims expired records itself. PostgreSQL has no TTL index, so the server sweeps them on roughly the same interval MongoDB’s monitor uses. Expiry itself is enforced on every read regardless, on both backends; the sweep only reclaims space.
DATABASE_NAMEis unused. The database name is in the connection URL.
Health and readiness
Section titled “Health and readiness”Two probes, and they answer different questions:
| Endpoint | Question | On failure |
|---|---|---|
/health |
Is this process alive? | Restart it. |
/ready |
Can it serve? | Stop routing to it; do not restart. |
/ready reports failure when the datastore is unreachable and recovers on its own when it returns.
Point a liveness probe at /health and a readiness probe at /ready. Wiring readiness to
/health makes an outage invisible: the process is alive, so the probe passes while requests keep
arriving at an instance that cannot serve them. Wiring liveness to /ready restarts a healthy
process for its database’s outage.