Dev

Self-Host Supabase with Docker Compose

The Supabase quickstart brings the whole stack up on localhost in about five minutes. That is the easy part. The gap between that and a deployment you would trust with real data is the default .env: it ships secrets that every reader of the docs already has, plus a dashboard password printed in the file as this_password_is_insecure_and_should_be_updated. This guide walks the whole path to self-host Supabase, from a clean Ubuntu box to an instance behind HTTPS with its own keys, SMTP wired for auth, and a backup that actually restores.

Original content from computingforgeeks.com - post 141778

Self-hosting Supabase means running PostgreSQL and ten services around it (the PostgREST API, GoTrue auth, Realtime, Storage, Studio, the Envoy API gateway, the Supavisor pooler, imgproxy, the meta service, and edge functions) under Docker Compose. Every command below was run end to end on a single Ubuntu server: the stack pulled, every default key regenerated, all eleven containers brought to healthy, and the REST API tested with a live request. The one thing the official quickstart glosses over, replacing the demo secrets, is the first thing that gets people compromised, so it gets its own step.

Deployed September 2026 on Supabase self-hosted v0.8.1, Ubuntu 24.04 LTS.

What you are deploying, and how big it has to be

Supabase is not one process. The compose file starts PostgreSQL and ten services around it, so the box has to hold all eleven containers plus the database working set. RAM is the constraint that bites first. Postgres wants its shared buffers and OS page cache sized to the working set, and the surrounding Node and Elixir services (Studio, Realtime, the meta service, edge functions) each carry their own heap. A quiet instance idles around 2 GB, but that leaves nothing for the database.

Size from the workload, not from this lab. For a real project, put Postgres RAM at the working set plus headroom for the other services: a 2 GB working set lands near 6 to 8 GB total, and anything with meaningful write traffic wants fast (NVMe) storage because the analytics and realtime paths are write-heavy. Disk is the database plus write-ahead logs plus the storage volume plus headroom, so start at 40 GB and grow it. The test box here ran 4 vCPU, 8 GB RAM, 40 GB disk, which is a floor for following along, not a production recommendation.

  • A server running a current Ubuntu LTS (this was tested on 24.04) with root or sudo access
  • Docker Engine and the Compose plugin (installed below)
  • A domain with an A record pointing at the server, and port 80 reachable, if you want HTTPS (any DNS provider works)
  • Outbound internet access to pull the container images (about 4 GB on first pull)

Step 1: Install Docker and the Compose plugin

Supabase ships as a Compose project, so Docker Engine and the docker compose plugin are the only prerequisites. Add Docker’s official repository and install the engine:

sudo apt-get update
sudo apt-get install -y ca-certificates curl git jq
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm the engine and the plugin are both present before going further:

docker --version
docker compose version

Both commands print a version and the Docker service is active. If either is missing, the full walkthrough is in the Docker and Compose install guide. With the engine running, pull the stack next.

Step 2: Get the Supabase stack

The self-hosting assets live in the docker directory of the main repository. Clone the pinned self-hosted release, then copy that directory into a project folder of your own so upstream updates never clobber your .env:

git clone --depth 1 --branch self-hosted/v0.8.1 https://github.com/supabase/supabase #https://github.com/supabase/supabase/releases
mkdir supabase-project
cp -rf supabase/docker/. supabase-project
cd supabase-project
cp .env.example .env

You now have a project directory with the compose files, a volumes/ tree for persistent data, a set of helper scripts under utils/, and a .env copied from the example. That .env is where every problem in this guide starts.

Step 3: Replace every default secret before the first boot

The demo .env is not a template with blanks to fill. It ships working secrets, and they are the same for everyone who has ever read the docs. The Postgres password is your-super-secret-and-long-postgres-password. The ANON_KEY and SERVICE_ROLE_KEY are real JWTs signed with a public demo secret, so anyone can mint a service_role token that bypasses every row-level-security policy you write. Boot with these on a public IP and the instance is compromised on arrival, not eventually.

Older guides send you to a web page to hand-craft JWTs. That step is gone. The repo now ships a generator that rewrites the secrets locally. Run it with --update-env so it writes straight into .env:

sh utils/generate-keys.sh --update-env

It prints each new secret and rewrites the file. The Postgres password, JWT secret, the anon and service_role keys, the Realtime and Vault encryption keys, and the dashboard password are all replaced in one pass:

JWT_SECRET=P+CLXbh7knjRgOeZzG7VLhYxvHh1dy0r...
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SECRET_KEY_BASE=tx/QDH6sKmkyAkBE/pBVTH+erxUGXLhb...
REALTIME_DB_ENC_KEY=2be8a0d6...
VAULT_ENC_KEY=2cdc7d20e6068a33...
PG_META_CRYPTO_KEY=rHZHNuOJy5OrW+/ljsxk...
LOGFLARE_PUBLIC_ACCESS_TOKEN=UTS5MH39kSED...
LOGFLARE_PRIVATE_ACCESS_TOKEN=bm6owD/3yvSP...
S3_PROTOCOL_ACCESS_KEY_ID=9619738f141c17bb...
S3_PROTOCOL_ACCESS_KEY_SECRET=cceb82b55e62...
MINIO_ROOT_PASSWORD=c974e3251c82...
POSTGRES_PASSWORD=c32f4d03217c...
DASHBOARD_PASSWORD=cc8dfdb68745...

Updating .env...

Recent Supabase also supports the newer API key format (a publishable key and a secret key) alongside the legacy JWTs. A second script generates that asymmetric pair and prints the keys to add to .env:

sh utils/add-new-auth-keys.sh

The legacy ANON_KEY and SERVICE_ROLE_KEY keep working, so this pair is optional for a first deployment. One value the scripts do not touch is the dashboard login name, which stays supabase. Change it, and confirm no demo strings survive:

sed -i 's/^DASHBOARD_USERNAME=.*/DASHBOARD_USERNAME=cfgadmin/' .env
grep -c 'your-super-secret\|this_password_is_insecure\|supabase-demo' .env

That count must read 0. Anything else means a demo value is still in the file. Two more values in .env matter before launch, and they are easy to get subtly wrong. API_EXTERNAL_URL is the public URL of the auth API and keeps its path (the default is http://localhost:8000/auth/v1, so the replacement keeps the /auth/v1 suffix). SITE_URL is your frontend application’s URL, not the gateway. The auth service builds confirmation and reset links from both, so leaving them at localhost is the reason self-hosted signup emails arrive with unclickable links.

Step 4: Start the stack and verify every container

Pull the images first. On a fresh box this is about 4 GB and the slowest part of the whole process:

docker compose pull

Then bring everything up. The --wait flag holds the command open until Docker reports each service healthy instead of returning the moment the containers are created:

docker compose up -d --wait

Check the roster. Every row should read Up and, for the services that define a health check, (healthy):

docker compose ps

The full roster on a clean deployment is eleven rows, every one healthy:

NAME                             SERVICE     STATUS
realtime-dev.supabase-realtime   realtime    Up 11 minutes (healthy)
supabase-auth                    auth        Up 11 minutes (healthy)
supabase-db                      db          Up 11 minutes (healthy)
supabase-edge-functions          functions   Up 11 minutes (healthy)
supabase-envoy                   api-gw      Up 11 minutes (healthy)
supabase-imgproxy                imgproxy    Up 11 minutes (healthy)
supabase-meta                    meta        Up 11 minutes (healthy)
supabase-pooler                  supavisor   Up 11 minutes (healthy)
supabase-rest                    rest        Up 11 minutes (healthy)
supabase-storage                 storage     Up 11 minutes (healthy)
supabase-studio                  studio      Up 11 minutes (healthy)

The same roster in the terminal, gateway on api-gw and the pooler on supavisor:

docker compose ps showing the healthy self-hosted Supabase stack

Eleven containers, all healthy. A service stuck in Restarting almost always means a secret in .env disagrees with what the database was initialized with, which is the error covered at the end.

Step 5: Reach Studio

Studio and every API sit behind the Envoy gateway on port 8000, not on separate ports. Point a browser at http://SERVER_IP:8000 and the gateway answers with an HTTP basic-auth prompt, using the DASHBOARD_USERNAME and DASHBOARD_PASSWORD from .env. That basic-auth wall is the only thing between the public internet and full database access, which is exactly why the dashboard password could not stay at its default.

The project home shows the instance URL and a health advisor. On a clean deployment it reports no issues:

Supabase Studio project dashboard on a self-hosted instance

The table editor is a live view of the public schema. A table created here, or over any client, appears immediately:

Supabase Studio table editor showing a notes table on self-hosted Ubuntu

The SQL editor runs against the same Postgres the API talks to. Anything you can do in psql works here, with the result grid inline:

Supabase Studio SQL editor running a query on self-hosted Postgres

Create a table here and it is queryable over the REST API in the next step, once one caveat about the schema cache is out of the way.

Step 6: Put it behind Nginx and HTTPS

Port 8000 is HTTP, and basic auth over plain HTTP sends that dashboard password in the clear. In production the gateway sits behind a reverse proxy that terminates TLS. Set the values that repeat across the next commands once:

export SITE_DOMAIN="supabase.example.com"
export ADMIN_EMAIL="[email protected]"

Install Nginx and Certbot, then create a proxy vhost. The full reverse-proxy pattern, with the WebSocket upgrade headers Realtime needs, is covered in the Nginx setup guide; the Supabase-specific piece is proxying to 127.0.0.1:8000:

sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo vim /etc/nginx/sites-available/supabase

Add a server block that forwards everything to the gateway and carries the upgrade headers so Realtime WebSockets survive the proxy. The SITE_DOMAIN_HERE placeholder is substituted from the shell variable in the next command:

server {
    listen 80;
    server_name SITE_DOMAIN_HERE;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Substitute the domain, enable the site, and check the config parses:

sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/g" /etc/nginx/sites-available/supabase
sudo ln -s /etc/nginx/sites-available/supabase /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

With the A record pointing at the server and port 80 open, Certbot issues the certificate over the HTTP-01 challenge and rewrites the vhost to serve HTTPS with a redirect:

sudo certbot --nginx -d "${SITE_DOMAIN}" --non-interactive --agree-tos --redirect -m "${ADMIN_EMAIL}"

Now point the auth URLs at the public names. Set API_EXTERNAL_URL=https://${SITE_DOMAIN}/auth/v1 (keep the /auth/v1 path) and SITE_URL to your frontend app’s URL, then recreate the stack so the auth service emits correct links. Open port 443, and drop public access to 8000 so the only way in is through the proxy.

Issuing the certificate behind NAT or for a wildcard

HTTP-01 needs inbound port 80. If the server sits on a private LAN with no public port 80, or you want a wildcard certificate, switch to the DNS-01 challenge, which proves ownership with a TXT record instead. Certbot has a plugin per provider:

DNS providerCertbot plugin package
Cloudflarepython3-certbot-dns-cloudflare
AWS Route 53python3-certbot-dns-route53
DigitalOceanpython3-certbot-dns-digitalocean
Google Cloud DNSpython3-certbot-dns-google
RFC2136 (BIND)python3-certbot-dns-rfc2136

Install your provider’s plugin, drop an API token in a credentials file, and issue the cert against that plugin. The Cloudflare form looks like this; substitute the plugin and credentials for your own provider:

sudo apt-get install -y python3-certbot-dns-cloudflare
echo "dns_cloudflare_api_token = your-token-here" | sudo tee /etc/letsencrypt/cloudflare.ini
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "${SITE_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}"

Point the Nginx ssl_certificate directives at the issued files and reload. Renewal runs the same way for both challenges, which you can confirm with sudo certbot renew --dry-run.

Step 7: Wire up SMTP so auth emails send

The default SMTP_HOST is supabase-mail, a host that is not part of the default stack, so the auth service cannot send anything and its log fills with SMTP connection errors. Signup confirmations and password resets never arrive, and users sit stuck at “check your email.” Point the SMTP_ variables in .env at a real relay:

[email protected]
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
SMTP_SENDER_NAME=Supabase

Apply the change by recreating the auth service, and watch its log to confirm it starts clean:

docker compose up -d --force-recreate auth
docker compose logs -f auth

For local testing before a relay is ready, set ENABLE_EMAIL_AUTOCONFIRM=true so signups skip the email step, or read the generated confirmation links straight out of docker compose logs auth. Never leave either shortcut in place once real users touch the instance.

Step 8: Connect an application

A client needs two things: the API URL and a key. The URL is the gateway, so https://SITE_DOMAIN once the proxy is up, or http://SERVER_IP:8000 for a quick local test. The key choice is the part that trips people up. The anon key (or the newer publishable key) is safe in a browser, and every request it makes is filtered by row-level security. The service_role key (or the newer secret key) bypasses RLS entirely and belongs only in server-side code and never in a client bundle.

Test the REST endpoint directly with the anon key against the notes table from the last step. PostgREST answers on /rest/v1/ and expects the key in both the apikey header and a bearer token:

export ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2)
curl -s "http://127.0.0.1:8000/rest/v1/notes?select=*" \
  -H "apikey: ${ANON_KEY}" \
  -H "Authorization: Bearer ${ANON_KEY}"

The row comes back as JSON:

[{"id":1,"body":"first note from the lab"}]

That row came straight from Postgres through the gateway. One caveat: a table created through Studio has row-level security on by default, so the same request returns an empty [] until you add a policy. That behavior, and the fix, is in the errors section below.

A JavaScript client is the same two values passed to createClient. The URL is your gateway, the key is the anon or publishable key:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://supabase.example.com',
  'YOUR_ANON_OR_PUBLISHABLE_KEY'
)

From here the client library, Postgres itself, and the pgvector extension are all available. If you are building retrieval or embeddings on top of this, the pgvector setup notes apply directly to the bundled database.

Step 9: Back up the database

All persistent state lives in the volumes/ tree next to the compose file. Postgres data is under volumes/db/data and uploaded files under volumes/storage. A file-level copy of those directories is only consistent while the stack is stopped, so the reliable backup is a logical dump taken from the running database container:

docker compose exec -T db pg_dumpall -U postgres | gzip > supabase-$(date +%F).sql.gz

That single file captures every schema, the auth users, and your data. Restoring is the inverse, piped back into the same container. For point-in-time recovery rather than nightly dumps, the same WAL-based approach in the Postgres backup and PITR guide works against this Postgres, since it is an ordinary instance. Whatever the method, test a restore into a throwaway stack before you need it.

Errors you will hit self-hosting Supabase

These are the failures that showed up during the deployment, with the fix for each.

FATAL: password authentication failed for user “authenticator”

A service, usually supabase-rest or supabase-auth, sits in a Restarting loop and its log repeats this line. It means a database password in .env no longer matches what Postgres was initialized with. The database password is written into volumes/db/data on the very first boot and is never re-read from .env after that, so editing POSTGRES_PASSWORD later breaks every service that connects with it:

supabase-rest  | Failed to query the PostgreSQL version. {"code":"PGRST000","details":"connection to server at \"db\" (172.18.0.4), port 5432 failed: FATAL:  password authentication failed for user \"authenticator\"","message":"Database connection error."}

This is why Step 3 regenerates every secret before the first up. If you already booted with the defaults, the clean fix on a dev instance is to stop the stack, delete the initialized data directory, and start fresh so Postgres re-reads the new password. On an instance that already has data, do not delete the volume: change the password inside the running database with ALTER USER to match .env instead.

docker compose down
sudo rm -rf volumes/db/data
docker compose up -d

PGRST205: Could not find the table in the schema cache

You create a table, then query it over REST and get this instead of rows:

{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.notes' in the schema cache"}

PostgREST caches the database schema and does not notice DDL made outside it, such as a CREATE TABLE run in psql. Tables created through Studio trigger the reload automatically, but a raw SQL change needs a nudge. Reload the cache with a NOTIFY, and the table appears immediately:

docker compose exec -T db psql -U postgres -c "NOTIFY pgrst, 'reload schema';"

The REST API returns an empty [] for a table that has rows

The table has data, the schema cache is fresh, and the anon key still gets back []. That is row-level security doing its job. Studio enables RLS on every new table by default, and with no policy attached, the deny-by-default rule hides every row from the anon and authenticated roles. It is a security feature, not a bug, and it is the single most common “why is my API empty” question in self-hosted Supabase. Add a policy that describes who may read the rows:

create policy "public read" on notes for select to anon using (true);

Run that in the SQL editor, re-query, and the rows come through. In production you would scope the using clause to the rows a caller should actually see rather than true, but the pattern is the same: no policy means no rows, so every table you expose needs one written for it.

Keep reading

What Is DeepSeek Harness? Install dsh and Run Your First Agent AI What Is DeepSeek Harness? Install dsh and Run Your First Agent Run DeepSeek Harness With a Local Model: Ollama, vLLM, llama.cpp AI Run DeepSeek Harness With a Local Model: Ollama, vLLM, llama.cpp OpenCode vs Claude Code vs Cursor: AI Coding Agents Compared (2026) AI OpenCode vs Claude Code vs Cursor: AI Coding Agents Compared (2026) Install Dockge on Ubuntu: Self-Hosted Docker Compose Manager Containers Install Dockge on Ubuntu: Self-Hosted Docker Compose Manager Self-Host Cal.com on Ubuntu with Docker DevOps Self-Host Cal.com on Ubuntu with Docker Kubernetes Node Management in Rancher Containers Kubernetes Node Management in Rancher

Leave a Comment

Press ESC to close