Supabase is an open source backend built on Postgres. It bundles a database, Auth, an auto-generated REST API, Storage, Realtime and Edge Functions, with Studio as the web dashboard. The supabase/supabase repository had about 113,000 GitHub stars on October 22, 2026, and the supabase/postgres image had passed 19 million pulls on Docker Hub.
This guide installs the official self-hosted release v0.8.3 with Docker Compose, generates the keys the official way, creates a table in Studio and reads it over the REST API with the anon key. It then covers the errors that usually come next: 401 from the gateway, PGRST301, an empty [] from Row Level Security, Storage upload errors and signups that never finish.
TL;DR:
- Clone the
self-hosted/v0.8.3tag, copydocker/to a project folder, runsh utils/generate-keys.sh --update-envandsh utils/add-new-auth-keys.sh --update-env, thendocker compose up -d --wait. All 11 containers reported healthy in 37 seconds. - Studio and every API sit behind one gateway on port 8000. Studio uses HTTP basic auth with
DASHBOARD_USERNAMEandDASHBOARD_PASSWORDfrom.env. - Every API call needs an
apikeyheader. Without it the gateway returns401 Unauthorized. A token signed with a differentJWT_SECRETreturnsPGRST301. - Tables created in Studio have RLS on. With no policy, the anon key gets
[]with HTTP 200, not an error. Add a policy for theanonrole. - Self-hosted Supabase is one project, and branching, PITR, managed backups, ETL and the Management API stay on the managed platform. Back up Postgres yourself before you run
update.sh.
What self-hosted Supabase includes, and what stays on Cloud
The self-hosted stack is the same set of open source services the managed platform runs, wired together by one Compose file. The v0.8.3 file starts 11 containers: Studio, the Envoy API gateway, Auth (GoTrue), PostgREST, Realtime, Storage, imgproxy, postgres-meta, Edge Runtime, Postgres and the Supavisor connection pooler. Logs and analytics (Logflare and Vector) are an optional override that you add with sh run.sh config add logs.
Cloud-only features
The self-hosting overview lists what you give up:
Some features of the managed platform aren't available when self-hosting: branching, advanced metrics beyond logs, managed backups and PITR, analytics and vector buckets, ETL, and the Management API.
The same page says self-hosted Supabase "runs as a single project, which means that Studio doesn't support multiple organizations or projects". Most settings live in .env instead of dashboard screens. The overview also states that self-hosted Supabase "does not phone home or collect any telemetry".
Hardware and ports
Per the Docker guide, the minimum for all components is 4 GB RAM, 2 CPU cores and a 40 GB SSD, with 8 GB, 4 cores and 80 GB recommended. The pinned images in v0.8.3 took about 6.6 GB of disk before any data, with Studio (1.28 GB), Postgres (1.24 GB) and Supavisor (1.17 GB) the largest.
The default file publishes three host ports:
| Port | Service | Purpose |
|---|---|---|
| 8000 | Envoy gateway | Studio plus /rest/v1, /auth/v1, /storage/v1, /realtime/v1, /functions/v1
|
| 5432 | Supavisor | Postgres connections in session mode |
| 6543 | Supavisor | Postgres connections in transaction mode |
Envoy is the default gateway in v0.8.3, and the 401 responses below carry server: envoy. Kong is still available as an override with sh run.sh config add kong. To move the gateway port, change API_GW_HTTP_PORT in .env. The old KONG_HTTP_PORT variable is kept only as a fallback so older .env files keep working.
Install Supabase with Docker Compose
You need Docker with the Compose plugin and git. The steps below are the sparse clone from the Docker guide, which pins the release tag.
Clone the pinned release
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
--branch self-hosted/v0.8.3 https://github.com/supabase/supabase
cd supabase
git sparse-checkout init --cone
git sparse-checkout set docker
git checkout --quiet
cd ..
mkdir supabase-project
cp -rf supabase/docker/. supabase-project
cd supabase-project && cp .env.example .env
printf 'ref=self-hosted/v0.8.3\n' > .supabase-version
docker compose pull
The tag pointed at commit bc27d2661edd09a5d0b8d76ce33da6d4071a756e from October 16, 2026. The .supabase-version file matters later, because update.sh reads it to know which release you started from. These are the image tags that release pins in docker-compose.yml:
services:
studio:
image: supabase/studio:2026.10.12-sha-3699278
api-gw:
image: envoyproxy/envoy:v1.39.1
auth:
image: supabase/gotrue:v2.197.0
rest:
image: postgrest/postgrest:v14.18
realtime:
image: supabase/realtime:v2.143.3
storage:
image: supabase/storage-api:v1.81.0
imgproxy:
image: darthsim/imgproxy:v3.31.4
meta:
image: supabase/postgres-meta:v0.100.0
functions:
image: supabase/edge-runtime:v1.77.4
db:
image: supabase/postgres:17.6.1.136
supavisor:
image: supabase/supavisor:2.9.13
Generate JWT_SECRET, ANON_KEY and SERVICE_ROLE_KEY
Never start the stack with the placeholder values from .env.example. The repo ships two scripts that write fresh secrets straight into .env:
sh utils/generate-keys.sh --update-env
sh utils/add-new-auth-keys.sh --update-env
The first script creates JWT_SECRET, the legacy ANON_KEY and SERVICE_ROLE_KEY (HS256 tokens signed with that secret, valid for five years), POSTGRES_PASSWORD, DASHBOARD_PASSWORD and the other internal secrets. The second adds the newer SUPABASE_PUBLISHABLE_KEY (sb_publishable_...) and SUPABASE_SECRET_KEY (sb_secret_...) plus an ES256 key pair, and it uncomments the lines in docker-compose.yml that pass the key pair to Auth, Realtime, Storage and Edge Functions. It needs Node 16 or newer, or it runs inside a node:22-alpine container that it pulls on first use. The legacy anon key and the new publishable key both worked in the apikey header. sh run.sh secrets prints the database and dashboard passwords, the publishable and secret keys and the S3 keys. Read ANON_KEY, SERVICE_ROLE_KEY and JWT_SECRET straight from .env.
Then set the URLs in .env. For a local install the defaults work: SUPABASE_PUBLIC_URL=http://localhost:8000, API_EXTERNAL_URL pointing at the same host, and SITE_URL=http://localhost:3000 for your app.
Start the stack and create a table in Studio
Keep the ports on localhost and start the stack
The default ports bind to every interface. Studio has only basic auth in front of it, so keep everything on the loopback until a reverse proxy is in place. An override file with the Compose !override tag replaces the port lists:
# docker-compose.local.yml
services:
api-gw:
ports: !override
- 127.0.0.1:8000:8000/tcp
supavisor:
ports: !override
- 127.0.0.1:5432:5432
- 127.0.0.1:6543:6543
Add it to the COMPOSE_FILE line in .env so every docker compose and run.sh command layers it:
COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
You cannot do this with POSTGRES_PORT=127.0.0.1:5432, because the Compose file also passes that variable to services as a plain port number. Now start everything and wait for the health checks:
docker compose up -d --wait
docker compose ps
sh run.sh start runs the same up -d --wait. The command returned after 37 seconds with all 11 containers healthy, and the pooler and gateway listened only on 127.0.0.1.
At idle the 11 containers used about 1.1 GiB of RAM added together and under 1% CPU each. Realtime (248 MiB), Studio (202 MiB) and Supavisor (193 MiB) were the largest, while Auth used about 11 MiB and PostgREST about 18 MiB.
Create a table and read it over REST
Open http://localhost:8000. The browser asks for a username and password. That is the HTTP basic auth from DASHBOARD_USERNAME (default supabase) and the generated DASHBOARD_PASSWORD. Studio opens straight into a single project called Default Project.
In Table Editor, choose New table, name it notes, keep the default id and created_at columns and add a title column of type text. Leave Enable Row Level Security checked. Save, then use Insert, Insert row and enter a title.
PostgREST serves every table in the public schema under /rest/v1. Read the anon key from .env and query the table:
export ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
curl -s -H "apikey: $ANON_KEY" \
"http://localhost:8000/rest/v1/notes?select=id,title"
The first answer was [] with HTTP 200, even though the row is there. That is RLS doing its job, covered in the next section. After adding a read policy, the same call returned the row:
[{"id":1,"title":"Hello from self-hosted Supabase"}]
The publishable key works the same way in the apikey header. The service role key bypasses RLS and returned the row even before the policy existed. The secret key bypassed RLS the same way for a Storage upload later in this guide. Keep both on servers only.
Fix API key, RLS, Storage and signup errors
Each error below came up during setup, with the change that cleared it.
API keys and RLS: 401, PGRST301, [] and 42501
A request with no apikey header, or with a value that is not one of your keys, stops at the gateway. Envoy answers HTTP/1.1 401 Unauthorized with the plain body Unauthorized. The fix is to send the key in the apikey header on every request, including from your own backend.
A valid apikey with a bad Authorization: Bearer token gets further and fails in PostgREST instead:
{"code":"PGRST301","details":"None of the keys was able to decode the JWT","hint":null,"message":"No suitable key or wrong key type"}
The same error, with HTTP 401, appeared after replacing ANON_KEY in .env with a token signed by a different secret and recreating the gateway, Auth and REST. The gateway accepted it because it matched .env, and PostgREST rejected it because it was not signed with JWT_SECRET. Auth kept answering /auth/v1/settings normally the whole time, so a working Auth endpoint does not prove your keys match. Putting the generated keys back and running docker compose up -d --force-recreate api-gw rest auth returned the row again. If you ever change JWT_SECRET, run generate-keys.sh again so all three values are regenerated together, then sh run.sh recreate.
Studio enables RLS on new tables, and a table with RLS and no policy returns no rows to anon without any error. Studio warns about this when you create the table. Add a policy in the SQL Editor or with psql inside the supabase-db container:
create policy "Public can read notes"
on public.notes for select
to anon
using (true);
That policy only covers reads. An anon POST to /rest/v1/notes still failed with HTTP 401, which is the intended result:
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"notes\""}
Add a separate insert policy for the role that should write, usually authenticated, instead of disabling RLS.
Storage and signup: NoSuchBucket, AccessDenied and 504
Storage and Auth fail in less obvious ways, because the HTTP status or the log line does not always match the real cause.
Storage errors come back with HTTP 400 and the real status inside the JSON body, which trips up clients that only check the status code. Uploading to a bucket that did not exist yet returned:
{"statusCode":"404","error":"Bucket not found","message":"Bucket not found","code":"NoSuchBucket"}
After creating a private bucket called docs with the service role key, an upload with the anon key returned {"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}. Storage keeps object metadata in Postgres, so the same RLS rules apply. Uploading with the service role key succeeded, and so did an upload with the sb_secret_ key in the apikey header. The file showed up in Studio. For uploads from the browser, add policies on storage.objects per the Storage access control docs.
A fresh install has email signups on and confirmations required, and .env points SMTP_HOST at supabase-mail on port 2500. No container with that name exists in the Compose file. A POST to /auth/v1/signup hung for 10 seconds and returned {"code":504,"error_code":"request_timeout",...}. Other write-ups show the same root cause as a 500 with Error sending confirmation email and a lookup supabase-mail DNS error in the Auth logs.
For a production install, set real SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_ADMIN_EMAIL values, then recreate Auth. For a local test stack, setting ENABLE_EMAIL_AUTOCONFIRM=true and running docker compose up -d --force-recreate auth made the next signup return HTTP 200 with a session. Do not leave autoconfirm on for real users. A plain docker compose restart auth does not reread .env. After a restart the Auth container still had the old GOTRUE_MAILER_AUTOCONFIRM value, and only a recreate picked up the change. Use sh run.sh recreate <service> after every .env edit.
HTTPS, backups and upgrades
Set SITE_URL and add HTTPS
Everything above runs on localhost. Once you put Supabase on a domain, three URL variables in .env have to change together, per the reverse proxy guide:
SUPABASE_PUBLIC_URL=https://supabase.example.com
API_EXTERNAL_URL=https://supabase.example.com/auth/v1
SITE_URL=https://app.example.com
SITE_URL is where Auth sends users after email links and OAuth logins when no other address is given. A redirect_to address has to match an entry in ADDITIONAL_REDIRECT_URLS, which Auth uses as its allow list. Otherwise Auth sends the user to SITE_URL instead. The redirect URLs docs allow wildcard patterns such as https://app.example.com/**, but recommend exact URLs in production.
The default Envoy gateway does not terminate TLS, so HTTPS comes from a proxy in front of it. v0.8.3 ships two proxy overrides. Per the guide, sh run.sh config add caddy followed by sh run.sh start adds Caddy with automatic Let's Encrypt certificates, configured in volumes/proxy/caddy/Caddyfile. sh run.sh config add nginx adds Nginx with Certbot instead, and it also needs PROXY_DOMAIN and CERTBOT_EMAIL set in .env. Both need a DNS record pointing at the server and ports 80 and 443 open. The guide's check is curl -I https://<your-domain>/auth/v1/, which should answer 401.
The guide also allows Nginx Proxy Manager, Traefik, HAProxy or another proxy you already run. With Nginx Proxy Manager, add a proxy host that forwards to port 8000, turn on WebSockets support for Realtime, pass the X-Forwarded headers, and remove the gateway's host port binding if the proxy shares the Docker network. Keep Studio's basic auth in front of the dashboard either way, or add an access list in your proxy.
Back up and upgrade with update.sh
The updating guide warns that update.sh "backs up your configuration files to backups/, but it does not back up Postgres or Storage data". Managed backups and PITR are Cloud features, so backups are your job.
A logical dump of your own schema runs inside the database container:
docker exec supabase-db pg_dump -U postgres -d postgres \
--schema=public --no-owner > public-backup.sql
The dump contained the notes table, its row and the Public can read notes policy. Storage files live on disk under volumes/storage, and the database files under volumes/db/data, so copy both directories with the stack stopped for a full file-level backup.
Per the updating guide, back up first, then preview and apply the next release, then pull and recreate:
sh update.sh --dry-run
sh update.sh
sh run.sh pull
sh run.sh recreate
update.sh does a three-way merge against the release in .supabase-version, keeps your .env, and stops with exit code 2 and merge markers if your edits conflict with upstream changes. Releases that need a manual step, such as a Postgres major upgrade, are flagged before anything is applied. sh reset.sh is the opposite tool. It runs docker compose down -v and deletes the data directories, so never run it on an install you care about.
If you want a broader self-hosted setup around this, Coolify can deploy the app that talks to Supabase, and Portainer gives you a web view of the 11 containers.
FAQ
Setup, limits and login
How much RAM does self-hosted Supabase need?
The docs ask for 4 GB of RAM and 2 cores at minimum, with 8 GB and 4 cores recommended. The full default stack of 11 containers used about 1.1 GiB at idle, and the images took about 6.6 GB of disk. You can remove Realtime, Storage, imgproxy or Edge Functions from the Compose file to save memory, per the Docker guide.
Can one self-hosted Supabase run multiple projects?
No. The self-hosting overview says Studio "doesn't support multiple organizations or projects". Users who run two stacks on one host report in GitHub issue #37412 that each copy needs its own .env, ports and renamed container_name values.
Why does my new dashboard password not work?
Studio's login is HTTP basic auth enforced at the gateway, which reads DASHBOARD_USERNAME and DASHBOARD_PASSWORD from its environment when the container is created. A restart keeps the old environment, so run sh run.sh recreate after editing .env. The Docker guide also says the password "must include at least one letter" and should not use numbers only or special characters.
Keys, email and redirects
Why does REST return "No suitable key or wrong key type" while Auth works?
PostgREST could not verify the JWT with your JWT_SECRET or JWKS. A token signed by another secret produced exactly that PGRST301 error, while /auth/v1/settings kept working. Regenerate the keys together with generate-keys.sh, recreate the stack, and send a user's JWT, not the secret key, as the Bearer token.
Why does signup fail with "Error sending confirmation email"?
The default .env points SMTP at a supabase-mail host that the Compose file does not create. Here the signup timed out with a 504 after 10 seconds. Set real SMTP values and recreate Auth, or use ENABLE_EMAIL_AUTOCONFIRM=true only on a test stack.
Why do auth emails redirect to localhost:3000?
SITE_URL defaults to http://localhost:3000. Set it to your app's URL. J0 explained the other common cause in GitHub issue #22242: "A redirect to the SITE_URL instead of the redirect_to URL typically indicates that the redirect URL is not in the allowed URL list." Add the callback URL to ADDITIONAL_REDIRECT_URLS and recreate Auth.





Top comments (0)