Build Supabase Pro

KINDAback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

The official Supabase stack running on one server behind TLS, with generated secrets rather than the shipped placeholders, Studio locked away from the public internet, nightly database and storage backups going off the machine, and a restore you have performed on a second server with the date recorded.

estimated effort multi-daythe files for this build are in the project pack

PlatformThe official supabase/docker Compose stackHostOne Linux server with at least 4 GB of memory and 2 virtual CPUsContainer runtimeDocker Engine 20.10 or newer with Compose v2GatewayKong, which the stack already includes, listening on port 8000EdgeCaddy in front of Kong for certificates and hostnamesBackupspg_dump plus a copy of the storage volume, sent off the machine

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. have readyroughly 20 to 40 USD a month at 8 GB with most providers

    Why Below this the stack starts and then dies under any real use. 8 GB is the honest recommendation once you have data.

    Get it Any virtual server provider. Choose a region near your users and a plan you can resize later. open ↗

    Verify free -m reports 4 GB or more and docker runs

  2. have readya few USD if you delete it the same day

    Why The drill is worthless on the machine that already has your state. You can destroy this one afterwards.

    Get it The same provider, the smallest plan that meets the memory floor. Create it for the drill and delete it after.

    Verify You can SSH to it and run Docker

  3. have readyroughly 10 to 15 USD a year

    Why Certificates need a hostname, and the API needs a stable address.

    Get it Any registrar. Add an A record for the API hostname pointing at the server.

    Verify dig your hostname returns the server address

  4. a few USD a month at these volumes; the first gigabytes are often free

    Why Backups on the server die with the server, which is the exact scenario you are protecting against.

    Get it Object storage from any provider, or a second host you control. Create a bucket and credentials scoped to write only into it.

    Verify A test file uploads and can be listed from your laptop

  5. decidefree

    Why The JWT secret is part of a working restore. Losing it means existing tokens and accounts do not survive.

    Get it A password manager entry, or an encrypted file kept somewhere other than the server. Record the anon key, service role key, JWT secret, database password and dashboard password.

    Verify You can retrieve all five from somewhere that is not the server

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- You are not designing a schema here. What you are designing is the operational state, and it is worth writing down before you start.
- secrets · POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD, all generated by you and none of them the shipped example values
- volumes · the PostgreSQL data directory and the storage volume, which are the two things whose loss is unrecoverable
- backups · dated database dumps and dated storage archives, in a destination that is not this server
- restore log · the date of every restore drill you have performed, kept in the README

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
POSTGRES_PASSWORDsecretrequiredgenerate-a-long-random-valueGenerate with openssl rand. Never the shipped example.
JWT_SECRETsecretrequiredgenerate-a-long-random-valueGenerate it, then derive the anon and service role keys from it. It is part of a working restore, so back it up.
ANON_KEYrequiredeyJhbGciOi...Signed with your JWT secret. Public by design, but it must be yours rather than the published example.
SERVICE_ROLE_KEYsecretrequiredeyJhbGciOi...Signed with your JWT secret. Bypasses row level security entirely, so it never reaches a browser.
DASHBOARD_PASSWORDsecretrequiredgenerate-a-long-random-valueStudio's own login. Change it even though Studio is not publicly exposed.
SITE_URLrequiredhttps://app.example.comWhere auth redirects send users after sign-in.
BACKUP_DESTrequireds3://your-bucket/supabaseThe off-server destination for both nightly artefacts.

The build, in order

  1. Server, Docker and a closed front door

    A server exists, runs containers, and exposes nothing to the internet that you have not decided to expose.

    1. Create a non-root user with sudo, disable password authentication for SSH, and enable unattended security updates.

      terminal
      adduser deploy && usermod -aG sudo deploy
      systemctl status ssh
    2. Use Docker's own repository rather than a distribution package, which is often several versions behind.

      terminal
      docker --version
      docker compose version
    3. Allow SSH, 80 and 443 only. Then scan from your laptop. Trusting the configuration file is how people expose databases.

      terminal
      ufw allow OpenSSH && ufw allow 80,443/tcp && ufw enable
      nmap -Pn YOUR_SERVER_IP
    done when · tick each as it passes
    watch out
    • Docker publishes ports by writing iptables rules that bypass the usual firewall front end, so a service you thought was blocked can be wide open. Verify with a port scan from a different machine rather than by reading your firewall configuration.
  2. The stack up, with secrets that are yours

    Supabase runs, and not one credential is the value that shipped in the example file.

    1. Take only the docker directory. Copy .env.example to .env, then treat every line of it as something you must replace.

      Files docker/.env

      terminal
      git clone --depth 1 https://github.com/supabase/supabase
      cp supabase/docker/.env.example supabase/docker/.env
    2. A long random PostgreSQL password, a long random JWT secret, then derive the anon and service role keys by signing the documented payloads with that secret. Store them in a password manager as you go.

      terminal
      openssl rand -base64 48
    3. Start the stack and read the health of every container. A container in a restart loop is the normal first outcome and the logs say why.

      terminal
      docker compose up -d
      docker compose ps
    done when · tick each as it passes
    watch out
    • The single most common self-hosting mistake is leaving the example values in place. The shipped anon key, service role key, JWT secret and dashboard password are published in the repository, so a stack running with them is readable and writable by anyone who finds it. Generate all of them before the first time the port is reachable, not after.
  3. TLS, hostnames and a private Studio

    The API is reachable over HTTPS at your domain, and the dashboard is not reachable by the public at all.

    1. An A record for the API hostname. Caddy proxies to Kong on 8000 and obtains certificates on first request.

      Files Caddyfile

    2. Bind its published port to 127.0.0.1 in the compose override, then reach it with ssh -L when you need it.

      Files docker-compose.override.yml

      terminal
      ssh -L 3000:localhost:3000 deploy@YOUR_SERVER
    3. Curl the API host from your laptop, and try the Studio port from your laptop too. The second one must fail.

      terminal
      curl -I https://api.example.com/rest/v1/
    done when · tick each as it passes
    watch out
    • Studio talks to the stack with the service role key, which bypasses every row level security policy you will ever write. Exposing it to the internet behind only a password makes that password equivalent to the whole database.
  4. Backups of both things that matter

    The database and the uploaded files both leave the server on a schedule.

    1. pg_dump from a container or from the host, gzipped and dated, run by cron or a systemd timer.

      terminal
      docker compose exec -T db pg_dump -U postgres postgres | gzip > db-$(date +%F).sql.gz
    2. Tar the storage volume to the same destination with the same date stamp so the two halves of a restore are obviously paired.

      terminal
      tar czf storage-$(date +%F).tar.gz -C ./volumes storage
    3. Copy to object storage or another host. Then check each morning that a file with today's date exists, and alert when it does not.

      Files scripts/backup.shscripts/check_backup.sh

    done when · tick each as it passes
    watch out
    • A database dump does not contain uploaded files, and a storage archive does not contain the database. Backing up only one gives you a restore that produces rows pointing at files that are gone, or files nobody can find. Back up both, on the same schedule, and restore both in the drill.
  5. The restore drill, actually performed

    You have brought the whole thing back on a different server, and you know how long it took.

    1. Same Docker setup, fresh clone of the stack, and the same environment file, which means the secrets are part of your backup and not only in your head.

    2. Load the dump into the fresh database and unpack the storage archive into the storage volume before starting the storage service.

      terminal
      gunzip -c db-2026-09-07.sql.gz | docker compose exec -T db psql -U postgres postgres
    3. Compare row counts, download a file that existed before the backup, and sign in with an existing account. Then record the date and how long it took.

      Files README.md

    done when · tick each as it passes
    watch out
    • Restoring onto the same server proves almost nothing, because the parts you forgot are still sitting there. The drill has value precisely because the second machine has none of your undocumented state.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more hosting.