Skip to content

Supabase on Kubernetes - Complete Deployment Guide

Last Updated: 2025-11-10 Version: v1.0 Tested On: k3s v1.28+, Debian 12

This guide provides complete step-by-step instructions for deploying Supabase on your k3s cluster from scratch.


Prerequisites

Before starting, ensure you have completed:

Phase 1: k3s Cluster Installation - 3-node cluster running ✅ Phase 2: kubectl Configuration - kubectl access configured ✅ Phase 3: Core Infrastructure - Longhorn storage + MetalLB LoadBalancer installed

Verify Prerequisites:

# Check cluster is running
kubectl get nodes
# Should show: k3s-master, k3s-worker-1, k3s-worker-2 all Ready

# Check Longhorn is installed
kubectl get sc
# Should show: longhorn (default)

# Check MetalLB is installed
kubectl get pods -n metallb-system
# Should show: controller and speaker pods Running

# Check available IPs for LoadBalancer
# You need 2 available IPs from your MetalLB pool
# Example: 10.89.97.214 and 10.89.97.215

Required Tools: - kubectl configured and working - openssl for generating secrets - Text editor (vim, nano, etc.) - Optional: Docker for JWT token generation


Architecture Overview

We're deploying these components:

PostgreSQL (StatefulSet)
    ├── GoTrue (Auth) - Deployment
    ├── PostgREST (REST API) - Deployment
    ├── Storage (Files) - Deployment
    └── postgres-meta (DB Admin) - Deployment

Kong API Gateway (LoadBalancer) ← Entry point for all API requests
Studio Dashboard (LoadBalancer) ← Web UI for management

External Access: - Kong API: http://<METALLB_IP_1>:8000 - Studio Dashboard: http://<METALLB_IP_2>:3000


Phase 1: Prepare Secrets and Configuration

Step 1.1: Generate Secure Passwords and Secrets

# Create working directory
mkdir -p ~/supabase-setup
cd ~/supabase-setup

# Generate PostgreSQL password (32 characters)
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d "=+/" | cut -c1-32)
echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> supabase-secrets.env

# Generate JWT Secret (must be 32+ characters)
JWT_SECRET=$(openssl rand -base64 48 | tr -d "=+/" | cut -c1-64)
echo "JWT_SECRET=$JWT_SECRET" >> supabase-secrets.env

# Display generated secrets
cat supabase-secrets.env

IMPORTANT: Save this file somewhere secure! You'll need these values.


Step 1.2: Generate JWT Tokens

Supabase requires two JWT tokens: ANON_KEY (public) and SERVICE_ROLE_KEY (admin).

Option A: Using jwt.io (Recommended for beginners)

  1. Go to https://jwt.io
  2. Select algorithm: HS256
  3. Set payload for ANON_KEY:
    {
      "iss": "supabase",
      "ref": "localhost",
      "role": "anon",
      "iat": 1762817111,
      "exp": 2078177111
    }
    
  4. In "VERIFY SIGNATURE", paste your JWT_SECRET (from step 1.1)
  5. Copy the encoded token (long string at top-left)
  6. Save as ANON_KEY

  7. Repeat for SERVICE_ROLE_KEY with payload:

    {
      "iss": "supabase",
      "ref": "localhost",
      "role": "service_role",
      "iat": 1762817111,
      "exp": 2078177111
    }
    

  8. Copy encoded token and save as SERVICE_ROLE_KEY

Option B: Using Docker (Advanced)

# Generate ANON_KEY
docker run --rm supabase/gotrue:v2.132.3 \
  jwt generate anon \
  --secret "$JWT_SECRET" \
  --expiry 10y

# Generate SERVICE_ROLE_KEY
docker run --rm supabase/gotrue:v2.132.3 \
  jwt generate service_role \
  --secret "$JWT_SECRET" \
  --expiry 10y

Save the tokens:

# Add to your secrets file
echo "ANON_KEY=<paste-anon-key-here>" >> supabase-secrets.env
echo "SERVICE_ROLE_KEY=<paste-service-role-key-here>" >> supabase-secrets.env

Step 1.3: Choose LoadBalancer IPs

Decide which IPs from your MetalLB pool to use:

# Example IPs (adjust to your network):
KONG_IP="10.89.97.214"
STUDIO_IP="10.89.97.215"

echo "KONG_IP=$KONG_IP" >> supabase-secrets.env
echo "STUDIO_IP=$STUDIO_IP" >> supabase-secrets.env

Verify these IPs are available in your MetalLB IP pool configuration.


Phase 2: Create Kubernetes Manifests

Step 2.1: Create Namespace

kubectl create namespace supabase

# Verify
kubectl get namespaces | grep supabase

Step 2.2: Create Secrets

Load your generated secrets:

# Source the secrets file
source ~/supabase-setup/supabase-secrets.env

# Create Kubernetes secret
kubectl create secret generic supabase-secrets \
  --namespace=supabase \
  --from-literal=POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
  --from-literal=JWT_SECRET="$JWT_SECRET" \
  --from-literal=ANON_KEY="$ANON_KEY" \
  --from-literal=SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
  --from-literal=DATABASE_URL="postgres://postgres:$POSTGRES_PASSWORD@postgres.supabase.svc.cluster.local:5432/postgres?search_path=auth" \
  --from-literal=DB_URL="postgres://postgres:$POSTGRES_PASSWORD@postgres.supabase.svc.cluster.local:5432/postgres"

# Verify secret created
kubectl get secret -n supabase supabase-secrets

Step 2.3: Create ConfigMap

Create configuration for all services:

cat > /tmp/supabase-configmap.yaml << 'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
  name: supabase-config
  namespace: supabase
data:
  # API URLs - IMPORTANT: Use LoadBalancer IPs, not internal DNS
  API_EXTERNAL_URL: "http://KONG_IP_PLACEHOLDER:8000"
  SUPABASE_PUBLIC_URL: "http://KONG_IP_PLACEHOLDER:8000"
  SUPABASE_URL: "http://KONG_IP_PLACEHOLDER:8000"

  # PostgreSQL Configuration
  POSTGRES_HOST: "postgres.supabase.svc.cluster.local"
  POSTGRES_PORT: "5432"
  POSTGRES_DB: "postgres"
  POSTGRES_USER: "postgres"

  # PostgREST Configuration
  PGRST_DB_SCHEMA: "home_portal,money_tracker,public,storage,graphql_public"
  PGRST_DB_ANON_ROLE: "anon"
  PGRST_DB_USE_LEGACY_GUCS: "false"
  PGRST_APP_SETTINGS_JWT_EXP: "3600"
  PGRST_APP_SETTINGS_JWT_SECRET: "placeholder"

  # GoTrue Configuration
  GOTRUE_SITE_URL: "http://localhost:3000"
  GOTRUE_URI_ALLOW_LIST: "*"
  GOTRUE_DISABLE_SIGNUP: "false"
  GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
  GOTRUE_MAILER_AUTOCONFIRM: "true"
  GOTRUE_JWT_AUD: "authenticated"
  GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated"
  GOTRUE_JWT_ADMIN_ROLES: "service_role"
  GOTRUE_JWT_EXP: "3600"

  # Storage Configuration
  STORAGE_BACKEND: "file"
  FILE_SIZE_LIMIT: "52428800"
  STORAGE_S3_REGION: "us-east-1"

  # Service Ports
  KONG_HTTP_PORT: "8000"
  KONG_HTTPS_PORT: "8443"
  STUDIO_PORT: "3000"
  REALTIME_PORT: "4000"
  REALTIME_DB_URL: "placeholder"
EOF

# Replace placeholder with actual Kong IP
sed -i "s/KONG_IP_PLACEHOLDER/$KONG_IP/g" /tmp/supabase-configmap.yaml

# Apply ConfigMap
kubectl apply -f /tmp/supabase-configmap.yaml

# Verify
kubectl get configmap -n supabase supabase-config

Phase 3: Deploy PostgreSQL

PostgreSQL must be deployed first as all other services depend on it.

Step 3.1: Create PostgreSQL StatefulSet

cat > /tmp/postgres.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: supabase
  labels:
    app: postgres
spec:
  ports:
    - port: 5432
      targetPort: 5432
      protocol: TCP
      name: postgresql
  clusterIP: None
  selector:
    app: postgres

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: supabase
  labels:
    app: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      securityContext:
        fsGroup: 102
        fsGroupChangePolicy: "OnRootMismatch"

      initContainers:
        - name: fix-permissions
          image: busybox:latest
          command:
            - sh
            - -c
            - |
              rm -rf /var/lib/postgresql/lost+found
              chown -R 101:102 /var/lib/postgresql
              chmod 755 /var/lib/postgresql
              mkdir -p /run-postgresql
              chown 101:102 /run-postgresql
              chmod 775 /run-postgresql
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql
            - name: postgres-run
              mountPath: /run-postgresql

      containers:
        - name: postgres
          image: supabase/postgres:15.1.1.78
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 5432
              name: postgresql
              protocol: TCP
          env:
            - name: POSTGRES_USER
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_USER
            - name: POSTGRES_DB
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_DB
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: POSTGRES_PASSWORD
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql
            - name: postgres-run
              mountPath: /var/run/postgresql
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: 2000m
              memory: 4Gi
          livenessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - pg_isready -U postgres
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 5
          readinessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - pg_isready -U postgres
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 3

      volumes:
        - name: postgres-run
          emptyDir: {}

  volumeClaimTemplates:
    - metadata:
        name: postgres-data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: longhorn
        resources:
          requests:
            storage: 20Gi
EOF

# Apply PostgreSQL deployment
kubectl apply -f /tmp/postgres.yaml

# Watch PostgreSQL pod start
kubectl get pods -n supabase -w
# Press Ctrl+C when postgres-0 shows Running

Expected Output:

NAME         READY   STATUS    RESTARTS   AGE
postgres-0   1/1     Running   0          45s


Step 3.2: Initialize Database Schemas

Once PostgreSQL is running, create the required schemas:

# Wait for PostgreSQL to be fully ready
sleep 10

# Create auth schema (required by GoTrue)
kubectl exec -n supabase postgres-0 -- psql -U postgres -c "CREATE SCHEMA IF NOT EXISTS auth;"

# Create storage schema (required by Storage API)
kubectl exec -n supabase postgres-0 -- psql -U postgres -c "CREATE SCHEMA IF NOT EXISTS storage;"

# Create application schemas
kubectl exec -n supabase postgres-0 -- psql -U postgres -c "CREATE SCHEMA IF NOT EXISTS home_portal;"
kubectl exec -n supabase postgres-0 -- psql -U postgres -c "CREATE SCHEMA IF NOT EXISTS money_tracker;"

# Grant permissions
kubectl exec -n supabase postgres-0 -- psql -U postgres << 'EOF'
GRANT USAGE ON SCHEMA auth TO postgres, authenticated, service_role, anon;
GRANT ALL ON SCHEMA auth TO postgres;

GRANT USAGE ON SCHEMA storage TO postgres, authenticated, service_role, anon;
GRANT ALL ON SCHEMA storage TO postgres;

GRANT USAGE ON SCHEMA home_portal TO postgres, authenticated, service_role, anon;
GRANT ALL ON SCHEMA home_portal TO postgres;

GRANT USAGE ON SCHEMA money_tracker TO postgres, authenticated, service_role, anon;
GRANT ALL ON SCHEMA money_tracker TO postgres;
EOF

# Verify schemas created
kubectl exec -n supabase postgres-0 -- psql -U postgres -c "\dn"

You should see: auth, storage, home_portal, money_tracker, public


Phase 4: Deploy Core Services

Now deploy the Supabase services in order.

Step 4.1: Deploy GoTrue (Authentication)

GoTrue manages user authentication and runs migrations to set up the auth schema.

# Create GoTrue deployment manifest
cat > /tmp/gotrue.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: gotrue
  namespace: supabase
  labels:
    app: gotrue
spec:
  type: ClusterIP
  ports:
    - port: 9999
      targetPort: 9999
      protocol: TCP
      name: http
  selector:
    app: gotrue

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gotrue
  namespace: supabase
  labels:
    app: gotrue
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gotrue
  template:
    metadata:
      labels:
        app: gotrue
    spec:
      containers:
        - name: gotrue
          image: supabase/gotrue:v2.132.3
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 9999
              name: http
              protocol: TCP
          env:
            - name: GOTRUE_API_HOST
              value: "0.0.0.0"
            - name: GOTRUE_API_PORT
              value: "9999"
            - name: GOTRUE_DB_DRIVER
              value: "postgres"
            - name: GOTRUE_DB_DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: DATABASE_URL
            - name: GOTRUE_SITE_URL
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_SITE_URL
            - name: GOTRUE_URI_ALLOW_LIST
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_URI_ALLOW_LIST
            - name: GOTRUE_DISABLE_SIGNUP
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_DISABLE_SIGNUP
            - name: GOTRUE_JWT_ADMIN_ROLES
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_JWT_ADMIN_ROLES
            - name: GOTRUE_JWT_AUD
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_JWT_AUD
            - name: GOTRUE_JWT_DEFAULT_GROUP_NAME
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_JWT_DEFAULT_GROUP_NAME
            - name: GOTRUE_JWT_EXP
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_JWT_EXP
            - name: GOTRUE_JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: JWT_SECRET
            - name: GOTRUE_EXTERNAL_EMAIL_ENABLED
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_EXTERNAL_EMAIL_ENABLED
            - name: GOTRUE_MAILER_AUTOCONFIRM
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: GOTRUE_MAILER_AUTOCONFIRM
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /health
              port: 9999
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health
              port: 9999
            initialDelaySeconds: 5
            periodSeconds: 5
EOF

# Apply GoTrue
kubectl apply -f /tmp/gotrue.yaml

# Watch GoTrue start and run migrations
kubectl logs -n supabase -l app=gotrue -f
# Press Ctrl+C after you see "GoTrue API started"

Verify GoTrue is running:

kubectl get pods -n supabase -l app=gotrue
# Should show: Running

Step 4.2: Deploy PostgREST (REST API)

PostgREST provides the REST API for database access.

cat > /tmp/postgrest.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: rest
  namespace: supabase
  labels:
    app: rest
spec:
  type: ClusterIP
  ports:
    - port: 3000
      targetPort: 3000
      protocol: TCP
      name: http
  selector:
    app: rest

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: rest
  namespace: supabase
  labels:
    app: rest
spec:
  replicas: 1
  selector:
    matchLabels:
      app: rest
  template:
    metadata:
      labels:
        app: rest
    spec:
      containers:
        - name: rest
          image: postgrest/postgrest:v12.0.2
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 3000
              name: http
              protocol: TCP
          env:
            - name: PGRST_DB_URI
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: DB_URL
            - name: PGRST_DB_SCHEMAS
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: PGRST_DB_SCHEMA
            - name: PGRST_DB_ANON_ROLE
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: PGRST_DB_ANON_ROLE
            - name: PGRST_JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: JWT_SECRET
            - name: PGRST_DB_USE_LEGACY_GUCS
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: PGRST_DB_USE_LEGACY_GUCS
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            tcpSocket:
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            tcpSocket:
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
EOF

# Apply PostgREST
kubectl apply -f /tmp/postgrest.yaml

# Verify
kubectl get pods -n supabase -l app=rest

Step 4.3: Deploy Storage API

Storage API manages file uploads and storage.

cat > /tmp/storage-api.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: storage
  namespace: supabase
  labels:
    app: storage
spec:
  type: ClusterIP
  ports:
    - port: 5000
      targetPort: 5000
      protocol: TCP
      name: http
  selector:
    app: storage

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: storage
  namespace: supabase
  labels:
    app: storage
spec:
  replicas: 1
  selector:
    matchLabels:
      app: storage
  template:
    metadata:
      labels:
        app: storage
    spec:
      containers:
        - name: storage
          image: supabase/storage-api:v0.43.11
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 5000
              name: http
              protocol: TCP
          env:
            - name: ANON_KEY
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: ANON_KEY
            - name: SERVICE_KEY
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: SERVICE_ROLE_KEY
            - name: POSTGREST_URL
              value: "http://rest.supabase.svc.cluster.local:3000"
            - name: PGRST_JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: JWT_SECRET
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: DB_URL
            - name: FILE_SIZE_LIMIT
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: FILE_SIZE_LIMIT
            - name: STORAGE_BACKEND
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: STORAGE_BACKEND
            - name: FILE_STORAGE_BACKEND_PATH
              value: "/var/lib/storage"
            - name: TENANT_ID
              value: "stub"
            - name: REGION
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: STORAGE_S3_REGION
          volumeMounts:
            - name: storage-data
              mountPath: /var/lib/storage
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
      volumes:
        - name: storage-data
          emptyDir: {}
EOF

# Apply Storage
kubectl apply -f /tmp/storage-api.yaml

# Verify
kubectl get pods -n supabase -l app=storage

Step 4.4: Deploy postgres-meta (Database Admin)

postgres-meta provides database introspection for Studio.

cat > /tmp/postgres-meta.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: postgres-meta
  namespace: supabase
  labels:
    app: postgres-meta
spec:
  type: ClusterIP
  ports:
    - port: 8080
      targetPort: 8080
      protocol: TCP
      name: http
  selector:
    app: postgres-meta

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres-meta
  namespace: supabase
  labels:
    app: postgres-meta
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres-meta
  template:
    metadata:
      labels:
        app: postgres-meta
    spec:
      containers:
        - name: postgres-meta
          image: supabase/postgres-meta:v0.68.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
              name: http
              protocol: TCP
          env:
            - name: PG_META_PORT
              value: "8080"
            - name: PG_META_DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_HOST
            - name: PG_META_DB_PORT
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_PORT
            - name: PG_META_DB_NAME
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_DB
            - name: PG_META_DB_USER
              valueFrom:
                configMapKeyRef:
                  name: supabase-config
                  key: POSTGRES_USER
            - name: PG_META_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: POSTGRES_PASSWORD
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
EOF

# Apply postgres-meta
kubectl apply -f /tmp/postgres-meta.yaml

# Verify
kubectl get pods -n supabase -l app=postgres-meta

Phase 5: Deploy Kong API Gateway

Kong is the entry point for all API requests. It validates JWT tokens and routes to services.

Step 5.1: Create Kong Configuration

IMPORTANT: Kong's consumer credentials must match your JWT tokens exactly.

# Load your tokens
source ~/supabase-setup/supabase-secrets.env

# Create Kong config with your actual tokens
cat > /tmp/kong.yaml << EOF
apiVersion: v1
kind: ConfigMap
metadata:
  name: kong-config
  namespace: supabase
data:
  kong.yml: |
    _format_version: "2.1"
    _transform: true

    services:
      - name: auth-v1-open
        url: http://gotrue:9999/verify
        routes:
          - name: auth-v1-open
            strip_path: true
            paths:
              - /auth/v1/verify
        plugins:
          - name: cors

      - name: auth-v1-open-callback
        url: http://gotrue:9999/callback
        routes:
          - name: auth-v1-open-callback
            strip_path: true
            paths:
              - /auth/v1/callback
        plugins:
          - name: cors

      - name: auth-v1-open-authorize
        url: http://gotrue:9999/authorize
        routes:
          - name: auth-v1-open-authorize
            strip_path: true
            paths:
              - /auth/v1/authorize
        plugins:
          - name: cors

      - name: auth-v1
        url: http://gotrue:9999
        routes:
          - name: auth-v1-all
            strip_path: true
            paths:
              - /auth/v1/
        plugins:
          - name: cors
          - name: key-auth
            config:
              hide_credentials: false

      - name: rest-v1
        url: http://rest:3000/
        routes:
          - name: rest-v1-all
            strip_path: true
            paths:
              - /rest/v1/
        plugins:
          - name: cors
          - name: key-auth
            config:
              hide_credentials: true

      - name: graphql-v1
        url: http://rest:3000/rpc/graphql
        routes:
          - name: graphql-v1-all
            strip_path: true
            paths:
              - /graphql/v1
        plugins:
          - name: cors
          - name: key-auth
            config:
              hide_credentials: true

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

      - name: pg-meta
        url: http://postgres-meta:8080/
        routes:
          - name: pg-meta-all
            strip_path: true
            paths:
              - /pg/
        plugins:
          - name: cors
          - name: key-auth
            config:
              hide_credentials: false

    consumers:
      - username: anon
        keyauth_credentials:
          - key: $ANON_KEY
      - username: service_role
        keyauth_credentials:
          - key: $SERVICE_ROLE_KEY

    plugins:
      - name: cors
        config:
          origins:
            - "*"
          credentials: true
          exposed_headers:
            - Content-Range
          headers:
            - authorization
            - content-type
            - x-client-info
            - apikey
            - x-upsert

---
apiVersion: v1
kind: Service
metadata:
  name: kong
  namespace: supabase
  labels:
    app: kong
spec:
  type: LoadBalancer
  loadBalancerIP: $KONG_IP
  ports:
    - port: 8000
      targetPort: 8000
      protocol: TCP
      name: http
    - port: 8443
      targetPort: 8443
      protocol: TCP
      name: https
  selector:
    app: kong

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kong
  namespace: supabase
  labels:
    app: kong
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kong
  template:
    metadata:
      labels:
        app: kong
    spec:
      containers:
        - name: kong
          image: kong:2.8.1
          imagePullPolicy: IfNotPresent
          env:
            - name: KONG_DATABASE
              value: "off"
            - name: KONG_DECLARATIVE_CONFIG
              value: /usr/local/kong/declarative/kong.yml
            - name: KONG_DNS_ORDER
              value: LAST,A,CNAME
            - name: KONG_PLUGINS
              value: request-transformer,cors,key-auth,acl
            - name: KONG_NGINX_PROXY_PROXY_BUFFER_SIZE
              value: 160k
            - name: KONG_NGINX_PROXY_PROXY_BUFFERS
              value: 64 160k
          ports:
            - containerPort: 8000
              name: http
              protocol: TCP
            - containerPort: 8443
              name: https
              protocol: TCP
          volumeMounts:
            - name: kong-config
              mountPath: /usr/local/kong/declarative
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            tcpSocket:
              port: 8000
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            tcpSocket:
              port: 8000
            initialDelaySeconds: 10
            periodSeconds: 5
      volumes:
        - name: kong-config
          configMap:
            name: kong-config
EOF

# Apply Kong
kubectl apply -f /tmp/kong.yaml

# Watch Kong start
kubectl get pods -n supabase -l app=kong -w
# Press Ctrl+C when Running

Verify Kong LoadBalancer IP:

kubectl get svc -n supabase kong
# EXTERNAL-IP should show your chosen KONG_IP

Phase 6: Deploy Studio Dashboard

Studio is the web UI for managing Supabase.

# Load environment
source ~/supabase-setup/supabase-secrets.env

cat > /tmp/studio.yaml << EOF
apiVersion: v1
kind: Service
metadata:
  name: studio
  namespace: supabase
  labels:
    app: studio
spec:
  type: LoadBalancer
  loadBalancerIP: $STUDIO_IP
  ports:
    - port: 3000
      targetPort: 3000
      protocol: TCP
      name: http
  selector:
    app: studio

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: studio
  namespace: supabase
  labels:
    app: studio
spec:
  replicas: 1
  selector:
    matchLabels:
      app: studio
  template:
    metadata:
      labels:
        app: studio
    spec:
      containers:
        - name: studio
          image: supabase/studio:20240729-ce42139
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 3000
              name: http
              protocol: TCP
          env:
            - name: STUDIO_PG_META_URL
              value: "http://postgres-meta.supabase.svc.cluster.local:8080"
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: POSTGRES_PASSWORD
            - name: SUPABASE_URL
              value: "http://$KONG_IP:8000"
            - name: SUPABASE_PUBLIC_URL
              value: "http://$KONG_IP:8000"
            - name: SUPABASE_ANON_KEY
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: ANON_KEY
            - name: SUPABASE_SERVICE_KEY
              valueFrom:
                secretKeyRef:
                  name: supabase-secrets
                  key: SERVICE_ROLE_KEY
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
EOF

# Apply Studio
kubectl apply -f /tmp/studio.yaml

# Watch Studio start
kubectl get pods -n supabase -l app=studio -w
# Press Ctrl+C when Running

Verify Studio LoadBalancer IP:

kubectl get svc -n supabase studio
# EXTERNAL-IP should show your chosen STUDIO_IP

Phase 7: Verification and Testing

Step 7.1: Check All Pods Running

kubectl get pods -n supabase

Expected output (all should be Running):

NAME                             READY   STATUS    RESTARTS   AGE
postgres-0                       1/1     Running   0          15m
gotrue-xxxxxxxxx-xxxxx           1/1     Running   0          10m
rest-xxxxxxxxx-xxxxx             1/1     Running   0          8m
storage-xxxxxxxxx-xxxxx          1/1     Running   0          6m
postgres-meta-xxxxxxxxx-xxxxx    1/1     Running   0          5m
kong-xxxxxxxxx-xxxxx             1/1     Running   0          3m
studio-xxxxxxxxx-xxxxx           1/1     Running   0          2m


Step 7.2: Test Kong API Gateway

# Test health endpoint
curl -I http://$KONG_IP:8000/auth/v1/health

# Should return: HTTP/1.1 200 OK

Step 7.3: Access Studio Dashboard

Open your browser and go to:

http://<STUDIO_IP>:3000

Example: http://10.89.97.215:3000

You should see the Supabase Studio interface.


Step 7.4: Create First User (Test Authentication)

In Studio: 1. Click "Authentication" in left sidebar 2. Click "Users" tab 3. Click "Add user" button 4. Fill in: - Email: test@example.com - Password: TestPassword123! - Auto Confirm User: ✅ (checked) 5. Click "Create user"

If this works: ✅ Your Supabase installation is successful!

If you get 401 Unauthorized: See troubleshooting section below.


Phase 8: Post-Deployment Configuration

Step 8.1: Save Manifest Files

Save all your manifests for future reference:

# Create manifests directory
mkdir -p /root/tower-fleet/manifests/supabase

# Copy all generated manifests
cp /tmp/supabase-configmap.yaml /root/tower-fleet/manifests/supabase/configmap.yaml
cp /tmp/postgres.yaml /root/tower-fleet/manifests/supabase/postgres.yaml
cp /tmp/gotrue.yaml /root/tower-fleet/manifests/supabase/gotrue.yaml
cp /tmp/postgrest.yaml /root/tower-fleet/manifests/supabase/postgrest.yaml
cp /tmp/storage-api.yaml /root/tower-fleet/manifests/supabase/storage-api.yaml
cp /tmp/postgres-meta.yaml /root/tower-fleet/manifests/supabase/postgres-meta.yaml
cp /tmp/kong.yaml /root/tower-fleet/manifests/supabase/kong.yaml
cp /tmp/studio.yaml /root/tower-fleet/manifests/supabase/studio.yaml

# Create namespace manifest
cat > /root/tower-fleet/manifests/supabase/namespace.yaml << 'EOF'
apiVersion: v1
kind: Namespace
metadata:
  name: supabase
EOF

# Create secrets template (WITHOUT actual values - for reference only)
cat > /root/tower-fleet/manifests/supabase/secrets.yaml << 'EOF'
# DO NOT COMMIT THIS FILE TO GIT!
# This is a template - actual secrets are in Kubernetes

apiVersion: v1
kind: Secret
metadata:
  name: supabase-secrets
  namespace: supabase
type: Opaque
stringData:
  POSTGRES_PASSWORD: "<generated-in-step-1.1>"
  JWT_SECRET: "<generated-in-step-1.1>"
  ANON_KEY: "<generated-in-step-1.2>"
  SERVICE_ROLE_KEY: "<generated-in-step-1.2>"
  DATABASE_URL: "postgres://postgres:<password>@postgres.supabase.svc.cluster.local:5432/postgres?search_path=auth"
  DB_URL: "postgres://postgres:<password>@postgres.supabase.svc.cluster.local:5432/postgres"
EOF

echo "✅ Manifests saved to /root/tower-fleet/manifests/supabase/"

Step 8.2: Update Home Portal Configuration

See Home Portal Documentation for integrating apps with Supabase.

Quick summary:

# Get your Supabase credentials
echo "NEXT_PUBLIC_SUPABASE_URL=http://$KONG_IP:8000"
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY"

Use these values in your application's .env.local file.


Troubleshooting

Issue: Pods Stuck in Pending

Symptom: Pods show STATUS: Pending for more than 2 minutes

Cause: No storage available or no LoadBalancer IP available

Solution:

# Check if Longhorn is running
kubectl get pods -n longhorn-system

# Check if MetalLB is running
kubectl get pods -n metallb-system

# Check pod events
kubectl describe pod -n supabase <pod-name>


Issue: PostgreSQL Won't Start

Symptom: postgres-0 shows CrashLoopBackOff or Error

Check logs:

kubectl logs -n supabase postgres-0

Common causes: - Permission issues → Check initContainer logs - Corrupted data → Delete PVC and recreate

Fix (nuclear option - deletes all data):

kubectl delete pvc -n supabase postgres-data-postgres-0
kubectl delete pod -n supabase postgres-0
# Wait for pod to recreate with fresh volume


Issue: GoTrue Migration Fails

Symptom: GoTrue pod logs show migration errors

Known issue: Migration 20221208132122 may fail with UUID/text type error

Solution:

# Manually mark migration as complete
kubectl exec -n supabase postgres-0 -- psql -U postgres -c \
  "INSERT INTO auth.schema_migrations (version) VALUES ('20221208132122') ON CONFLICT DO NOTHING;"

# Restart GoTrue
kubectl rollout restart deployment -n supabase gotrue


Issue: 401 Unauthorized Creating Users in Studio

Symptom: Studio shows "Invalid authentication credentials" or 401 error

Cause: JWT tokens in Kong ConfigMap don't match supabase-secrets

Solution: See JWT Token Management

Quick check:

# Check if Kong has correct tokens
kubectl exec -n supabase -l app=kong -- cat /usr/local/kong/declarative/kong.yml | grep -A 3 "consumers:"

# Compare with secrets
kubectl get secret -n supabase supabase-secrets -o jsonpath='{.data.SERVICE_ROLE_KEY}' | base64 -d

If different: Kong needs to be updated with correct tokens (see JWT Token Management guide).


Issue: Studio Can't Connect to Services

Symptom: Studio loads but shows "Cannot connect to server" errors

Cause: Studio using internal Kubernetes DNS instead of LoadBalancer IP

Solution:

# Verify Studio has correct SUPABASE_URL
kubectl exec -n supabase -l app=studio -- env | grep SUPABASE_URL
# Should show: SUPABASE_URL=http://<KONG_IP>:8000
# NOT: SUPABASE_URL=http://kong.supabase.svc.cluster.local:8000

If wrong: Update Studio deployment with correct LoadBalancer IP and restart.


Summary

You now have a fully functional Supabase deployment! 🎉

Access Points: - API Gateway: http://<KONG_IP>:8000 - Studio Dashboard: http://<STUDIO_IP>:3000 - PostgreSQL: postgres.supabase.svc.cluster.local:5432 (internal only)

Next Steps: 1. Configure applications to use Supabase 2. Set up Row Level Security policies 3. Create database schemas and tables

Architecture Documentation: - Supabase Architecture Overview - Multi-App Setup - JWT Token Management


Additional Resources


Questions or Issues?

Check the Troubleshooting Guide or review the Deployment Lessons Learned.