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.
dbis PostgreSQL with the Supabase extensions loaded. Every other service talks to it. If this container is unhealthy, everything else fails too.kongis 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.restis PostgREST. It reads your Postgres schema and serves it as a REST API, so a new table becomes a new endpoint with no code.authis GoTrue. It issues the JSON web tokens (JWT) that identify your users.storageandimgproxyhandle file uploads and image resizing.realtimestreams database changes over websockets.studioandmetaare the dashboard and the admin API behind it.analytics(Logflare) andvectorcollect logs, andsupavisoris 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 pulldocker 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-envThat 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_USERNAMEandDASHBOARD_PASSWORD. These are the basic authentication credentials for Studio. The shipped default password is literallythis_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 secretsStart it and confirm it is healthy
sh run.sh start
docker compose psrun.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 authStudio 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).sqlCheck 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 recreaterecreate 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.shFAQ
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.
Why did my auth confirmation emails link to localhost?
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.