Supabase Docker self-hosting AWS

Self-host Supabase with Docker Compose without the CLI: custom registry setup

Run a production Supabase stack using plain Docker Compose without the CLI. Full setup with custom registries, environment variables, and inter-service networking.

·
The Supabase CLI is great for local development but poor for production self-hosting. This runbook shows how to run the full Supabase stack with plain Docker Compose, pull images from any registry, and configure services without CLI dependencies.

This runbook extends the ECR 403 registry override guide. That one fixes local dev. This one covers production self-hosted deployments where you don’t want the CLI in the loop at all.

Symptoms

You want to run Supabase self-hosted but:

  • supabase start is not suitable for production (it’s a development-only command).
  • Your CI/CD pipeline can’t depend on the Supabase CLI being installed.
  • You need images from a private registry, not public ECR.
  • You need full control over environment variables, networking, and service versions.

Cause

The Supabase CLI bundles a hidden Docker Compose configuration that it generates at runtime. This means:

  • You can’t pin exact versions in your IaC or GitOps flow.
  • The CLI applies its own opinionated networking and volume setup.
  • Upgrades happen when the CLI updates, not when you decide.
  • Corporate environments that require image provenance and signing can’t audit CLI-generated configs.

The solution is to maintain your own docker-compose.yml that references Supabase images directly, with full version pinning and registry control.

Fix

Step 1: Create a base Docker Compose file

Start from the official Supabase self-hosting reference, then customise for your registry:

# docker-compose.yml
version: "3.8"

services:
  postgres:
    image: ${IMAGE_REGISTRY:-docker.io}/supabase/postgres:15.6.1.143
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  auth:
    image: ${IMAGE_REGISTRY:-docker.io}/supabase/gotrue:v2.158.1
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@postgres:5432/postgres
      GOTRUE_SITE_URL: ${SITE_URL}
      GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
      GOTRUE_JWT_SECRET: ${JWT_SECRET}
      GOTRUE_JWT_EXP: 3600
      GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
      GOTRUE_MAILER_AUTOCONFIRM: "false"
      API_EXTERNAL_URL: ${API_EXTERNAL_URL}

  rest:
    image: ${IMAGE_REGISTRY:-docker.io}/supabase/postgrest:v12.2.3
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@postgres:5432/postgres
      PGRST_DB_SCHEMAS: public,storage,graphql_public
      PGRST_DB_ANON_ROLE: anon
      PGRST_JWT_SECRET: ${JWT_SECRET}

  realtime:
    image: ${IMAGE_REGISTRY:-docker.io}/supabase/realtime:v2.30.34
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: supabase_admin
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: postgres
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      API_JWT_SECRET: ${JWT_SECRET}

  storage:
    image: ${IMAGE_REGISTRY:-docker.io}/supabase/storage-api:v1.0.6
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@postgres:5432/postgres
      STORAGE_BACKEND: file
      FILE_STORAGE_BACKEND_PATH: /var/lib/storage
      ANON_KEY: ${ANON_KEY}
      SERVICE_KEY: ${SERVICE_ROLE_KEY}
      POSTGREST_URL: http://rest:3000
    volumes:
      - storage-data:/var/lib/storage

  kong:
    image: ${IMAGE_REGISTRY:-docker.io}/library/kong:3.5
    restart: unless-stopped
    ports:
      - "8000:8000"
      - "8443:8443"
    environment:
      KONG_DATABASE: "off"
      KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
    volumes:
      - ./kong.yml:/var/lib/kong/kong.yml:ro

volumes:
  postgres-data:
  storage-data:

Step 2: Create an .env file for secrets

# .env
IMAGE_REGISTRY=docker.io
POSTGRES_PASSWORD=your-strong-password-here
JWT_SECRET=your-jwt-secret-at-least-32-chars
ANON_KEY=your-anon-key
SERVICE_ROLE_KEY=your-service-role-key
SITE_URL=https://your-app.example.com
API_EXTERNAL_URL=https://api.example.com
SECRET_KEY_BASE=your-secret-key-base-at-least-64-chars

Step 3: Switch registries

To pull from your private ECR:

IMAGE_REGISTRY=123456789.dkr.ecr.eu-west-1.amazonaws.com

To pull from GHCR:

IMAGE_REGISTRY=ghcr.io

All services pick up the registry prefix from a single variable. No need to edit each image line.

Step 4: Start the stack

docker compose up -d

# Watch logs for startup errors
docker compose logs -f --tail=50

Step 5: Kong API gateway configuration

Create a kong.yml declarative config to route requests to the correct services:

# kong.yml
_format_version: "3.0"

services:
  - name: auth
    url: http://auth:9999
    routes:
      - name: auth-routes
        paths:
          - /auth/v1
        strip_path: true

  - name: rest
    url: http://rest:3000
    routes:
      - name: rest-routes
        paths:
          - /rest/v1
        strip_path: true

  - name: realtime
    url: http://realtime:4000
    routes:
      - name: realtime-routes
        paths:
          - /realtime/v1
        strip_path: true

  - name: storage
    url: http://storage:5000
    routes:
      - name: storage-routes
        paths:
          - /storage/v1
        strip_path: true

Validation

# 1. Confirm all containers are running
docker compose ps
# Expected: all services show "Up" status

# 2. Verify images came from the correct registry
docker compose images
# Expected: IMAGE column shows your registry prefix

# 3. Test PostgREST is responding
curl -s http://localhost:8000/rest/v1/ \
  -H "apikey: ${ANON_KEY}" | head -c 100
# Expected: JSON response (empty array or schema info)

# 4. Test Auth (GoTrue) health
curl -s http://localhost:8000/auth/v1/health
# Expected: {"status":"ok"}

# 5. Test database connectivity
docker compose exec postgres pg_isready -U postgres
# Expected: accepting connections

If all five checks pass, your Supabase stack is running independently of the CLI. You own the versions, the registry source, and the full configuration. From here, integrate into your deployment pipeline by templating the .env file from your secrets manager (AWS Secrets Manager, Vault, etc.).

For teams that need image scanning and supply chain controls, see our guide on self-hosting Supabase on AWS without ECR which covers building and signing your own images.

 

Jerzy Kopaczewski

Need production-grade Supabase on AWS?

Book a free 30-minute call. We build infrastructure for self-hosted Supabase with private registries, automated backups, and full observability.