Docker Compose starts Supabase, but you still have to run it
Here's the starting point: Supabase ships a ready-made Docker Compose template for self-hosting. On a fresh server, it gets the full stack running in a few minutes, including Studio, authentication and the REST API. That's exactly what tempts teams into the wrong math. Run the template and you have a development environment, not a production operation.
What's missing isn't in any Compose file. Who backs up the database, and how do you restore it? Where do the keys live, and who's allowed to read them? What happens at the next upgrade when a service expects a newer database version? We've answered these questions in our runbooks for production operations. This guide sums up what lies between docker compose up and a system you can trust with customer data.
Who this is for: teams that want to run Supabase themselves for reasons of data sovereignty, for example on Hetzner, in their own data center or on Kubernetes. If you work with Supabase Cloud in Frankfurt, operations come included. For you, this text is a checklist of what you're paying for there.
What runs in the stack: ten services instead of one server
First, the scope: Supabase isn't a single application but a set of open-source services built around Postgres. The Compose template starts them as separate containers (as of October 2026):
- Postgres: the database, extended with Supabase extensions such as pg_net, pgsodium and supabase_vault.
- Auth (GoTrue): sign-in, sign-up and token issuance.
- PostgREST: generates the REST API from your database schema.
- Realtime: pushes database changes to clients over WebSocket.
- Storage and imgproxy: file storage, including image transformations.
- Edge Runtime: runs your Deno-based Edge Functions.
- Supavisor: pools many concurrent database connections into a few.
- Studio and postgres-meta: the dashboard and its interface to the database.
- Envoy: the API gateway that brings all paths together under one address. Older guides still show Kong here.
Logflare and Vector for logging live in a second, optional Compose file. You can leave them out at first, as long as you collect logs some other way. If you want to handle sign-in through your own identity provider, our article on Supabase and Keycloak walks through the setup.
The point: each of these services has its own versions, its own configuration and its own failure modes. Self-hosting Supabase means running ten services that all rely on the same database and the same keys. That's manageable. But it's operations, not hosting.
Postgres first: the one service you don't just restart
Every other service is replaceable: if Realtime fails, you restart the container. With Postgres, that only works as long as the data is intact. That's why the database gets its own lifecycle, with its own storage, its own backups and its own upgrade rules. Our article on Row Level Security explains how to handle access rights directly in the database.
Container, operator or managed service?
You have three options. The Postgres container from the Compose template is the simplest, but you handle backups yourself. On Kubernetes, an operator like CloudNativePG takes care of setup, continuous archiving and recovery. An external managed Postgres service takes operations off your hands, but often doesn't allow the extensions Supabase depends on.
The reason for that limitation: extensions like pgsodium and pg_net have to be loaded when the database starts, and they need extensive privileges. Many managed offerings allow neither, or only for a fixed list. So before you decide, check whether every extension Supabase uses is available from your provider.
The init scripts nobody runs
This is the pitfall hardly any guide mentions. On first start, the supabase/postgres image creates roles, schemas and extensions through its startup script. An operator like CloudNativePG, however, sets up the database itself and skips that startup script. Without a countermeasure, you end up missing the auth schema, the supabase_admin role and everything Auth and Storage build on.
Our solution: we copy the init SQL files from exactly the image tag we run and apply them once through a migration job. Each applied file gets an entry in a log table. A second run skips it; an interrupted run picks up again at the file that failed.
That leads to a hard rule: a new image tag brings new init files. So the tag and the applied SQL always change together, never separately.
Three details that decide the first start
- User ID: The database process has to run as the
postgresuser. In tag17.6.1.136, that's UID 100 with GID 101. Enter 0 here and Postgres starts as root, and the data directory check fails. - Preloaded libraries:
pg_net,pgsodium,pg_cronandsupabase_vaultonly work if they're listed inshared_preload_libraries. Take the list from the image'spostgresql.conf, not from memory. - pgsodium key: Supabase Vault encrypts secrets with a root key that a script generates on first start. This key has to sit next to the data and be backed up with it. A newly generated key won't decrypt old Vault entries.
In short: with Supabase, the database isn't just storage, it's part of the application. Treat it like any other Postgres and you'll find out at the first login that doesn't work.
Keys: a dozen values that have to match
Supabase runs on keys, and they depend on each other. The bundled scripts generate-keys.sh and add-new-auth-keys.sh write them to an .env file. That's fine for development. In production, this file belongs neither on the server nor in Git.
What you need to manage
- JWT secret and derived keys: the shared secret, the anon and service role keys, the asymmetric keys along with the JWKS document, plus the publishable and secret keys.
- Database password: The template uses one password for all system roles. We keep it that way because the init SQL expects exactly this one value.
- Service keys: Realtime, Supavisor and postgres-meta need their own values, and the dashboard needs its own password.
- Object storage: access keys for the file bucket and, separately, for the backup bucket.
Where the keys should live
In our runbooks, every value lives in a password manager and reaches the cluster through the External Secrets Operator. The operator reads the fields and creates Kubernetes secrets from them. The Git repository holds only references, never values.
Whether the source is Bitwarden, HashiCorp Vault or a cloud provider's service is up to your team. What matters is that there's exactly one source and that its export is stored separately from the servers.
One exception remains: the credential the cluster uses to unlock the password manager can't be stored inside it. We create this one value by hand, once, and use a dedicated account for it that holds only this platform's values. Anyone who can read this secret can unlock the account. That's why read access to it is tightly restricted in the cluster.
A typical real-world failure: Auth and PostgREST verify tokens with different keys. Sign-in works, but every query via /rest/v1 fails with a JWT error. The cause is almost always a service that still holds an old value. That's why we always rotate keys for all services at the same time.
What should be publicly reachable
The API gateway brings all paths together on one port. Of those, only the API paths need to be publicly reachable: Auth, REST, GraphQL, Realtime, Storage and Functions under /auth/v1, /rest/v1 and so on. Everything else belongs on the internal network.
- Studio: the dashboard with full access to the database. We protect it twice: with a password prompt via HTTP Basic Auth at the gateway and with an IP allowlist limited to the office or VPN network.
- postgres-meta: the interface Studio uses to work as a superuser. It never gets a public route.
- Postgres and Supavisor: Ports 5432 and 6543 stay internal. External tools connect through a tunnel or a VPN.
- TLS: certificates issued automatically via Let's Encrypt. Test against the staging environment first, because the production environment limits failed attempts.
A small detail with a big impact: for an IP allowlist to work, the ingress has to see the real source address. On Kubernetes, that means externalTrafficPolicy: Local on the ingress service. Without this setting, the allowlist checks the node's address, and Studio is either open to everyone or to no one.
Files don't belong on the database server
By default, the Compose template stores uploaded files on the local disk. That's enough for a test setup. In production, it couples two things that should be kept apart: if the server fails, the database and the files are gone at the same time. And as the files grow, they fill up the database's disk.
That's why we always use S3-compatible object storage outside the server, such as Hetzner Object Storage, Cloudflare R2 or AWS S3. Storage and imgproxy read from and write to the same bucket.
The differences between providers are in the details. With AWS, the endpoint stays empty; all others need their address and path-style addressing. With Cloudflare R2, you also have to disable the S3 tags for resumable uploads, or Storage responds with error 500.
And the sentence we write into every runbook: a backup stored in the same bucket as the live data isn't a backup. So database backups go to a second bucket with its own access key.
Backups you can actually restore
On backups, the uncomfortable question comes first: when did you last restore one? A dump that's created every night only proves that a dump gets created. Whether it turns back into a working Supabase only becomes clear when you restore it.
What needs to be backed up
- Postgres: continuous archiving of the write-ahead logs plus a daily base backup to the backup bucket. That lets you restore to any point in time, not just to last night.
- pgsodium key: lives in the data directory and is therefore part of the database backup.
- Files: already live in object storage. A second copy comes from the provider's replication or a sync to a third bucket.
- Keys and configuration: an encrypted export of the password manager and, on Kubernetes, the cluster state along with the key it uses to encrypt its secrets.
Putting it to the test
We run a restore drill in five steps: set up a fresh environment, restore the database from the backup, point the services at it, start the migration job and read a row in Studio that was written before the backup.
The migration job shouldn't have anything to do, because its log table was backed up along with the data. If it does do something, something is wrong. This check takes a minute and spares you the surprise in a real emergency.
A detail with consequences: if write-ahead log archiving fails, Postgres keeps the files until they've been archived. A misconfigured backup can quietly fill up the disk this way until the database stops. That's why we only turn on backups once the base installation is running, and then trigger a first backup by hand right away.
Bottom line: a restore you've never tested is a hypothesis. Whether you run the drill regularly or only before major upgrades is a question of cost. Running it at least once is not.
Upgrades: pinned versions and one component per day
The Compose template references image tags, and Supabase publishes new ones frequently. It's tempting to pull everything up to the latest version on a regular basis. We do the opposite: every version is pinned, and every image is additionally pinned by its digest, the checksum of its exact contents. A tag can be reassigned; a digest can't.
Three rules from day-to-day operations
- Change together what belongs together: The Postgres tag and the applied init SQL only ever change as a pair. Mixed versions are the most common reason Auth and PostgREST disagree about token algorithms.
- One component per day: Never update the operating system, Kubernetes, the database operator and the Supabase images on the same afternoon. Otherwise you won't know which change caused the failure.
- Read all the release notes: not just those of the target version, but those of every skipped version in between.
Schema changes go through migrations
In production, you don't change tables, policies and functions with a click in Studio, but through migration files in the repository. The Supabase CLI creates them locally, for example with supabase migration new. The local config.toml only serves the development environment; the server doesn't read it.
In production, the same migration job that applies the init SQL also applies your migrations: in a fixed order, and each one exactly once. A table keeps track of which version was applied. Seed data stays off by default, because a seed can rarely run twice without causing damage.
The payoff is twofold: after a restore, the database knows its own state. And every change is traceable in Git, with author, date and review.
Edge Functions belong in the image
When you self-host, the Edge Runtime loads your functions from a directory. So we build them into a dedicated image based on supabase/edge-runtime, versioned like any other software. That way, production runs exactly the code that was tested.
One limitation you should know about: when you self-host, JWT verification applies to all functions collectively. The per-function verify_jwt setting from the local config.toml has no effect there. If a single function needs an exception, verify the token inside the function itself.
Docker Compose, Kubernetes or Supabase Cloud?
Which brings us to the question that really belongs at the start: which operating model fits your team? Our default recommendation is either Supabase Cloud in Frankfurt or Supabase self-hosted on a Hetzner server. Kubernetes comes into play if your team already runs Kubernetes or wants to drive deployments entirely from Git.
| Supabase Cloud (Frankfurt) | Docker Compose on one server | Kubernetes with GitOps | |
|---|---|---|---|
| Operations | handled by Supabase | your team or us | your team or us |
| Data sovereignty | EU region, contracting party Supabase Pte. Ltd. | entirely with you | entirely with you |
| Backups | included in the plan, scope depends on the plan | set up and test yourself | via the operator, testing still required |
| Upgrades | by Supabase | by hand, one component at a time | via commit, can be rolled back |
| Barrier to entry | low | medium | high |
| Makes sense if | you want to get started quickly | a small team wants full control | Kubernetes is already in place or planned |
To be honest: a single Kubernetes node isn't high availability. It gives you reproducible deployments and clean key management. But if the server goes down, the service is down, just as it would be with Docker Compose.
If you need real fault tolerance, you need multiple nodes and a database replica on separate hardware. That significantly increases the operational effort. For most of the projects we support, a properly backed-up single server with a tested restore is the more honest choice.
When we advise against self-hosting
Being honest also means looking at the other side. We advise against self-hosting if no one on the team can take long-term ownership of backups and upgrades. A system that no one understands once a single person leaves costs more than any cloud bill.
We also advise against it if high availability is mandatory but the budget only covers one server. In that case, Supabase Cloud is the more honest choice. And finally, if data sovereignty isn't the reason at all: if you only want to cut costs, you'll usually put too low a price on your own working time.
What resources to plan for
For planning, a few firm numbers. For the plain Compose setup, Supabase states 4 GB of RAM as the minimum. Kubernetes adds cluster services on top: ingress, certificate management, GitOps, a secrets operator and a database operator.
Our runbooks assume at least 4 vCPU, 16 GB of RAM and 100 GB of SSD storage for this. The recommended size is 8 vCPU, 32 GB and 200 GB of SSD, so Studio, Realtime and a migration job can run at the same time without memory getting tight.
The bigger cost item is rarely the server; it's the working time for setup, restore drills and upgrades. How server, storage and operations compare against the cloud is covered in our honest TCO analysis.
Next steps
If you want to run Supabase yourself, settle the operations question first and the technology second: who is responsible for backups, restore drills and upgrades, including two years from now? You'll find the operating models, our price ranges and the cases where we advise against self-hosting on our Supabase page.
Want to know which route fits your project? In a video call, we go through your requirements for data sovereignty, availability and team. Afterward, you'll know whether Cloud, Compose or Kubernetes is the right choice. Book a Supabase intro call
