SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Self-host Supabase on a VPS with Docker

Run the official Supabase Docker stack on a server you own: the secrets you must replace, what the fourteen services do, RAM needs, backups, and updates.

What you are building

Self-hosting Supabase means running the official Docker Compose stack on your own server: Postgres, a REST API in front of it, an auth service, file storage, realtime websockets, and the Studio dashboard. You clone one repository, edit one .env file, and start about fourteen containers that together behave like a Supabase project you control.

The install is short. The part that goes wrong is the .env file. It ships with demo secrets that are published in the repository, and a stack started with those defaults is open to anyone who finds it. This guide covers the secrets you must replace, what each service is for, how much memory the stack really needs, and how to update it without deleting your database.

If Compose itself is new to you, read Docker Compose basics on a VPS first. Everything below assumes docker compose version already prints a version.

What the stack actually contains

Supabase is not one program. The Compose file starts a set of separate services on one network, and knowing which is which turns a wall of container names into something you can debug.

  • db is PostgreSQL with the Supabase extensions loaded. Every other service talks to it. If this container is unhealthy, everything else fails too.
  • kong is the API gateway. It listens on port 8000 and routes /rest/v1/, /auth/v1/ and /storage/v1/ to the right backend. It is the only container you should ever expose.
  • rest is PostgREST. It reads your Postgres schema and serves it as a REST API, so a new table becomes a new endpoint with no code.
  • auth is GoTrue. It issues the JSON web tokens (JWT) that identify your users.
  • storage and imgproxy handle file uploads and image resizing.
  • realtime streams database changes over websockets.
  • studio and meta are the dashboard and the admin API behind it.
  • analytics (Logflare) and vector collect logs, and supavisor is the Postgres connection pooler.

That list is why the resource numbers below are what they are. You are not running a database. You are running a database plus a dozen support services.

Sizing: plan for 8 GB of RAM

The stack idles at roughly 2.5 to 3 GB of resident memory on a fresh install, as of July 2026, before your own data or traffic. The analytics service and the Studio Node.js process are the two largest single consumers. A 2 GB server will start the containers and then lose one to the kernel out of memory killer, usually analytics or db, and the symptom is a container stuck restarting with exit code 137.

Give it 8 GB of RAM and 4 vCPU for anything you rely on. 4 GB works for a solo development instance if you accept that a heavy query and a Studio session at the same time will be slow. Disk matters too, because Postgres, the storage volume and the log data all live under the project directory. Start with 40 GB and watch it.

Install: clone the official repository

The supported path copies the docker directory out of the main repository into a project directory of your own. That separation matters, because it means a later git pull cannot overwrite your .env.

git clone --depth 1 https://github.com/supabase/supabase
mkdir supabase-project
cp -rf supabase/docker/* supabase-project
cp supabase/docker/.env.example supabase-project/.env
cd supabase-project
docker compose pull

docker compose pull downloads several gigabytes of images. It should end with every service marked Pulled. A manifest unknown error here means the pinned image tag was removed upstream, and the fix is to pull a newer copy of the repository rather than to edit tags by hand.

The secrets you must change before the first start

Do this before you start the stack, not after. Several of these values are written into data on first boot, so changing them later means resetting the database.

The repository ships a generator that produces every value correctly, including the two API keys that must be signed with your new JWT secret.

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

That script writes new values for JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, SECRET_KEY_BASE, REALTIME_DB_ENC_KEY, VAULT_ENC_KEY, PG_META_CRYPTO_KEY and the Logflare tokens into .env. It needs openssl, which is present on any normal Ubuntu image.

Two values it does not set, and you must edit by hand in .env:

  • POSTGRES_PASSWORD. Use letters and digits only. Punctuation here breaks the connection strings that several services build by joining strings, and the failure looks like an authentication error rather than a parsing error, which sends people hunting in the wrong place.
  • DASHBOARD_USERNAME and DASHBOARD_PASSWORD. These are the basic authentication credentials for Studio. The shipped default password is literally this_password_is_insecure_and_should_be_updated.

Understand why ANON_KEY and SERVICE_ROLE_KEY cannot be invented. Both are JWTs signed with JWT_SECRET. The gateway verifies that signature on every request, so a key that does not match your secret is rejected with {"message":"Invalid authentication credentials"}. This is the most common self-hosting failure: the operator changed JWT_SECRET but kept the demo keys. Generate all three together, always.

Treat SERVICE_ROLE_KEY like a root password. It bypasses row level security completely. It belongs in server side code and nowhere else.

Set SITE_URL and API_EXTERNAL_URL to the address your users will actually reach, for example https://supabase.example.com. Auth builds its email confirmation and OAuth callback links from those values, so leaving them at http://localhost:8000 sends every one of your users to their own machine.

Then check what you have:

sh run.sh secrets

Start it and confirm it is healthy

sh run.sh start
docker compose ps

run.sh start wraps docker compose up -d --wait, so it does not return until the health checks pass. Every service should show running (healthy) or running. First boot takes two to four minutes because Postgres runs its initialisation scripts before anything else can connect.

If a container is restarting, read its logs by service name:

docker compose logs db
docker compose logs auth

Studio is then on port 8000, and it will ask for the dashboard username and password you set.

Do not put port 8000 on the public internet

Kong on 8000 speaks plain HTTP. Every API key and every user password crosses the network in clear text, and the Studio credentials are basic authentication, which is base64 encoding rather than encryption.

Put a reverse proxy in front of it, terminate TLS (transport layer security) there, and bind Kong to the loopback address so nothing else can reach it. In docker-compose.yml the kong port mapping becomes 127.0.0.1:8000:8000, and the proxy forwards to that. Traefik in front of several Compose apps covers the certificate side.

Close the rest at the firewall as well, because Docker publishes ports by writing its own iptables rules that a naive ufw configuration never sees. That trap is explained in why Docker containers ignore your ufw rules.

Back up the database, not the directory

Postgres data lives in a bind mount at ./volumes/db/data. Copying that directory while the container runs gives you a torn copy, because Postgres buffers writes and the files on disk are only consistent at a checkpoint. Restoring it will usually work and will sometimes quietly lose the last transactions, which is the worst possible failure mode for a backup.

Dump instead. pg_dumpall runs inside the container and produces a consistent snapshot:

docker exec -t supabase-db pg_dumpall -U postgres > supabase-$(date +%F).sql

Check that the file is not empty before you trust it. Then ship those dumps off the server on a schedule, which is what encrypted offsite backups with restic is for. Back up your .env at the same time. Losing JWT_SECRET means every issued token becomes invalid and every stored encrypted secret is unreadable.

Uploaded files sit in ./volumes/storage, and those are ordinary files, so a plain copy is fine.

Update without losing data

Supabase pins image versions in docker-compose.yml, so nothing moves until you move it. Take a dump first, every time.

docker compose pull
sh run.sh recreate

recreate stops the stack and starts it again on the new images. Your data survives because it lives in the bind mounts on the host, not inside the containers. Read CHANGELOG.md in the repository before a major version jump, since Postgres major upgrades are not automatic and need a dump and a restore.

To pick up changes to the Compose file itself, clone the upstream repository again and copy its docker directory over your project, taking care not to overwrite .env.

The full reset, which destroys everything including the database, is a separate script and it asks for confirmation:

sh reset.sh

FAQ

Why do my API calls return "Invalid authentication credentials"?

Your ANON_KEY or SERVICE_ROLE_KEY was not signed with the JWT_SECRET currently in .env. The gateway verifies the signature on every request and rejects a mismatch. Regenerate all three together with sh utils/generate-keys.sh --update-env, then run sh run.sh recreate so the services read the new values.

Can I run self-hosted Supabase on a 2 GB VPS?

Not reliably. The stack idles near 3 GB as of July 2026 because it runs about fourteen services, so a 2 GB box loses containers to the out of memory killer and you see exit code 137 in docker compose ps. Use 8 GB for production and treat 4 GB as the floor for solo development.

Does self-hosted Supabase include edge functions?

Yes. The Compose file includes the Deno based functions runtime, and it serves anything you place under ./volumes/functions. It does not include the hosted platform's global deployment network, so your functions run on your one server, in one location.

How do I connect to the Postgres database directly?

Use docker exec -it supabase-db psql -U postgres for an interactive shell on the server itself. For an external client, connect through Supavisor on port 5432 with the user postgres.<POOLER_TENANT_ID> and your POSTGRES_PASSWORD. Do not open that port to the internet. Reach it over a VPN or an SSH tunnel.

SITE_URL and API_EXTERNAL_URL in .env were left at their defaults. The auth service builds every confirmation and password reset link from those two values, so it sends the address it was told to send. Set both to your real public URL and recreate the stack.