Supabase Self-Hosted — Packing a BaaS into One Postgres Pot
Supabase Self-Hosted — Packing a BaaS into One Postgres Pot
Supabase appeared in 2020 with the slogan "open source Firebase." If Firebase is one giant managed box with its own NoSQL · Auth · Storage, Supabase took the path of placing Postgres at the center and stacking small components on top. Managed (supabase.com) and self-hosted run on the same components.
1. About Supabase
Supabase's "backend service" is in fact a single Postgres + a bundle of microservices in front of it. None of the components keeps its own separate database.
- The Auth user table, Storage object metadata, and Realtime change events all live in different schemas of the same Postgres (
auth·storage·_realtime·_supabase·_analytics).
That is why a single SQL query can deal with permissions, storage, and authentication at once, and why the deploy unit for self-hosted is simple.
2. The 14 components
| Component | Image | Role |
|---|---|---|
| Postgres | supabase/postgres |
The DB itself. pgvector · pg_graphql · pg_cron · pg_net · pgaudit pre-built. |
| Kong | kong |
API gateway. Single external entry, JWT validation · routing. |
| GoTrue | supabase/gotrue |
Auth — email · OAuth · OTP · MFA. |
| PostgREST | postgrest/postgrest |
Auto-REST API from the DB schema. |
| Realtime | supabase/realtime |
Postgres CDC → WebSocket. |
| Storage | supabase/storage-api |
Object storage API. S3 or file backend. |
| imgproxy | darthsim/imgproxy |
Image transforms. |
| postgres-meta | supabase/postgres-meta |
DB schema metadata API. |
| Studio | supabase/studio |
Dashboard UI (Next.js). |
| Edge Runtime | supabase/edge-runtime |
Deno-based serverless functions. |
| Logflare | supabase/logflare |
Log analytics. |
| Vector | timberio/vector |
Container logs → Logflare. |
| Supavisor | supabase/supavisor |
Connection pooler (PgBouncer replacement). |
13~14 containers in one bundle. The official docker-compose is the answer — basing your own setup on the compose under supabase/supabase/docker/ is far less error-prone than authoring from scratch.
3. JWT secret model — the most common stuck point
Supabase's Auth and PostgREST trust only JWTs signed with the same JWT_SECRET. 90% of self-hosted setup pitfalls live here.
Three variables must remain consistent:
JWT_SECRET— A 32+ character random string. The same value across all component env vars.ANON_KEY— A{role: "anon"}JWT signed withJWT_SECRET.SERVICE_ROLE_KEY— A{role: "service_role"}JWT signed withJWT_SECRET.
The demo keys in the official .env.example are signed with a fixed secret (your-super-secret-jwt-token-with-at-least-32-characters-long). Changing only JWT_SECRET while leaving the keys means immediate 401 · 403 on every call.
node -e '
const c = require("crypto");
const s = "put new JWT_SECRET here";
const sign = (p) => {
const h = Buffer.from(JSON.stringify({alg:"HS256",typ:"JWT"})).toString("base64url");
const b = Buffer.from(JSON.stringify(p)).toString("base64url");
return `${h}.${b}.${c.createHmac("sha256",s).update(`${h}.${b}`).digest("base64url")}`;
};
const iat = Math.floor(Date.now()/1000), exp = iat + 60*60*24*365*5;
console.log("ANON_KEY=" + sign({role:"anon", iss:"supabase-demo", iat, exp}));
console.log("SERVICE_ROLE_KEY=" + sign({role:"service_role", iss:"supabase-demo", iat, exp}));
'
4. Storage — the value of file mode
STORAGE_BACKEND selects the backend:
STORAGE_BACKEND=s3— S3-compatible endpoint (managed standard). Self-hosted needs an extra S3 like MinIO.STORAGE_BACKEND=file— Stores files directly on container volumes. Zero external dependency — the simplest option for self-hosted.
Managed Supabase uses s3 mode, but for self-hosted simplicity, file is the answer. publicUrl, signed URLs, RLS, and imgproxy transforms all behave identically.
5. Auth + Inbucket — receiving mail to disk
GoTrue sends mail over SMTP for sign-up and password reset. With no external SMTP, the standard self-hosted pattern is to also bring up a dev SMTP server like Inbucket.
auth:
environment:
GOTRUE_SMTP_HOST: inbucket
GOTRUE_SMTP_PORT: 2500
inbucket:
image: inbucket/inbucket
ports:
- "9000:9000" # Web UI
- "2500:2500" # SMTP
After signing up, opening http://localhost:9000 shows the "Confirm Your Email" message GoTrue sent.
6. Kong — the single entry point
Kong's role:
- Externally only one Kong port (typically 8000 or 54321) is visible.
- Internal per-path routing (
/auth/v1/* → auth,/rest/v1/* → rest,/storage/v1/* → storage,/realtime/v1/* → realtime,/functions/v1/* → functions). - Distinguishes anon · service_role via apikey/JWT headers.
Kong's kong.yml is declarative config. Env-var placeholders ($ANON_KEY) are substituted by an awk-based container entrypoint. When self-hosted Kong dies with /entrypoint.sh: No such file or directory, the cause is usually the kong image bumping versions and moving the entrypoint to /docker-entrypoint.sh (kong:3.x and later).
7. Postgres — why supabase/postgres matters
Standard images like postgres:15-alpine will not run self-hosted Supabase. Required extensions:
pgvector— embeddings.pg_graphql— auto-generated GraphQL.pg_cron— scheduled jobs.pg_net— HTTP calls from inside the DB (webhooks).pgaudit— audit logs.pg_stat_statements.
The supabase/postgres image ships these prebuilt. Installing them onto a standard image causes Realtime · Auth init failures.
8. Frequent stuck points
| Symptom | Cause |
|---|---|
| Every call returns 401/403 | JWT_SECRET vs ANON_KEY / SERVICE_ROLE_KEY mismatch. |
| One or two of the 14 containers stays unhealthy and others hang | depends_on condition: service_healthy chain stalls. Healthcheck timing or permissions. |
Studio healthcheck ECONNREFUSED 127.0.0.1:3000 |
Next.js binds to the container hostname. HOSTNAME=0.0.0.0 is required. |
| Realtime healthcheck always 403 | The _supabase DB tenant seed is not finished at healthcheck time. Can be ignored. |
| Pooler hits Elixir SyntaxError | Windows git autocrlf injects CR into pooler.exs. Convert to LF. |
Kong plugin 'request-termination' not enabled |
Missing in KONG_PLUGINS. Check the full plugin list in the official compose. |
9. Calling with supabase-js
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
"http://localhost:54321",
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
// Auth
await supabase.auth.signUp({ email: "x@local.dev", password: "1234" });
// REST (auto-generated)
const { data } = await supabase.from("posts").select("*").limit(10);
// Storage
await supabase.storage.from("bucket").upload("k.png", file);
// Realtime
supabase
.channel("posts")
.on("postgres_changes", { event: "*", schema: "public", table: "posts" }, console.log)
.subscribe();
// Edge Function
await supabase.functions.invoke("hello", { body: { name: "world" } });
Swapping just one URL between managed and self-hosted runs the same code on both. That is Supabase's biggest promise.
10. Limitations
Memory footprint of 14 containers — idle ~3 GB. On tight laptops, disable analytics · vector, or use the managed offering.
Fast-moving version matrix — components have specific compatible combinations. Following SHA-pinned tags from the official compose is safest.
CDN · domain handling — Storage publicUrl can return container-internal hostnames, so production runs Caddy reverse proxy + SUPABASE_PUBLIC_URL env var for the external domain.
Backup — pg_dump is effectively the backup. Also preserve the storage file backend (/var/lib/storage).
Closing thoughts
Self-hosted Supabase is a bundle of 14 containers, but the data lives in one Postgres. JWT secret consistency + Storage file mode + Inbucket make the most solid shape with zero external dependency. Code compatibility between managed and self-hosted comes down to one URL — accepting 14 containers for that value is the essence of self-hosted.
Next
- firebase-emulator
- api-mocking-wiremock
Supabase official · Self-hosting guide · supabase-js · GoTrue · PostgREST · Realtime · Storage API · Inbucket for reference.