---
title: "PostgreSQL | FoxAuth"
description: "Choosing PostgreSQL instead of MongoDB, which variable selects it, how to provision the schema, and the migration step each upgrade needs."
canonical: "https://foxauth.dev/docs/deploy/postgresql/"
lastmod: "2026-09-14T14:46:41+03:00"
---
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.

One datastore, not two

Set both MONGODB_URI and POSTGRES_URL and the server refuses to start instead of picking one.
A server that quietly chose the wrong database would look, from the outside, exactly like total data
loss: it starts, it serves, and everything you had is missing.
Check .env and .env.local as well as your shell. The runtime loads those files automatically, so
the variable causing the refusal is often one you cannot see with env.

Setting it upSection titled “Setting it up”

Create a database and a role that can create tables in it. CREATE is needed at
provisioning time and also while the server runs: creating a user bucket in the console
provisions that bucket’s table and its unique-email constraint at that moment, so a bucket 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_NAME is not
used:

Terminal window

POSTGRES_URL=postgres://user:password@host:5432/oauth

ISSUER=https://auth.example.com

Provision the schema, before the first start:

Terminal window

bun run db:setup:pg

This 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 versionSection titled “The shortest version”

A Compose file ships for exactly this, and it is probably the fastest way to see the server on
PostgreSQL:

Terminal window

docker compose -f docker-compose.postgres.yml up

It 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 itSection titled “Checking a database without changing it”

Terminal window

bun run db:setup:pg --check

Reports every table or index that is missing and changes nothing. Useful in a deployment pipeline
before a release, and after one if something looks wrong.

UpgradingSection titled “Upgrading”

Two steps, in this order, on every upgrade:

Terminal window

bun run db:setup:pg # adds anything newly declared

bun run db:migrate # applies any schema migrations the release declares

db: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.

Upgrading from before ownership groups

A deployment old enough to predate ownership groups must upgrade through an
earlier release first. The one-off conversion that introduced them has been
retired, and this release does not attempt it.

What differs from MongoDBSection 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_NAME is unused. The database name is in the connection URL.

Health and readinessSection 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 the first and a readiness probe at the second. 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 dependency’s outage.

PreviousMongoDB AtlasNextUpgrading
