DEV Community

William Kwabena Akoto
William Kwabena Akoto

Posted on

Self-Hosting Supabase on a Linux Server

Supabase has become one of the most popular open-source alternatives to Firebase, bundling a Postgres database, authentication, auto-generated REST and realtime APIs, storage, and edge functions into a single platform. While Supabase offers a managed cloud service, many teams choose to self-host it for reasons like data sovereignty, cost control, or compliance requirements.

This article walks through deploying a self-hosted Supabase stack on a Linux server using Docker Compose, and covers an often-overlooked security issue: what happens when the Kong API gateway is exposed to the public internet without proper safeguards.


What You'll Need

  • A Linux server (Ubuntu 22.04 or 24.04 LTS recommended) with at least 2 vCPUs and 4GB RAM
  • A non-root user with sudo privileges
  • Docker Engine and Docker Compose v2
  • A registered domain name pointed at your server's IP (recommended for production)
  • Basic familiarity with the command line

Step 1: Install Docker

If Docker isn't already installed, the official convenience script gets you up and running quickly:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
docker --version
docker compose version
Enter fullscreen mode Exit fullscreen mode

Step 2: Clone the Supabase Repository

Supabase maintains an official Docker Compose configuration in its main repository. You only need the docker directory, so a shallow clone keeps things light:

git clone --depth 1 https://github.com/supabase/supabase
cd supabase/docker
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure Environment Variables

The repository ships with an .env.example file (it's hidden, use ls -a to see it). Copy it to .env:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Open .env in your editor and update at minimum the following:

POSTGRES_PASSWORD=your-strong-database-password
JWT_SECRET=your-32-character-or-longer-secret
ANON_KEY=<generated-key>
SERVICE_ROLE_KEY=<generated-key>
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=your-strong-dashboard-password
SITE_URL=https://yourdomain.com
API_EXTERNAL_URL=https://api.yourdomain.com
SUPABASE_PUBLIC_URL=https://api.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

The ANON_KEY and SERVICE_ROLE_KEY are JSON Web Tokens signed with JWT_SECRET. The critical rule is that the secret used to sign these tokens must exactly match the JWT_SECRET value in your .env file. Below are two ways to generate them.

Never start the stack with the placeholder values from .env.example. These are publicly known and would leave your database and auth system trivially compromised.

Option A: Supabase's generate-keys.sh script (recommended)

The Supabase repo includes an official script for this, and it's already in the docker directory you cloned:

ls *.sh
Enter fullscreen mode Exit fullscreen mode

Run it with --update-env so it writes everything directly into your .env file:

./generate-keys.sh --update-env
Enter fullscreen mode Exit fullscreen mode

This generates a random JWT_SECRET (if one isn't already set), derives ANON_KEY and SERVICE_ROLE_KEY from it, and also generates the other secrets the stack needs are SECRET_KEY_BASE, VAULT_ENC_KEY, PG_META_CRYPTO_KEY, Logflare tokens, and S3/MinIO credentials and writing all of them into .env automatically.

If you'd rather review the values before applying them, run it without the flag and it will print the values to your terminal instead:

./generate-keys.sh
Enter fullscreen mode Exit fullscreen mode
JWT_SECRET=abc123...
ANON_KEY=eyJhbGciOi...
SERVICE_ROLE_KEY=eyJhbGciOi...
...
Enter fullscreen mode Exit fullscreen mode

Copy each value into the matching line in .env.

If generate-keys.sh isn't present (older clones may not have it), pull the latest changes from the repo or grab the script from the docker directory of https://github.com/supabase/supabase.

Option B: A Node.js script using built-in crypto

If you'd rather not rely on the shell script or want to understand exactly how these tokens are built, you can generate them yourself with nothing but Node's built-in crypto module (no jsonwebtoken package required).

1. Generate a strong JWT secret:

openssl rand -base64 32
Enter fullscreen mode Exit fullscreen mode

Copy the output, you'll need it in the next step and in your .env file.

2. Create the script:

cat > generate-keys.mjs << 'EOF'
import crypto from 'crypto'

// Paste the secret you generated with `openssl rand -base64 32`
const JWT_SECRET = 'PASTE_YOUR_GENERATED_SECRET_HERE'

function base64url(input) {
  const json = typeof input === 'string' ? input : JSON.stringify(input)
  return Buffer.from(json)
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
}

function signJWT(payload) {
  const header = { alg: 'HS256', typ: 'JWT' }
  const encodedHeader = base64url(header)
  const encodedPayload = base64url(payload)
  const signingInput = `${encodedHeader}.${encodedPayload}`

  const signature = crypto
    .createHmac('sha256', JWT_SECRET)
    .update(signingInput)
    .digest('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')

  return `${signingInput}.${signature}`
}

const issuedAt = Math.floor(Date.now() / 1000)
const expiresAt = issuedAt + (10 * 365 * 24 * 60 * 60) // 10 years from now

const anonKey = signJWT({
  role: 'anon',
  iss: 'supabase',
  iat: issuedAt,
  exp: expiresAt,
})

const serviceRoleKey = signJWT({
  role: 'service_role',
  iss: 'supabase',
  iat: issuedAt,
  exp: expiresAt,
})

console.log('ANON_KEY=' + anonKey)
console.log('SERVICE_ROLE_KEY=' + serviceRoleKey)
EOF
Enter fullscreen mode Exit fullscreen mode

3. Paste your generated secret into the script:

nano generate-keys.mjs
Enter fullscreen mode Exit fullscreen mode

Replace PASTE_YOUR_GENERATED_SECRET_HERE with the value from step 1.

4. Run it:

node generate-keys.mjs
Enter fullscreen mode Exit fullscreen mode
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU...
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2Ui...
Enter fullscreen mode Exit fullscreen mode

5. Update .env with the secret and both generated keys:

JWT_SECRET=<the same secret you pasted into the script>
ANON_KEY=<ANON_KEY output>
SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY output>
Enter fullscreen mode Exit fullscreen mode

Verifying the Keys Work

After updating .env, restart the stack and test the anon key against Kong:

docker compose down
docker compose up -d
curl http://localhost:8000/rest/v1/ -H "apikey: <your-ANON_KEY>"
Enter fullscreen mode Exit fullscreen mode

A JSON response (even an empty schema) confirms PostgREST accepted the key, meaning JWT_SECRET and ANON_KEY are correctly matched. A JWSError or 401 Invalid token means the secret used to sign the key doesn't match JWT_SECRET , regenerate both together.

Note: Recent Supabase releases also introduce a second key system (sb_publishable_... / sb_secret_...) using asymmetric EC P-256 signing, alongside the legacy ANON_KEY/SERVICE_ROLE_KEY JWTs. If your .env.example includes sections for these, generate-keys.sh --update-env (combined with add-new-auth-keys.sh) handles them too, the Node.js method above only covers the legacy HS256 keys, which remain fully supported.


Step 4: Start the Stack

Pull the images and bring everything up in detached mode:

docker compose pull
docker compose up -d
docker compose ps
Enter fullscreen mode Exit fullscreen mode

Within a minute or two, you should see all containers reporting as healthy, including db, auth, rest, realtime, storage, meta, studio, and kong.

By default, Supabase Studio (the dashboard) is reachable on port 3000, and the Kong API gateway which fronts every Supabase service listens on port 8000 (HTTP) and 8443 (HTTPS).


Step 5: Verify the Installation

From your server, confirm the API gateway responds:

curl http://localhost:8000/rest/v1/ -H "apikey: <your-anon-key>"
Enter fullscreen mode Exit fullscreen mode

A JSON response (even an error about missing tables) confirms PostgREST is reachable through Kong. Visit http://your-server-ip:3000 in a browser to log into Studio using the dashboard credentials from your .env file.

At this point, the stack is functional but not yet safe to expose to the internet.


The Security Risk: Exposing Kong Directly

Kong is the API gateway that sits in front of every Supabase service: PostgREST, GoTrue (Auth), Realtime, Storage, and the Edge Functions runtime. By default, the Docker Compose file binds Kong's ports (8000/8443) to all network interfaces, meaning if your server has a public IP and no firewall, anyone on the internet can reach it directly.

This creates several concrete risks:

1. Unauthenticated Access to Internal Routes

Kong's configuration (kong.yml) defines routes for internal-facing services such as the Postgres Meta API, which is used by Studio to introspect and modify your database schema. If these routes aren't properly locked down or are reachable without the correct API key, an attacker could query schema information or, in misconfigured setups, perform administrative actions against your database.

2. Brute-Force Attacks Against Auth Endpoints

The /auth/v1/* routes handle sign-up, sign-in, password recovery, and token refresh. Without rate limiting at the gateway, these endpoints are exposed to credential stuffing and brute-force attacks at whatever rate the attacker's infrastructure allows.

3. No TLS by Default

The default Compose setup serves Kong over plain HTTP on port 8000. Any credentials, JWTs or data in transit, including the anon and service_role keys sent as headers can be intercepted by anyone positioned between the client and server (e.g., on shared networks, compromised routers, or via DNS hijacking).

4. Leaked or Guessable Service Role Keys

The service_role key bypasses Row Level Security entirely. If Kong is exposed without TLS, or if this key ends up in client-side code, an attacker who captures or finds it gains unrestricted read/write access to every table in your database, RLS policies notwithstanding.

5. Direct Database Exposure via Supavisor

Less obviously, the Supavisor connection pooler also exposes a Postgres-compatible port (typically 5432 and 6543). If these ports are open to the world alongside Kong, attackers can attempt direct connections to your database, separate from the API layer entirely.


Mitigating the Kong Exposure Risk

The fixes below range from "absolutely mandatory" to "strongly recommended for production." None of them are optional if your server has a public IP.

1. Put Kong Behind a Reverse Proxy with TLS

Never expose Kong's raw HTTP port to the internet. Instead, run Nginx in front of it, terminate TLS there, and forward only HTTPS traffic to Kong's internal port.

The Supabase repository actually ships a docker-compose.nginx.yml overlay for exactly this purpose, combine it with the main compose file:

docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
Enter fullscreen mode Exit fullscreen mode

If you'd rather configure it yourself (or need to customize routing), here's how to set it up manually.

Install Nginx and Certbot:

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
Enter fullscreen mode Exit fullscreen mode

Create an Nginx server block for the API gateway, e.g. /etc/nginx/sites-available/api.yourdomain.com:

server {
    listen 80;
    server_name api.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        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;

        # Required for Realtime websocket connections
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
Enter fullscreen mode Exit fullscreen mode

And a second block for Studio, e.g. /etc/nginx/sites-available/studio.yourdomain.com:

server {
    listen 80;
    server_name studio.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable both sites:

sudo ln -s /etc/nginx/sites-available/api.yourdomain.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/studio.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Obtain TLS certificates with Certbot (this also rewrites the server blocks above to add the listen 443 ssl directives and redirect HTTP to HTTPS automatically):

sudo certbot --nginx -d api.yourdomain.com -d studio.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Certbot installs a renewal cron job/systemd timer automatically, so certificates stay valid without further action.

With this in place, Kong's port 8000 and Studio's port 3000 should be bound only to 127.0.0.1 in your docker-compose.yml (see the firewall step below), so the only way to reach either service is through Nginx on 443.

2. Firewall Everything Except 80/443

Use ufw (or your cloud provider's security groups) to close every port except what's strictly needed:

sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

Critically, this means Kong's 8000/8443 ports and Postgres/Supavisor's 5432/6543 ports should not be reachable from outside, only from localhost or your internal Docker network. If your docker-compose.yml binds these ports as "8000:8000", change them to bind only to loopback:

ports:
  - "127.0.0.1:8000:8000"
Enter fullscreen mode Exit fullscreen mode

3. Enforce Rate Limiting on Auth Routes

Kong supports rate-limiting plugins. Add a rate limit specifically on /auth/v1/* routes to slow down brute-force attempts. This can be configured in kong.yml or via Kong's admin API, limiting requests per IP per minute for sign-in and token endpoints.

4. Treat the Service Role Key as a Secret, Never Ship It to Clients

The service_role key should only ever be used in trusted server-side environments (your Next.js server actions, API routes, or backend services), never bundled into frontend JavaScript. Store it in environment variables that aren't prefixed for client exposure (in Next.js, anything without NEXT_PUBLIC_ stays server-side).

5. Restrict the Postgres Meta and Analytics Routes

The Postgres Meta service (used by Studio) and the Logflare analytics service are intended for internal use only. Either remove their routes from Kong's public-facing configuration entirely, or restrict access to them via IP allowlisting at the reverse proxy level, so only your Studio's host can reach them.

6. Enable Row Level Security on Every Table

Even with Kong properly secured, RLS is your last line of defense if an anon key is ever leaked. By default, newly created tables in Postgres have no RLS policies, meaning the anon role can read and write freely if RLS isn't enabled. Run this for every table:

ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
Enter fullscreen mode Exit fullscreen mode

Then define explicit policies for select, insert, update, and delete as appropriate.

7. Disable Studio in Production (or Lock It Down)

Supabase Studio is a powerful database administration tool, exactly the kind of thing you don't want reachable by anyone who finds your IP address. Options include:

  • Putting it behind its own subdomain with HTTP basic auth at the reverse proxy layer (in addition to the built in dashboard login)
  • Restricting access via VPN or IP allowlist
  • Running it only on a separate internal network and accessing it through an SSH tunnel

8. Keep Images Updated

Security fixes for Kong, GoTrue, PostgREST, and the other components ship regularly. Periodically pull updated images and restart:

docker compose pull
docker compose down
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Summary

Self-hosting Supabase gives you full control over your backend infrastructure, but that control comes with responsibility. The Docker Compose setup works well for local development out of the box, but the default configuration, particularly the direct exposure of Kong's HTTP port is not safe for production.

The core principle is straightforward: nothing should be reachable from the public internet except a TLS-terminated reverse proxy on ports 80/443. Everything else, thus, Kong's internal port, Postgres, Supavisor, and Studio should sit behind that proxy, a firewall, or both. Combine that with rate limiting, strict Row Level Security policies, and careful handling of the service_role key, and you have a self-hosted Supabase deployment that's both functional and defensible.

Top comments (0)