PostgREST
PostgREST is a single-process, stateless web server that turns a PostgreSQL
schema into a RESTful API. As a pgcli addon it runs as one container in front
of any PG endpoint — no data directory, no rendered config file, everything
configured through PGRST_* environment variables.
PostgREST is a proxy-type addon like PgBouncer and PgDog: it holds no state of its own. Two deployment modes mirror PgBouncer’s:
- Local:
pg addon install postgrest -i <instance>— stored as a sidecar underinstances.<name>.addons.postgrest, DSN built from the instance. - Remote:
pg addon install postgrest --dsn <dsn> --pg-name <name>— stored in top-leveladdons.postgrest.
The --dsn in either mode is passed verbatim into the container’s
PGRST_DB_URI, so it works against any PG endpoint: a direct managed
instance, a PgBouncer pool, or a Patroni cluster behind its
HAProxy listener.
Platform support
PostgREST works on Linux (host networking, --network host) and
macOS (container joins the pgcli-net bridge and publishes its port, the
same shape as a PG instance under podman machine).
- Local mode on macOS just works — the container reaches the managed instance over the bridge.
- Remote mode on macOS:
--dsnmust be reachable from the Mac itself, not from the podman machine VM. Point it at an address the Mac can resolve (a real host IP, orhost.containers.internalfor a service on the VM). - Clients always reach the REST API at
127.0.0.1:<port>(or--listen); gvproxy forwards the published port to the Mac’s loopback.
How it works
pg addon install postgrestpulls the image (if missing) and starts a container wired entirely fromPGRST_*env vars.- The container reaches the backend PG over the host network (Linux) or the
pgcli-netbridge (macOS). - PostgREST introspects the exposed schema on startup, serves REST requests,
and listens on the
pgrstchannel for a schema-cache reload signal.
PostgREST holds no data on the host — pg addon remove postgrest only
stops and removes the container.
Container name: pgcli-postgrest<ns>-<name> (local mode uses the instance
name as <name>; remote mode uses --pg-name). Namespace isolation applies:
two configs with different namespaces can run independent APIs without
colliding.
Commands
Install
Re-running install is idempotent: an existing container is reused (a stopped
one is started). Pass --force to recreate it after changing ports, listen
address, DSN, db-pool, schema, anon-role, or jwt-secret.
Parameters:
| Parameter | Description | Default |
|---|---|---|
-i, --instance |
Managed instance (local mode) | default |
--dsn |
Backend PG URI (remote mode); → PGRST_DB_URI verbatim |
— |
--pg-name |
Name to identify a remote PostgREST (required with --dsn) |
— |
--schema |
Exposed schema(s), comma-separated; → PGRST_DB_SCHEMAS |
PostgREST default (public) |
--db-pool |
Connections in PostgREST’s pool toward the backend; → PGRST_DB_POOL |
PostgREST default (10) |
--anon-role |
Role unauthenticated requests run as; → PGRST_DB_ANON_ROLE |
— (anonymous access off) |
--jwt-secret |
Secret used to verify Authorization: Bearer JWTs; → PGRST_JWT_SECRET |
— (JWT auth off) |
--port |
HTTP host port | auto, from postgrest_start_port (base 3500) |
--listen |
Bind address | 127.0.0.1 |
--image |
Container image | docker.io/postgrest/postgrest:v16.3 |
--force |
Recreate an existing container to apply changed flags | off |
--db-pooland the backend’smax_connections. Total PG connections a PostgREST deployment opens is roughly number of PostgREST instances ×--db-pool. If you run several replicas behind the same endpoint, budgetmax_connectionsaccordingly.
List
PostgREST appears under Local add-ons / Remote add-ons with Status, REST URL, Backend, Schema, DB pool, Anon role, JWT auth, and Container.
Start / stop / remove / logs
Autostart
Autostart is start-only: at boot the container is brought up reading the
PGRST_* env already in the config (recreated from config if it was removed).
Install the addon first.
Configuration
PostgREST settings live under addons.postgrest (remote) or as an
instances.<name>.addons.postgrest sidecar (local):
Ports are auto-assigned from the postgrest_start_port pool (base 3500) when
--port is omitted; the pool is independent of the PgBouncer / etcd / pgdog /
minio pools.
Recommended: front a Patroni cluster with HAProxy
For a Patroni cluster the recommended shape is PostgREST → HAProxy rw
listener → cluster leader, not PostgREST pointed straight at a member. Pass
the listener as --dsn (remote mode):
Why the listener, not a member’s direct port: a pinned member loses writes after a failover (the old leader stops accepting them, but the DSN keeps pointing there). pgcli detects a member’s direct port at install time and warns, suggesting the HAProxy listener instead.
- Failover self-heal, no restart. When the leader changes, PostgREST just
reconnects through the LB and re-selects the new leader — service resumes
without restarting the container or re-running install. Verified: after a
pg ha switchover, the one in-flight write that hit the demoted connection failed503(SQLSTATE 57P01, connection terminated), and the very next retried request succeeded201against the new leader. Treat that single transient503/57P01during a leader change as expected and retry it. - HAProxy routes by health check, decoupled from who is leader. The rw
listener only admits the member passing the
/primarycheck and the ro listener only the replicas passing/replica, so a failover needs no haproxy reconfiguration — the traffic moves on its own. - Scale-out. Run several PostgREST replicas behind a load balancer; each
adds its own
--db-poolworth of backend connections.
Database-side setup (not managed by pgcli)
pgcli installs and runs the PostgREST container; it does not touch your
database. Before the API serves anything, the database needs the unauthenticated
role PostgREST SET ROLEs to, plus grants on the exposed schema — usually owned
by your migrations, not pgcli. This block is re-runnable:
The role view’s column is
rolname, notrolename— a typo fails the whole batch.ALTER DEFAULT PRIVILEGEScovers only tables created after it runs; existing ones are handled by theGRANT ... ON ALL TABLESline.
PostgREST connects as the DSN user and SET ROLEs to web_anon per request, so
the API carries exactly that role’s privileges: web_anon sees SELECT-only
until you GRANT INSERT/UPDATE/DELETE on the tables you want writable. The
connecting login role itself needs nothing beyond membership of the roles
requests will assume. Then install with --anon-role web_anon. The two ways to
authenticate a request — anonymous (--anon-role) and JWT (--jwt-secret) —
are covered next; without either, every request is refused with 401.
PostgREST caches the schema it introspected. After a schema change, reload the cache:
LISTENand transaction pooling. PostgREST relies on a persistentLISTEN pgrstsession to receive reload notifications. Behind a pooler in transaction pooling mode (PgBouncer’s default) that session-level LISTEN is broken — the notification is never delivered, and neither isNOTIFY pgrstreaching PostgREST via a pooled connection. Verified behaviour: a reload sent through a transaction-pooled PgBouncer does not refresh PostgREST’s cache (a fresh table stays 404), and even aNOTIFYsent directly to the backend is lost because PostgREST’s own listener has no stable connection. Either point PostgREST at a session-pooled or direct connection, or restart the PostgREST container (it re-introspects on boot) to pick up schema changes.
Verifying the API
The install prints the REST URL (http://127.0.0.1:<port>). Create a table in
the exposed schema, then hit the API to confirm the server is live and serves
its rows:
A few things to expect on a fresh install:
- The root can take a moment to answer — schema introspection runs after boot,
so the first requests may
503until PostgREST has connected. - A
404on a just-created table or a401on unauthenticated requests means the schema cache is stale or--anon-roleis unset — see the Troubleshooting table below for the fix.
JWT authentication
--anon-role is the simplest model: every unauthenticated request runs as one
fixed role. --jwt-secret turns on per-request identity. Pass it at install
time (a HS256 shared secret, or a JSON Web Key for RS256); pgcli passes it to
the container as PGRST_JWT_SECRET.
The secret is not written to your shell history if you inline a command substitution as shown. It does land in
pg.yaml(so pgcli can recreate the container) — treat that file as secret-bearing, and re-run install with--forceafter changing it.
Length: for HS256 the secret must be at least 32 characters (256-bit key material; 48 for HS384, 64 for HS512). PostgREST refuses to start with a shorter one —
openssl rand -hex 32(64 chars) is a safe default. A JWK (for RS256/ECDSA) has no such minimum; the key strength comes from the JWK itself.
With a secret set, PostgREST verifies any request that carries
Authorization: Bearer <token> and runs it as the role named in the token’s
role claim:
- The token must be signed with the same secret; a tampered one is rejected 401.
- The
roleclaim must name a database role with grants on the exposed schema (create it likeweb_anonabove,NOINHERIT). - The login role in the DSN must be a member of that role, because serving the
request means
SET ROLEto it. So for a JWT role likeweb_useryou also needGRANT web_user TO <dsn-user>;— same requirement as the anon role above, and likewise skippable when the DSN user is a superuser. Without the membership, requests fail withpermission denied to set role. - Change the claim key from
roleviaPGRST_JWT_ROLE_CLAIM_KEYif your issuer uses another field — pgcli does not surface that flag; set it on the container directly if needed.
--anon-role and --jwt-secret compose: requests with a valid JWT run as
their role claim, requests without one fall back to --anon-role. With
neither flag, every request is refused (401). A typical progression is anon for
public reads plus JWT roles for authenticated writes.
In production your application’s auth service signs the tokens. To hand-craft a test token with the same HS256 secret:
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
Install fails with cannot connect to source database |
The DSN host:port is unreachable from the container (on macOS, remote 127.0.0.1 points at the Mac, not the VM). Verify with pg exec --dsn <dsn> "SELECT 1". |
Every request returns HTTP 401 Anonymous access is disabled |
No --anon-role was given and the request carried no valid JWT — add --anon-role, or install with --jwt-secret and send a signed token. A JWT 401 with --jwt-secret set means the signature/role claim is wrong. |
A write returns HTTP 401 whose JSON body says 42501 / permission denied for table |
The role the request ran as (anon or the JWT role claim) lacks that privilege — PostgREST surfaces insufficient privilege as 401 at the HTTP layer, with the real SQLSTATE only in the body. Grant the privilege to the role the request assumes. |
A brand-new table/relationship still returns 404/PGRST205 after NOTIFY pgrst |
Reload is not reaching PostgREST (see transaction pooling above). Restart the container, or use a session/direct connection. |
| Writes fail right after a Patroni failover | The DSN pointed at a member’s direct port, not the HAProxy rw listener — install warned about this. Re-point the DSN at the LB. |
--db-pool change didn’t take effect |
Existing containers are reused; re-run install with --force to recreate. |
Related
- PgBouncer — connection pooler (watch the transaction-pooling caveat above when pooling PostgREST’s backend).
- PgDog — pooling / sharding proxy, same dual-mode shape.
- HAProxy — the listener PostgREST’s DSN should target for a Patroni cluster.
- HA Cluster — Patroni topology that
--dsnpoints at.