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 eleven 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 eleven 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.api-gwis the API gateway. Since the August 2026 release it runs Envoy; before that it was a service calledkong, and Kong is still available as an opt-in override. 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.functionsis the Deno based edge runtime, andsupavisoris the Postgres connection pooler.
Logs and analytics (Logflare plus vector) left the default Compose file in June 2026. They are now an opt-in override file that you enable with sh run.sh config add logs, the same layering of Compose files described in running one stack from several Compose files.
That list is why the resource numbers below are what they are. You are not running a database. You are running a database plus ten support services.
Sizing: plan for 8 GB of RAM
The stack idled at roughly 2.5 to 3 GB of resident memory on a fresh install as of July 2026, before your own data or traffic, and in that measurement the analytics service and the Studio Node.js process were the two largest single consumers. Analytics is no longer part of the default stack, so it only costs you memory if you turn the logs override on. A 2 GB server will start the containers and then lose one to the kernel out of memory killer, and the symptom is a container stuck restarting with exit code 137. Supabase's own self-hosting docs, checked in October 2026, list 4 GB of RAM, 2 CPU cores and 40 GB of SSD as the minimum, and 8 GB, 4 cores and 80 GB as recommended.
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, if you enable logs, the log data all live under the project directory. Start with 40 GB and watch it. Counting services before you pick a plan is a habit worth keeping for anything you self-host, since PhotoPrism and Immich have real RAM floors too: Immich's quick start page, checked in October 2026, asks for at least 6 GB of RAM and 2 CPU cores. If that is more server than your project needs yet, compare it with what Supabase's free cloud plan actually includes, since a small app that stays under 500 MB and never sits idle for a week may run there at no cost.
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, the Logflare tokens, the S3 protocol keys and MINIO_ROOT_PASSWORD into .env. As of October 2026 it also writes random values for POSTGRES_PASSWORD and DASHBOARD_PASSWORD. It needs openssl, which is present on any normal Ubuntu image. Because it now rewrites the database password as well, run it with --update-env only before the first start, never against a stack that already holds data.
Two values are still worth checking by hand in .env:
POSTGRES_PASSWORD. The script fills it with random hex, which is safe. If you replace it with your own, 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 script sets a random password but leaves the username at the defaultsupabase. The shipped default password is literallythis_password_is_insecure_and_should_be_updated, so if you skipped the script, change it now.
Understand why ANON_KEY and SERVICE_ROLE_KEY cannot be invented. Both are JWTs signed with JWT_SECRET. The gateway accepts only the exact keys in .env, and the services behind it verify the signature against JWT_SECRET, so a key that does not match is rejected with HTTP 401. The default Envoy gateway answers 401 Unauthorized, and so does the current Kong override, which runs Kong 3.9 and returns {"message":"Unauthorized"}. Installs from before March 2026 ran Kong 2.8, which answers {"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 to the address of your app, and API_EXTERNAL_URL to the public address of the API with /auth/v1 on the end, for example https://supabase.example.com/auth/v1. Set SUPABASE_PUBLIC_URL to the same public address without the path. Auth builds its email confirmation and OAuth callback links from those values, so leaving them at their localhost defaults 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
The gateway 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 the gateway to the loopback address so nothing else can reach it. In docker-compose.yml the port line under the api-gw service becomes 127.0.0.1:${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp, and the proxy forwards to that. On an install from before the August 2026 switch to Envoy, the same port line sits under a service called kong instead. Traefik in front of several Compose apps covers the certificate side. That same proxy ends up fronting everything else on the box, from this stack to something as unserious as a Jellyfin library rebuilt as a 90s video store, and each of them wants a hostname rather than another open port. If a dashboard is only ever for you, skip the proxy and reach the loopback port over an SSH tunnel instead, the same approach self-hosted open-kritt uses to keep its scanning interface off the public internet entirely.
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. A scheduled dump that fails quietly is indistinguishable from no backup at all, so have the cron or systemd job push an alert to your phone when it exits non-zero. 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. Pinning is worth copying in any stack you assemble by hand, which is why a self-hosted RustDesk relay pins its two server images rather than following a moving tag: an upgrade should be something you choose on a morning you have time for 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 a Postgres major upgrade is a separate step that recreate does not perform. Postgres 17 has been the default since June 2026, and an existing install on Postgres 15 moves to it with sudo bash utils/upgrade-pg17.sh, which uses pg_upgrade to migrate the data in place and asks for confirmation at each major step. It needs every container running and free disk of at least twice your database size plus 5 GB, and Supabase still recommends your own independent backup before you start.
To pick up changes to the Compose file itself, run sh update.sh from the project directory, or sh update.sh --dry-run to see what would change first. It merges the upstream files with your own edits, never overwrites existing values in .env (it only appends new keys), and archives your configuration under backups/ before writing, though not the database. An install without a .supabase-version file needs --from and the release it started from. This matters for the August 2026 release in particular, which replaced the kong service with api-gw and requires a docker-compose.yml update. The manual route still works too: 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 and puts .env back to the defaults (the old one is kept as .env.old), is a separate script and it asks for confirmation:
sh reset.shFAQ
Why do my API calls return "Invalid authentication credentials"?
That exact message comes from Kong 2.8, the gateway in the stack on installs from before March 2026. The default Envoy gateway and the current Kong override, which runs Kong 3.9, report the same problem as a plain 401 Unauthorized. Either way, the key you sent does not match the ANON_KEY or SERVICE_ROLE_KEY in .env, or those keys were not signed with the JWT_SECRET currently in .env. On a stack that has never held data, regenerate all three together with sh utils/generate-keys.sh --update-env. On a running stack, run the script without the flag and answer no, so it only prints new values, copy JWT_SECRET, ANON_KEY and SERVICE_ROLE_KEY into .env by hand, and leave POSTGRES_PASSWORD alone. 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. Supabase's own self-hosting docs list 4 GB of RAM as the minimum as of October 2026, and the stack idled near 3 GB as of July 2026 in a measurement that included the logging services, which are now opt-in. 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.