Supabase is an open-source backend platform built on PostgreSQL. A self-hosted deployment gives you the database, an auto-generated REST API (PostgREST), authentication (GoTrue), file storage, Realtime subscriptions, Edge Functions and the Studio dashboard, all running as Docker containers behind a Kong API gateway. In this tutorial you will deploy Supabase with the official Docker Compose setup on Ubuntu 24.04, replace every default secret, publish it at https://supabase.your_domain through Nginx with a Let's Encrypt certificate, and verify the REST API, Auth and Edge Functions.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS with at least 4 GB of RAM and 2 vCPUs, for example a CubePath VPS. Use 8 GB for production workloads. Plan for at least 50 GB of disk.
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • A domain name with an A record for supabase.your_domain pointing to your_server_ip.
  • An SMTP account if you want Supabase Auth to send real confirmation and password reset emails.

Step 1 - Downloading the Supabase Docker files

The Docker Compose configuration lives in the docker directory of the main Supabase repository. Clone the repository with a shallow history and copy that directory into a separate project folder, so later repository updates do not overwrite your configuration:

cd ~
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

Check the contents:

ls -a
.  ..  .env  docker-compose.yml  volumes  ...

Step 2 - Generating secrets

The .env.example file ships with demo secrets that are public on GitHub. Anyone who knows them can read and write your whole database, so replace every one before the first start.

Generate a JWT secret. This key signs every token Supabase issues:

JWT_SECRET=$(openssl rand -hex 32)
echo "$JWT_SECRET"

The ANON_KEY and SERVICE_ROLE_KEY are JWTs signed with that secret, with the anon and service_role roles. You can create them with openssl alone. Run these lines in the same shell session, so JWT_SECRET is still set:

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
make_jwt() {
  local header payload sig iat exp
  iat=$(date +%s)
  exp=$((iat + 5 * 365 * 24 * 3600))
  header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
  payload=$(printf '{"role":"%s","iss":"supabase","iat":%d,"exp":%d}' "$1" "$iat" "$exp" | b64url)
  sig=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -hmac "$JWT_SECRET" -binary | b64url)
  printf '%s.%s.%s\n' "$header" "$payload" "$sig"
}
echo "ANON_KEY=$(make_jwt anon)"
echo "SERVICE_ROLE_KEY=$(make_jwt service_role)"
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIs...
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2Vydmlj...

The keys are valid for five years. The anon key is safe to ship in browser code because Row Level Security decides what it can access. The service_role key bypasses Row Level Security and must stay on your servers.

Generate the remaining secrets:

echo "POSTGRES_PASSWORD=$(openssl rand -hex 16)"
echo "DASHBOARD_PASSWORD=$(openssl rand -hex 16)"
echo "SECRET_KEY_BASE=$(openssl rand -base64 48)"
echo "VAULT_ENC_KEY=$(openssl rand -hex 16)"

VAULT_ENC_KEY must be exactly 32 characters, which is what openssl rand -hex 16 produces. Use hexadecimal for the Postgres password: some characters, such as @ or /, break the connection strings built from it.

Step 3 - Configuring the environment

Open the environment file:

nano .env

Set the secrets you generated and the public URLs. Leave the other lines as they are for now:

POSTGRES_PASSWORD=your_postgres_password
JWT_SECRET=your_jwt_secret
ANON_KEY=your_anon_key
SERVICE_ROLE_KEY=your_service_role_key
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=your_dashboard_password
SECRET_KEY_BASE=your_secret_key_base
VAULT_ENC_KEY=your_vault_enc_key

SITE_URL=https://your_app_domain
API_EXTERNAL_URL=https://supabase.your_domain
SUPABASE_PUBLIC_URL=https://supabase.your_domain

SITE_URL is where Auth redirects users after they confirm their email or sign in, usually your frontend. API_EXTERNAL_URL and SUPABASE_PUBLIC_URL are the address clients use to reach this Supabase instance.

Newer versions of .env.example may include more variables marked as secrets (for example tokens for the analytics service). Search the file for any value that still looks like a demo placeholder and replace it with the output of openssl rand -hex 32.

If you have an SMTP account, configure it in the same file so Auth can send emails:

SMTP_ADMIN_EMAIL=admin@your_domain
SMTP_HOST=smtp.your_provider.com
SMTP_PORT=587
SMTP_USER=your_smtp_user
SMTP_PASS=your_smtp_password
SMTP_SENDER_NAME=Your App

Protect the file, since it contains every credential of the stack:

chmod 600 .env

Step 4 - Binding the gateway to localhost

By default the Compose file publishes the Kong gateway on port 8000 on every interface. Ports published by Docker bypass UFW, so the API and Studio would be reachable over plain HTTP from the internet. Because Nginx will terminate TLS in front of Kong, bind Kong to the loopback interface only.

Open the Compose file:

nano docker-compose.yml

In the kong service, prefix its published ports with 127.0.0.1::

    ports:
      - 127.0.0.1:${KONG_HTTP_PORT}:8000/tcp
      - 127.0.0.1:${KONG_HTTPS_PORT}:8443/tcp

The connection pooler (the supavisor service) also publishes the Postgres ports 5432 and 6543. If only applications on this server need direct database access, apply the same 127.0.0.1: prefix to its ports entries.

Step 5 - Starting Supabase

Pull the images and start the stack in the background:

sudo docker compose pull
sudo docker compose up -d

The first start takes a minute or two while PostgreSQL initializes. Check the status:

sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
SERVICE     STATUS
analytics   Up 2 minutes (healthy)
auth        Up 2 minutes (healthy)
db          Up 2 minutes (healthy)
functions   Up 2 minutes
imgproxy    Up 2 minutes (healthy)
kong        Up 2 minutes (healthy)
meta        Up 2 minutes (healthy)
realtime    Up 2 minutes (healthy)
rest        Up 2 minutes
storage     Up 2 minutes (healthy)
studio      Up 2 minutes (healthy)
supavisor   Up 2 minutes (healthy)
vector      Up 2 minutes (healthy)

Every service should be Up, and those with a health check should be healthy. Confirm that Kong answers locally. Without an API key it rejects the request, which proves the gateway is running:

curl -s http://127.0.0.1:8000/rest/v1/
{"message":"No API key found in request"}

Step 6 - Publishing Supabase with Nginx and HTTPS

Install Nginx and Certbot:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

Create a server block for Supabase:

sudo nano /etc/nginx/sites-available/supabase

Proxy all traffic to Kong. The Upgrade and Connection headers are required for Realtime, which uses WebSockets, and the larger body size allows file uploads to Storage:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name supabase.your_domain;

    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }
}

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/supabase /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Allow HTTP and HTTPS through the firewall and request the certificate:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo certbot --nginx -d supabase.your_domain

Certbot adds the TLS configuration, redirects HTTP to HTTPS and installs a timer for automatic renewal. Check the renewal:

sudo certbot renew --dry-run

Open https://supabase.your_domain in your browser. The browser asks for credentials: use DASHBOARD_USERNAME and DASHBOARD_PASSWORD from .env, and Supabase Studio loads.

Step 7 - Creating a table and testing the REST API

In Studio, open the SQL Editor and run the following to create a table, enable Row Level Security and allow anonymous reads:

create table public.todos (
  id bigint generated always as identity primary key,
  task text not null,
  done boolean not null default false
);

alter table public.todos enable row level security;

create policy "Public read access"
  on public.todos for select
  to anon
  using (true);

insert into public.todos (task) values ('Deploy Supabase');

PostgREST exposes the table immediately. Query it from your workstation with the anon key:

curl -s "https://supabase.your_domain/rest/v1/todos?select=*" \
  -H "apikey: your_anon_key" \
  -H "Authorization: Bearer your_anon_key"
[{"id":1,"task":"Deploy Supabase","done":false}]

Without the policy, the same request returns an empty list: Row Level Security denies everything that no policy allows. Enable RLS on every table in the public schema.

Step 8 - Testing Auth and Edge Functions

Create a user through the Auth API:

curl -s -X POST "https://supabase.your_domain/auth/v1/signup" \
  -H "apikey: your_anon_key" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "your_strong_password"}'

The response contains the new user. If SMTP is configured and email confirmation is enabled, the user receives a confirmation email and appears under Authentication in Studio. If you have no SMTP yet, set ENABLE_EMAIL_AUTOCONFIRM=true in .env only for testing, and apply the change by recreating the service:

sudo docker compose up -d auth

Edge Functions are TypeScript files served by the functions service from volumes/functions/<name>/index.ts. The Compose setup includes a sample hello function. Call it:

curl -s "https://supabase.your_domain/functions/v1/hello" \
  -H "Authorization: Bearer your_anon_key"
"Hello from Edge Functions!"

To add your own, create volumes/functions/<name>/index.ts and restart the service with sudo docker compose restart functions.

Step 9 - Backing up the database

All application data lives in PostgreSQL, while uploaded files live in volumes/storage. Dump the database with pg_dump from inside the db container:

sudo docker compose exec -T db pg_dump -U postgres -d postgres -Fc > supabase-$(date +%F).dump

Check that the file is not empty:

ls -lh supabase-*.dump

Copy these dumps, together with .env and volumes/storage, to storage outside the server on a schedule.

Troubleshooting

A service keeps restarting. Read its logs, using the service name from docker compose ps:

sudo docker compose logs auth --tail 50

Requests fail with "Invalid JWT" or 401. ANON_KEY and SERVICE_ROLE_KEY were not signed with the current JWT_SECRET. Regenerate them with the same secret and recreate the stack with sudo docker compose up -d.

Changing POSTGRES_PASSWORD breaks the stack. The password is set when the database is first initialized in volumes/db/data. Changing .env later does not change it inside PostgreSQL, so choose it before the first start.

Realtime clients cannot connect. The proxy must pass WebSocket upgrades. Check that the Upgrade and Connection headers are in the Nginx configuration and reload Nginx.

Conclusion

You deployed a self-hosted Supabase stack on Ubuntu 24.04 with your own secrets, published it over HTTPS with Nginx and tested the REST API, Auth and Edge Functions. Next, point a frontend at https://supabase.your_domain with the supabase-js client and the anon key, configure OAuth providers for Auth, or move Storage to an S3-compatible bucket. When updating, pull the latest docker directory from the repository, compare it with your docker-compose.yml, then run sudo docker compose pull and sudo docker compose up -d.