Back to Blog
Tutorial7 min read2026-07-13

Deploy a Supabase-Style Stack with PandaStack

Run the core of Supabase yourself: managed Postgres, PostgREST, and GoTrue auth as three small services glued together by one JWT secret.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Supabase's core idea holds up: put Postgres at the center, generate the REST API directly from the schema, and make auth a small service that writes to the same database. What's less obvious is that the useful 80% of that stack is only three deployable pieces. You don't need the full self-hosting docker-compose file — Kong, Studio, Realtime, image proxy, a dozen containers — to get schema-driven APIs with real authentication.

Here's how to run a Supabase-style stack as three services against one managed PostgreSQL database.

What the stack actually is

Strip Supabase down and you get:

  • PostgreSQL — holds all state, enforces all authorization via row-level security.
  • PostgREST — reads your schema and serves it as a REST API. Stateless, one binary.
  • GoTrue — Supabase's auth server. Handles signup, login, password reset, and issues JWTs. Also stateless; it keeps users in an auth schema in the same database.

The glue is a single shared JWT secret. GoTrue signs tokens with it, PostgREST verifies them with it, and the role claim inside the token tells PostgREST which database role to execute the request as. That's the whole trick. No session store, no gateway required.

Step 1: the database, roles, and one table

Provision a PostgreSQL instance (14.x or 16.x both work). Then set up the role structure PostgREST expects — this is the same convention Supabase uses:

-- The role PostgREST connects as. It can't do anything itself.
create role authenticator noinherit login password 'change-me';

-- Roles PostgREST switches into per-request, based on the JWT.
create role anon nologin;
create role authenticated nologin;

grant anon to authenticator;
grant authenticated to authenticator;

-- Keep the API surface in its own schema.
create schema api;
grant usage on schema api to anon, authenticated;

Now a table with row-level security. The JWT's sub claim (the user ID GoTrue issues) is available inside Postgres via current_setting:

create table api.todos (
  id     bigint generated always as identity primary key,
  owner  uuid not null,
  task   text not null,
  done   boolean not null default false
);

alter table api.todos enable row level security;

grant select, insert, update, delete on api.todos to authenticated;

create policy todos_owner on api.todos
  using (owner = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid);

Anonymous users get nothing on this table; authenticated users see only their own rows. Authorization lives in the database, which means it applies no matter how many services talk to it later.

Step 2: PostgREST

PostgREST ships as an official image and configures itself entirely from environment variables. The Dockerfile is one line:

FROM postgrest/postgrest

Configuration:

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

It listens on port 3000 by default. Once it's up, your table is an endpoint:

curl "https://<your-postgrest-host>/todos" \
  -H "Authorization: Bearer $TOKEN"

Filters, ordering, and pagination come for free (?done=is.false&order=id.desc). I go deeper on PostgREST specifics — pool sizing, schema cache reloads, migrations — in a separate post; this one stays focused on the stack.

Step 3: GoTrue for auth

GoTrue is Go, stateless, and runs its own migrations at boot — first start creates the auth schema and its tables in your database automatically. Supabase publishes the image (supabase/gotrue; newer releases are also published under the auth name).

Minimum configuration:

GOTRUE_DB_DRIVER=postgres
GOTRUE_DB_DATABASE_URL=postgres://<user>:<pass>@<host>:5432/<db>
GOTRUE_API_HOST=0.0.0.0
GOTRUE_API_PORT=9999
GOTRUE_JWT_SECRET=<same 32+ char secret as PostgREST>
GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated
GOTRUE_SITE_URL=https://yourapp.example.com

Two of these matter more than they look:

  • GOTRUE_JWT_SECRET must be byte-identical to PGRST_JWT_SECRET. If tokens verify in one service and not the other, this is why.
  • GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated puts "role": "authenticated" in every issued token, which is exactly what PostgREST uses to switch database roles.

For email confirmation and password resets you'll also need SMTP settings (GOTRUE_SMTP_HOST and friends). For a first deployment you can disable email confirmation and add SMTP later.

Signup and login are plain HTTP:

# Sign up
curl -X POST "https://<your-gotrue-host>/signup" \
  -H "Content-Type: application/json" \
  -d '{"email":"me@example.com","password":"s3cret-pass"}'

# Log in — returns an access_token you pass to PostgREST
curl -X POST "https://<your-gotrue-host>/token?grant_type=password" \
  -H "Content-Type: application/json" \
  -d '{"email":"me@example.com","password":"s3cret-pass"}'

Take the access_token from the login response, send it to PostgREST as a Bearer token, and row-level security does the rest.

Wiring it up on PandaStack

This stack maps cleanly onto PandaStack because both services are just containers pointed at one database:

  1. 1Provision a managed PostgreSQL (14.x or 16.x) from the dashboard. Run the role and table SQL from step 1 against it with any SQL client.
  2. 2Deploy PostgREST as a container app from a repo containing the one-line Dockerfile. Because the database is attached to the app, DATABASE_URL is injected automatically — but PostgREST reads PGRST_DB_URI, so set that env var to the same connection string (swapping in the authenticator user you created). The build runs in rootless BuildKit and you can watch it in the live build logs.
  3. 3Deploy GoTrue the same way, setting GOTRUE_DB_DATABASE_URL and the shared GOTRUE_JWT_SECRET.
  4. 4Each service gets its own URL, and you can attach custom domains with automatic SSL — api.yourapp.com for PostgREST, auth.yourapp.com for GoTrue.

From then on it's git push to deploy. Change a config, push, watch the build logs, done.

One free-tier note worth knowing: idle apps scale to zero and cold-start on the next request. For a hobby project that's fine — the first login after a quiet night is just slower. For production auth, run it on a paid tier where instances stay warm on stable nodes. Also check connection math: the free-tier database allows 50 connections, and PostgREST's default pool of 10 plus GoTrue fits comfortably inside that.

What you're giving up, honestly

Compared to hosted Supabase, this setup does not include:

  • Studio. Use any SQL client — psql, TablePlus, DBeaver. You lose the pretty table editor, not any capability.
  • Realtime. Supabase's realtime server is an Elixir application with its own operational footprint. If you need live queries, budget real time for it or poll.
  • Storage. Use any object storage directly instead of the Storage API.

What you keep is the part that ages best: a plain Postgres database you fully control, an API generated from its schema, and standard JWTs. Nothing here is proprietary — every piece is open source and swappable, and your data model isn't entangled with anyone's platform conventions.

The payoff

Three small services, one database, one secret. Auth issues tokens, the API enforces row-level security, and the entire backend is defined in SQL you can read in one sitting. When you add a table and a policy, you've added an authenticated API endpoint — no application server code involved.

If you want to try this without standing up the database yourself, PandaStack's managed Postgres and container deploys at https://pandastack.io get the whole stack running from a couple of git pushes.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also