Back to Blog
Tutorial7 min read2026-07-12

How to Deploy a PostgREST API with PandaStack

Turn a Postgres schema into a production REST API: roles, config, migrations, health checks, pool sizing, and git-push deploys.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

PostgREST is a single binary that reads your PostgreSQL schema and serves it as a REST API. No routes to write, no ORM, no controller layer — the database schema is the API, and authorization is enforced by Postgres roles and row-level security. It's one of the highest leverage-to-code ratios in backend engineering, and because the whole thing is stateless, it's also one of the easiest services to deploy.

Here's the full path from empty database to production API.

The role model: understand this first

PostgREST connects to Postgres as one role and then switches into another role per request. Getting this wrong is the number one source of confusing 401s, so set it up deliberately:

-- Connection role. NOINHERIT so it has no privileges of its own.
create role authenticator noinherit login password 'change-me';

-- Request roles. NOLOGIN — they're only reachable via SET ROLE.
create role web_anon nologin;
create role api_user nologin;

grant web_anon to authenticator;
grant api_user to authenticator;

Unauthenticated requests run as web_anon. Requests carrying a valid JWT run as whatever the token's role claim says — so a token with "role": "api_user" executes with api_user's privileges. If a request can't do something, the answer is always in the grants, never in PostgREST config.

Keep the API surface in a dedicated schema rather than exposing public:

create schema api;
grant usage on schema api to web_anon, api_user;

create table api.articles (
  id         bigint generated always as identity primary key,
  title      text not null,
  body       text not null,
  published  boolean not null default false
);

grant select on api.articles to web_anon;
grant select, insert, update, delete on api.articles to api_user;

Configuration and the DATABASE_URL trick

PostgREST reads a config file or PGRST_-prefixed environment variables. The minimum viable set:

PGRST_DB_URI=postgres://authenticator:change-me@<host>:5432/<db>
PGRST_DB_SCHEMAS=api
PGRST_DB_ANON_ROLE=web_anon
PGRST_JWT_SECRET=<32+ random characters>

It listens on port 3000 by default (PGRST_SERVER_PORT to change it).

If your platform hands you a single DATABASE_URL — PandaStack injects one automatically when a managed database is attached to the app — you have two options. Set PGRST_DB_URI to the same string yourself, or use the config file's environment interpolation:

# postgrest.conf
db-uri = "$(DATABASE_URL)"
db-schemas = "api"
db-anon-role = "web_anon"
jwt-secret = "$(PGRST_JWT_SECRET)"

One caveat with reusing the injected URL directly: it connects as the database's main user, which skips the authenticator least-privilege setup. Fine for a first deploy; for production, create the roles above and point db-uri at authenticator.

The Dockerfile

The official image makes this nearly trivial:

FROM postgrest/postgrest
COPY postgrest.conf /etc/postgrest.conf
CMD ["postgrest", "/etc/postgrest.conf"]

Or skip the config file entirely, use env vars only, and the Dockerfile is literally one FROM line. Pin a version tag in production — PostgREST moves fast and major versions occasionally change defaults.

Migrations: PostgREST doesn't have them, so pick a tool

PostgREST deliberately has no migration system — the schema is managed however you manage SQL. [dbmate](https://github.com/amacneil/dbmate) is a good lightweight fit because it also speaks DATABASE_URL:

dbmate new create_articles
# edit db/migrations/20260709..._create_articles.sql
-- migrate:up
create table api.articles (...);
grant select on api.articles to web_anon;

-- migrate:down
drop table api.articles;
dbmate up

Two operational rules. First, run migrations as a deliberate step before the new deploy takes traffic, not in the container's startup command — two replicas booting simultaneously will race on the same migration. Second, after DDL changes, tell PostgREST to refresh its schema cache; it won't see new tables or columns until it reloads:

NOTIFY pgrst, 'reload schema';

Forgetting that NOTIFY is the classic "I created the table but the API 404s" moment.

Auth: JWTs with a role claim

Any JWT signed with your PGRST_JWT_SECRET (HS256, secret at least 32 characters) works. The only required claim is role. Mint a test token in Node:

const jwt = require('jsonwebtoken');
console.log(jwt.sign({ role: 'api_user' }, process.env.PGRST_JWT_SECRET));

Then:

# Anonymous: can read published articles
curl "https://<host>/articles?published=is.true"

# Authenticated: can write
curl -X POST "https://<host>/articles" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"title":"Hello","body":"First post"}'

Prefer: return=representation makes writes return the created row — worth knowing on day one. Filtering (?id=eq.3), ordering (?order=id.desc), and embedding related tables through foreign keys (?select=*,comments(*)) all come free from the schema.

In real systems, pair the JWTs with an auth server that issues them — I cover wiring PostgREST to GoTrue in the Supabase-style stack post.

Health checks

Enable the admin server on a second port:

PGRST_ADMIN_SERVER_PORT=3001

That exposes GET /live (process is up) and GET /ready (database connection works) — exactly what a platform health probe wants, kept off your public port.

Deploying on PandaStack

The full sequence:

  1. 1Provision a managed PostgreSQL (14.x or 16.x) from the dashboard and attach it to your app. DATABASE_URL is injected automatically.
  2. 2Push the repo containing the Dockerfile and postgrest.conf. The image builds with rootless BuildKit in an ephemeral build pod — no Docker daemon anywhere — and you can watch the build logs stream live.
  3. 3Set the remaining env vars in the dashboard: PGRST_JWT_SECRET, and PGRST_DB_URI if you're using the authenticator role rather than the injected URL.
  4. 4Run migrations with dbmate up against the database, then NOTIFY pgrst, 'reload schema';.
  5. 5Attach a custom domain — SSL is automatic.

From here every schema change is: write migration, dbmate up, NOTIFY, done. Application deploys are just git push, and honestly most weeks you won't need one, because the API changes when the schema changes.

Pool sizing against real connection limits

PostgREST maintains a connection pool (PGRST_DB_POOL, default 10). Match it to your database's actual limit: the PandaStack free tier allows 50 connections, Pro allows 300, Premium 1,000. A single PostgREST instance at the default pool of 10 fits the free tier easily — but if you scale to multiple replicas, multiply the pool by replica count and leave headroom for migrations and your SQL client. Blowing the connection limit presents as intermittent 503s, which is a miserable thing to debug at 2 a.m. if you didn't do the arithmetic at 2 p.m.

One more free-tier behavior to know: idle apps scale to zero, so the first request after a quiet period pays a cold start. PostgREST boots fast, so it's tolerable for hobby APIs; paid tiers keep instances warm on stable nodes.

Why this setup ages well

Everything above is portable: standard Postgres, standard JWTs, an open-source binary, SQL migrations in your repo. The platform's job is to make the boring parts disappear — builds, TLS, credentials, backups (daily, retained 7 to 30 days depending on plan) — without owning your data model. If you want to see how fast a schema can become a live API, try it on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also