Use case · Backend platform

Self-hosted Supabase on a dedicated server, with Docker Compose

Self-hosted Supabase is the official Docker Compose stack, Postgres plus Auth, PostgREST, Realtime, Storage, Edge Runtime and the Studio dashboard behind one API gateway, run on a machine you control. Supabase lists 4 GB of RAM, 2 cores and 40 GB of SSD as the minimum and 8 GB, 4 cores and 80 GB as recommended. The install takes minutes; the work is in the secrets, the published ports and the backups.

See the machines

What the Compose stack runs

The Compose file in the docker/ folder of the supabase/supabase repository starts one container per service. Clients talk to a single gateway on port 8000, and Supavisor publishes 5432 and 6543 for Postgres clients; everything else sits on the internal Docker network.

ServiceJob
Envoy (Kong optional)API gateway on port 8000, routes /rest, /auth, /storage, /realtime, /functions
PostgresThe database; data under volumes/db/data
SupavisorConnection pooler: session mode on 5432, transaction mode on 6543
AuthSign-ups, logins and sessions, issuing JWTs
PostgRESTREST API generated from your schema
RealtimePushes database changes to subscribed clients over websockets
Storage, imgproxyFile API with Postgres-held permissions; image transforms
Edge RuntimeDeno functions from volumes/functions
Studio, postgres-metaThe dashboard and the API it uses to manage Postgres

Logflare and Vector, which feed the Log Explorer, are not in the default set. sh run.sh config add logs layers them on, and the docs warn they raise the resource requirements.

Sizing the machine

ResourceMinimumRecommended
RAM4 GB8 GB+
CPU2 cores4 cores+
Disk40 GB SSD80 GB+ SSD

Supabase gives these figures for running all components, for development and small to medium production workloads. Services you do not use (Realtime, Storage, imgproxy, Edge Runtime) can be deleted from docker-compose.yml with their dependencies to cut the footprint. Past that, size it as you would any Postgres host: the working set should fit in memory, and uploaded files in the Storage volume grow the disk independently of the database.

Which dedicated line fits

A dedicated server gives the stack the whole machine: no other tenant on the CPU, RAM or disks, root, and a port nobody meters, so Realtime fan-out and file downloads add nothing to the invoice. The range page says size memory first, then cores, then storage, and that applies here.

  • Moonshot cartridges (8 to 32 GB on most) cover a staging or small production instance. They have no hardware RAID, and only the m510 and m710x carry a console; the others have power control only.
  • Single-socket rack servers are the usual base for an application with its database on one box, with an out-of-band console (iLO, iDRAC or a Supermicro BMC).
  • Dual-socket lines suit a busy database: up to 1 TB of registered ECC memory on some lines, and on the enterprise blades a Smart Array controller for a RAID 1 mirror under volumes/db/data.

A racked machine is handed over in about 30 minutes once payment is approved; a built-to-order line takes 4 to 24 hours. Linux images install free from the control panel; the Linux servers page lists them.

Secrets before the first start

.env.example ships placeholder values for POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_USERNAME and DASHBOARD_PASSWORD, and those values are in a public repository. Anyone holding the example JWT_SECRET can mint a service-role token for your API. Replace every one before the stack is reachable from anywhere. Prerequisites first: Git, and Docker Engine 28.0 or later with the Compose plugin. add-new-auth-keys.sh runs Node through Docker when Node 16+ is absent, and Docker releases before 28.0 let hosts on the same network segment reach ports bound to 127.0.0.1.

cd /opt   # as root
git clone --depth 1 --branch self-hosted/v0.8.3 https://github.com/supabase/supabase
mkdir supabase-project
cp -rf supabase/docker/. supabase-project
cd supabase-project && cp .env.example .env && chmod 600 .env
printf 'ref=self-hosted/v0.8.3\n' > .supabase-version
sh utils/generate-keys.sh --update-env
sh utils/add-new-auth-keys.sh --update-env
chmod 600 .env .env.old
# now edit DASHBOARD_USERNAME by hand, then confirm nothing is left at the example value:
for k in POSTGRES_PASSWORD JWT_SECRET ANON_KEY SERVICE_ROLE_KEY DASHBOARD_USERNAME DASHBOARD_PASSWORD; do
  grep -qxF "$(grep "^$k=" .env.example)" .env && echo "$k is still the example value"
done

Run it as root in /opt. The tag is the one the guide pins; check the guide for a newer release. generate-keys.sh rewrites the JWT secret, both legacy keys, the Postgres and dashboard passwords and the encryption keys, and add-new-auth-keys.sh adds the ES256 key pair and the sb_publishable_ and sb_secret_ keys. Both edit with sed -i.old, leaving the previous file as .env.old, hence the second chmod. The username is not generated. The dashboard password must contain a letter and no special characters. .env also holds your SMTP password once you configure the production mail server Auth sends its emails through. The loop prints key names only, never values.

Ports, ufw and IPv6

The base Compose file publishes 8000 (gateway), 5432 and 6543 (Supavisor) on every interface, IPv4 and IPv6. Docker inserts its own iptables rules for published ports, so ufw rules do not stop traffic to them. Bind what does not need to be public to loopback instead, by editing the two Supavisor lines in docker-compose.yml:

# docker-compose.yml, service supavisor
    ports:
      - 127.0.0.1:${POSTGRES_PORT}:5432
      - 127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543

# host firewall, enabled before the first start
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enable
grep IPV6 /etc/default/ufw   # must read IPV6=yes

A 127.0.0.1 binding is IPv4 loopback only: the port no longer listens on any public address, IPv4 or IPv6. ufw then covers anything else that listens on the host, provided IPV6=yes. IPv6 is added at checkout here at no charge; if the server has an IPv6 address, the firewall must cover it. update.sh keeps local edits that do not collide with upstream changes, but re-read the ports after each update.

TLS and Studio behind Caddy

Studio is served through the gateway and protected only by HTTP basic auth, which over plain HTTP on port 8000 crosses the network readable. Supabase ships a Caddy override. Point a DNS A record at the server, set PROXY_DOMAIN and the three URLs (SUPABASE_PUBLIC_URL, API_EXTERNAL_URL, SITE_URL) to https values in .env, then run sh run.sh config add caddy and sh run.sh start. The override removes the gateway's host port, takes a Let's Encrypt certificate, sends the API paths to the gateway and puts every other path, which is Studio, behind basic auth with your dashboard credentials. This is the first start, so nothing has listened with example secrets. curl -I https://your-domain/auth/v1/ returning 401 means it is up. Only now, with the gateway's host port gone, check from another machine that 8000, 5432 and 6543 refuse connections on both the IPv4 and the IPv6 address.

Reach the pooler for psql or migrations through a tunnel under your own login, ssh -N -L 5432:127.0.0.1:5432 you@your-server, never by opening 5432. Port 5432 is Supavisor, so the username is postgres. followed by the POOLER_TENANT_ID value from .env, and the password is POSTGRES_PASSWORD; enter it at the prompt rather than in a connection string, where it lands in ps and shell history. Front ends use the publishable key; the secret key, like the legacy service-role key, belongs only on trusted servers and never in client code.

Backups and updates

Managed backups and point-in-time recovery are platform features that self-hosting lacks, and update.sh backs up configuration only, not Postgres or Storage data. The state to keep is the project directory (.env, volumes/db/data, volumes/storage, volumes/functions) and the named volume supabase_db-config, which holds the pgsodium key Postgres needs to decrypt. A cold copy, run as root, is the simplest consistent backup:

cd /opt/supabase-project && sh run.sh stop
umask 077 && mkdir -p /root/backups
tar -czf /root/backups/supabase-$(date +%F).tgz \
  -C /opt supabase-project \
  -C /var/lib/docker/volumes supabase_db-config
sh run.sh start

It stops the stack for the length of the copy, and the archive holds every secret, so the umask keeps it root-only and it should be encrypted before it leaves the box. Put it in a root-owned script rather than straight into crontab, where % must be escaped. For dumps without downtime, the restore guide uses supabase db dump for roles, schema and data and warns that raw pg_dump output drags in Supabase internals. Supabase publishes a tested Compose snapshot about once a month; back up, then sh update.sh --dry-run, sh update.sh, and sh run.sh pull && sh run.sh recreate.

Where self-hosting stops

A self-hosted instance is a single project: Studio does not support several organizations or projects, and most settings live in environment variables. Branching, advanced metrics, managed backups and PITR, analytics and vector buckets, ETL and the Management API are platform-only. Support is community, through GitHub Discussions and Discord. The Supabase CLI's local stack is a development tool and must not be exposed to the internet.

If the application only needs Postgres and talks to it directly, the API layer is overhead; the PostgreSQL server page covers tuning and replication on bare metal. If you would rather a panel run your containers, see self-hosted Coolify.

Questions

How much RAM does a self-hosted Supabase instance need?

Supabase's docs give 4 GB as the minimum and 8 GB or more as recommended for all components, with 2 and 4 cores and 40 and 80 GB of SSD. Enabling the logs override adds to that. Removing services you do not use lowers it.

Is the quick-start script safe to run?

curl -fsSL https://supabase.link/setup.sh | sh installs Docker if missing, fetches the configuration, writes fresh secrets, including a random Studio password, and prompts for the public URLs. The docs say you can read setup.sh before running it, which is the right habit for any piped script. It supports Debian, Ubuntu and RHEL-family distributions only. Nothing in its list of steps touches the firewall or binds ports to loopback.

Why can I still reach port 5432 after ufw denied it?

Ports published with Docker are handled by Docker's own iptables rules, ahead of ufw. Bind the mapping to 127.0.0.1 in docker-compose.yml and run sh run.sh recreate supavisor. Then test from another host against both the IPv4 and the IPv6 address.

Do I need the new publishable and secret keys?

They are the current format. add-new-auth-keys.sh creates them and an ES256 key pair, and folds your JWT_SECRET into the JWKS. The legacy keys (ANON_KEY, SERVICE_ROLE_KEY) keep working alongside. Self-hosted allows one publishable and one secret key, and changing JWT_SECRET later means regenerating the JWKS.

Can I move a hosted Supabase project to my own server?

The restore guide dumps roles, schema and data with supabase db dump and loads them with psql in one transaction. Storage objects and Edge Functions are not covered by that guide and move separately. Non-default extensions must be enabled on the new instance first.

Put Supabase on a whole machine

Pick a line by memory first, take root, and run the Compose stack with nothing else on the box.

See the machines

Related on Server Room

Other use cases