Post

Deploy Supabase self-hosted with Docker, Traefik, and Authentik

Run the official self-hosted Supabase stack with Studio protected by Authentik, Kong exposed as the app API, and Postgres kept private.

Deploy Supabase self-hosted with Docker, Traefik, and Authentik

Supabase gives you a full backend platform: PostgreSQL, Auth, REST, Realtime, Storage, Edge Functions, Studio, logs, image proxying, and a connection pooler. The clean homelab pattern is to expose only the pieces that need to be reached:

  • Studio is an operator dashboard, so protect it with Authentik.
  • Kong is the public API gateway for apps, so expose it with HTTPS but secure data with keys, JWTs, RLS, and policies.
  • PostgreSQL, pooler, storage internals, Realtime internals, metadata, analytics, and Vector stay on private Docker networks or localhost binds.

All hostnames, IPs, passwords, JWT secrets, and API keys below are placeholders. Replace supabase.example.com, supabase-api.example.com, and passwords with your own values.

Never publish real Supabase anon, service_role, JWT, Postgres, SMTP, or dashboard credentials in public documentation. Keep them in .env, restrict file permissions, and rotate anything that leaks.


What this service does

Route or access pattern:

1
2
3
4
https://supabase.example.com       -> Supabase Studio dashboard, protected by Authentik
https://supabase-api.example.com   -> Supabase Kong API gateway for app clients
127.0.0.1:5432                     -> optional local Postgres/pooler access only
127.0.0.1:6543                     -> optional local pooler access only

Main components:

1
Supabase Studio, Kong, GoTrue Auth, PostgREST, Realtime, Storage API, Edge Runtime, Postgres, Supavisor, Postgres Meta, Logflare, Vector, imgproxy, Traefik, Authentik.

Network model:

1
2
3
4
5
Browser / App
  -> Traefik HTTPS
  -> Studio or Kong
  -> internal Supabase services
  -> PostgreSQL

The important part is that applications talk to the API gateway, not directly to Postgres.


Read these first if this is part of the same homelab platform:


Folder layout

Use one dedicated directory for the Supabase stack.

1
2
3
4
5
6
7
8
9
10
11
12
13
/home/ubuntu/supabase/
├── docker-compose.yml              # official Supabase compose file
├── docker-compose.override.yml     # local Traefik/Auth routing
├── .env                            # private secrets and host settings
├── volumes/
│   ├── api/
│   │   ├── kong.yml
│   │   └── kong-entrypoint.sh
│   ├── db/
│   ├── functions/
│   ├── logs/
│   └── storage/
└── backups/

Keep .env private:

1
2
3
cd /home/ubuntu/supabase
chmod 600 .env
mkdir -p backups

Get the official Supabase Docker stack

Edit files under /home/ubuntu/supabase.

1
2
3
4
5
6
mkdir -p /home/ubuntu/supabase
cd /home/ubuntu/supabase

git clone --depth 1 https://github.com/supabase/supabase.git /tmp/supabase-source
cp -a /tmp/supabase-source/docker/. /home/ubuntu/supabase/
cp .env.example .env

Then review the upstream files before editing:

1
2
ls -la
ls -la volumes/api volumes/db volumes/functions

Environment file

Edit /home/ubuntu/supabase/.env.

This is not a complete upstream .env; it shows the values you must deliberately set for a real deployment.

############
# Hostnames
############
SUPABASE_PUBLIC_URL=https://supabase-api.example.com
API_EXTERNAL_URL=https://supabase-api.example.com
SITE_URL=https://app.example.com
ADDITIONAL_REDIRECT_URLS=https://app.example.com,https://supabase.example.com

########################
# Studio default labels
########################
STUDIO_DEFAULT_ORGANIZATION=Homelab
STUDIO_DEFAULT_PROJECT=Default Project

###################
# Dashboard access
###################
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=<strong-dashboard-password>

###################
# Postgres settings
###################
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432
POSTGRES_PASSWORD=<strong-postgres-password>

###################
# Local port binds
###################
KONG_HTTP_PORT=127.0.0.1:8004
KONG_HTTPS_PORT=127.0.0.1:8444
POSTGRES_PORT=5432
POOLER_PROXY_PORT_TRANSACTION=6543

###################
# JWT / API keys
###################
JWT_SECRET=<long-random-jwt-secret>
ANON_KEY=<generated-anon-key>
SERVICE_ROLE_KEY=<generated-service-role-key>

###################
# Auth behavior
###################
DISABLE_SIGNUP=false
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=false
ENABLE_PHONE_SIGNUP=false
ENABLE_PHONE_AUTOCONFIRM=false

###################
# SMTP
###################
[email protected]
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=smtp-user
SMTP_PASS=<smtp-password>
SMTP_SENDER_NAME=Supabase

Generate real secrets locally; do not reuse the placeholders:

1
2
openssl rand -base64 48
openssl rand -hex 32

For the Supabase JWT keys, follow the official Supabase self-hosting docs or use the Supabase CLI/key generator and place the generated values in .env.


Docker Compose stack

Edit /home/ubuntu/supabase/docker-compose.yml.

The official Supabase Docker Compose file is large because Supabase is not one container. It is a coordinated platform. If you copied the upstream file from supabase/docker, keep it as your base. The example below shows a readable production-style Compose layout with the important services and wiring kept explicit.

Prefer starting from the official Supabase docker-compose.yml and then applying the local override shown later. Use this full example to understand what each service does and what must stay private.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
name: supabase

services:
  studio:
    image: supabase/studio:2026.04.27-sha-5f60601
    container_name: supabase-studio
    restart: unless-stopped
    depends_on:
      analytics:
        condition: service_healthy
    environment:
      HOSTNAME: 0.0.0.0
      STUDIO_PG_META_URL: http://meta:8080
      POSTGRES_HOST: ${POSTGRES_HOST}
      POSTGRES_PORT: ${POSTGRES_PORT}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
      DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
      SUPABASE_URL: http://kong:8000
      SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
      AUTH_JWT_SECRET: ${JWT_SECRET}
      LOGFLARE_URL: http://analytics:4000
      NEXT_PUBLIC_ENABLE_LOGS: "true"
      NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
    volumes:
      - ./volumes/snippets:/app/snippets:Z
      - ./volumes/functions:/app/edge-functions:Z
    networks:
      - default

  kong:
    image: kong/kong:3.9.1
    container_name: supabase-kong
    restart: unless-stopped
    depends_on:
      analytics:
        condition: service_healthy
    ports:
      - ${KONG_HTTP_PORT}:8000/tcp
      - ${KONG_HTTPS_PORT}:8443/tcp
    environment:
      KONG_DATABASE: "off"
      KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
      KONG_DNS_ORDER: LAST,A,CNAME
      KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
      KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
      KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
      DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
      DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
    volumes:
      - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
      - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
    entrypoint: /home/kong/kong-entrypoint.sh
    networks:
      default:
        aliases:
          - api-gw

  auth:
    image: supabase/gotrue:v2.186.0
    container_name: supabase-auth
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      analytics:
        condition: service_healthy
    environment:
      GOTRUE_API_HOST: 0.0.0.0
      GOTRUE_API_PORT: 9999
      API_EXTERNAL_URL: ${API_EXTERNAL_URL}
      GOTRUE_SITE_URL: ${SITE_URL}
      GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
      GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
      GOTRUE_DB_DRIVER: postgres
      GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
      GOTRUE_JWT_SECRET: ${JWT_SECRET}
      GOTRUE_JWT_EXP: ${JWT_EXPIRY}
      GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
      GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
      GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
      GOTRUE_SMTP_HOST: ${SMTP_HOST}
      GOTRUE_SMTP_PORT: ${SMTP_PORT}
      GOTRUE_SMTP_USER: ${SMTP_USER}
      GOTRUE_SMTP_PASS: ${SMTP_PASS}
      GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
      GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
      GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
    networks:
      - default

  rest:
    image: postgrest/postgrest:v14.8
    container_name: supabase-rest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
      PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
      PGRST_DB_ANON_ROLE: anon
      PGRST_JWT_SECRET: ${JWT_SECRET}
      PGRST_DB_USE_LEGACY_GUCS: "false"
      PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
      PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
    command: ["postgrest"]
    networks:
      - default

  realtime:
    image: supabase/realtime:v2.76.5
    container_name: realtime-dev.supabase-realtime
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      analytics:
        condition: service_healthy
    environment:
      PORT: 4000
      DB_HOST: ${POSTGRES_HOST}
      DB_PORT: ${POSTGRES_PORT}
      DB_USER: supabase_admin
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: ${POSTGRES_DB}
      DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
      DB_ENC_KEY: ${REALTIME_DB_ENC_KEY}
      API_JWT_SECRET: ${JWT_SECRET}
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      ERL_AFLAGS: -proto_dist inet_tcp
      DNS_NODES: "''"
      RLIMIT_NOFILE: "10000"
      APP_NAME: realtime
      SEED_SELF_HOST: "true"
      RUN_JANITOR: "true"
    networks:
      - default

  storage:
    image: supabase/storage-api:v1.48.26
    container_name: supabase-storage
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      rest:
        condition: service_started
      imgproxy:
        condition: service_healthy
    environment:
      ANON_KEY: ${ANON_KEY}
      SERVICE_KEY: ${SERVICE_ROLE_KEY}
      POSTGREST_URL: http://rest:3000
      PGRST_JWT_SECRET: ${JWT_SECRET}
      DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
      FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT}
      STORAGE_BACKEND: file
      FILE_STORAGE_BACKEND_PATH: /var/lib/storage
      TENANT_ID: stub
      REGION: stub
      GLOBAL_S3_BUCKET: stub
      ENABLE_IMAGE_TRANSFORMATION: "true"
      IMGPROXY_URL: http://imgproxy:5001
    volumes:
      - ./volumes/storage:/var/lib/storage:z
    networks:
      - default

  imgproxy:
    image: darthsim/imgproxy:v3.30.1
    container_name: supabase-imgproxy
    restart: unless-stopped
    environment:
      IMGPROXY_BIND: :5001
      IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
      IMGPROXY_USE_ETAG: "true"
      IMGPROXY_ENABLE_WEBP_DETECTION: "true"
    volumes:
      - ./volumes/storage:/var/lib/storage:z
    networks:
      - default

  meta:
    image: supabase/postgres-meta:v0.96.3
    container_name: supabase-meta
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      PG_META_PORT: 8080
      PG_META_DB_HOST: ${POSTGRES_HOST}
      PG_META_DB_PORT: ${POSTGRES_PORT}
      PG_META_DB_NAME: ${POSTGRES_DB}
      PG_META_DB_USER: supabase_admin
      PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
    networks:
      - default

  functions:
    image: supabase/edge-runtime:v1.71.2
    container_name: supabase-edge-functions
    restart: unless-stopped
    depends_on:
      analytics:
        condition: service_healthy
    environment:
      JWT_SECRET: ${JWT_SECRET}
      SUPABASE_URL: http://kong:8000
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
      SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
      VERIFY_JWT: "false"
    volumes:
      - ./volumes/functions:/home/deno/functions:Z
    command:
      - start
      - --main-service
      - /home/deno/functions/main
    networks:
      - default

  analytics:
    image: supabase/logflare:1.36.1
    container_name: supabase-analytics
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      LOGFLARE_NODE_HOST: 127.0.0.1
      DB_USERNAME: supabase_admin
      DB_DATABASE: ${POSTGRES_DB}
      DB_HOSTNAME: ${POSTGRES_HOST}
      DB_PORT: ${POSTGRES_PORT}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_SCHEMA: _analytics
      LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
      LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
      LOGFLARE_SINGLE_TENANT: "true"
      LOGFLARE_SUPABASE_MODE: "true"
      POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
      POSTGRES_BACKEND_SCHEMA: _analytics
      LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
    networks:
      - default

  vector:
    image: timberio/vector:0.53.0-alpine
    container_name: supabase-vector
    restart: unless-stopped
    volumes:
      - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
      - ${DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock:ro,z
    command: ["--config", "/etc/vector/vector.yml"]
    networks:
      - default

  db:
    image: supabase/postgres:15.8.1.085
    container_name: supabase-db
    restart: unless-stopped
    volumes:
      - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
      - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
      - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
      - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
      - ./volumes/db/data:/var/lib/postgresql/data:Z
    environment:
      POSTGRES_HOST: /var/run/postgresql
      PGPORT: ${POSTGRES_PORT}
      POSTGRES_PORT: ${POSTGRES_PORT}
      PGPASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATABASE: ${POSTGRES_DB}
      POSTGRES_DB: ${POSTGRES_DB}
      JWT_SECRET: ${JWT_SECRET}
      JWT_EXP: ${JWT_EXPIRY}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -h localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks:
      - default

  supavisor:
    image: supabase/supavisor:2.7.4
    container_name: supabase-pooler
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      analytics:
        condition: service_healthy
    ports:
      - 127.0.0.1:5432:5432
      - 127.0.0.1:6543:6543
    environment:
      PORT: 4000
      POSTGRES_PORT: ${POSTGRES_PORT}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
      CLUSTER_POSTGRES: "true"
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      VAULT_ENC_KEY: ${VAULT_ENC_KEY}
      API_JWT_SECRET: ${JWT_SECRET}
      METRICS_JWT_SECRET: ${JWT_SECRET}
      REGION: local
      ERL_AFLAGS: -proto_dist inet_tcp
      POOLER_TENANT_ID: ${POOLER_TENANT_ID}
      POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
      POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
      POOLER_POOL_MODE: transaction
    networks:
      - default

networks:
  default:
    name: supabase_default

The upstream file may contain more environment variables than this readable example. Keep upstream additions when upgrading.


Kong declarative config

Edit /home/ubuntu/supabase/volumes/api/kong.yml.

Kong maps public API paths to internal Supabase services. The upstream file includes all routes, but the core idea looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
_format_version: "2.1"
_transform: true

consumers:
  - username: anon
    keyauth_credentials:
      - key: ${SUPABASE_ANON_KEY}
  - username: service_role
    keyauth_credentials:
      - key: ${SUPABASE_SERVICE_KEY}

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

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

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

  - name: realtime-v1
    url: http://realtime:4000/socket/
    routes:
      - name: realtime-v1-all
        strip_path: true
        paths:
          - /realtime/v1/
    plugins:
      - name: cors
      - name: key-auth

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

  - name: functions-v1
    url: http://functions:9000/
    routes:
      - name: functions-v1-all
        strip_path: true
        paths:
          - /functions/v1/
    plugins:
      - name: cors

Do not hand-roll Kong unless you need to. Use the upstream volumes/api/kong.yml as the source of truth, then verify it has the routes your SDK expects.


Compose override for Traefik and Authentik

Edit /home/ubuntu/supabase/docker-compose.override.yml.

This override leaves the official compose mostly untouched and adds two public routes:

  • Studio route: protected by Authentik.
  • Kong API route: public app API route, not behind Authentik.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
services:
  studio:
    networks:
      - default
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      - "traefik.http.middlewares.supabase-studio-https.redirectscheme.scheme=https"
      - "traefik.http.routers.supabase-studio.entrypoints=http"
      - "traefik.http.routers.supabase-studio.rule=Host(`supabase.example.com`)"
      - "traefik.http.routers.supabase-studio.middlewares=supabase-studio-https"

      - "traefik.http.routers.supabase-studio-secure.entrypoints=https"
      - "traefik.http.routers.supabase-studio-secure.rule=Host(`supabase.example.com`)"
      - "traefik.http.routers.supabase-studio-secure.tls=true"
      - "traefik.http.routers.supabase-studio-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.supabase-studio-secure.middlewares=authentik@docker"
      - "traefik.http.routers.supabase-studio-secure.service=supabase-studio"
      - "traefik.http.services.supabase-studio.loadbalancer.server.port=3000"

  kong:
    networks:
      - default
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      - "traefik.http.middlewares.supabase-api-https.redirectscheme.scheme=https"
      - "traefik.http.routers.supabase-api.entrypoints=http"
      - "traefik.http.routers.supabase-api.rule=Host(`supabase-api.example.com`)"
      - "traefik.http.routers.supabase-api.middlewares=supabase-api-https"

      - "traefik.http.routers.supabase-api-secure.entrypoints=https"
      - "traefik.http.routers.supabase-api-secure.rule=Host(`supabase-api.example.com`)"
      - "traefik.http.routers.supabase-api-secure.tls=true"
      - "traefik.http.routers.supabase-api-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.supabase-api-secure.service=supabase-api"
      - "traefik.http.services.supabase-api.loadbalancer.server.port=8000"

networks:
  proxy:
    external: true

Do not put authentik@docker on the Kong API route unless every client is a browser user. Mobile apps, server apps, and frontend SDKs need direct API access.


Authentik outpost route for Studio

If you use embedded outpost routing in the Authentik compose file, edit /home/ubuntu/authentik/docker-compose.yml and add an outpost route for the Studio hostname.

Add this under the authentik-server Traefik labels:

1
2
3
4
5
6
      - "traefik.http.routers.authentik-supabase-outpost.entrypoints=https"
      - "traefik.http.routers.authentik-supabase-outpost.rule=Host(`supabase.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)"
      - "traefik.http.routers.authentik-supabase-outpost.priority=100"
      - "traefik.http.routers.authentik-supabase-outpost.tls=true"
      - "traefik.http.routers.authentik-supabase-outpost.tls.certresolver=cloudflare"
      - "traefik.http.routers.authentik-supabase-outpost.service=authentik"

Then restart Authentik:

1
2
cd /home/ubuntu/authentik
docker compose up -d

Start the stack

Run from /home/ubuntu/supabase:

1
2
3
4
5
6
7
8
9
10
11
cd /home/ubuntu/supabase

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.yml \
  pull

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.yml \
  up -d

Check the containers:

1
2
3
4
docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.yml \
  ps

Expected services include:

1
2
3
4
5
6
7
8
9
10
11
12
13
supabase-studio
supabase-kong
supabase-auth
supabase-rest
supabase-realtime
supabase-storage
supabase-db
supabase-pooler
supabase-meta
supabase-edge-functions
supabase-analytics
supabase-vector
supabase-imgproxy

Verify Studio

From a browser, open:

1
https://supabase.example.com

Expected result:

1
Authentik login -> Supabase Studio dashboard

From the server, check the container health:

1
docker inspect supabase-studio --format '' | jq

If Studio loads but cannot query the database, check:

1
2
3
docker logs --tail=200 supabase-studio
docker logs --tail=200 supabase-meta
docker logs --tail=200 supabase-db

Verify the API gateway

Check Kong from the server:

1
curl -i http://127.0.0.1:8004/auth/v1/health

Check it through Traefik:

1
curl -i https://supabase-api.example.com/auth/v1/health

You should see a healthy HTTP response from GoTrue/Auth. If the route returns 404, inspect Kong and Traefik labels. If it returns an Authentik login page, you accidentally put Authentik middleware on the app API route.


Create an example application client

In your frontend application, configure the Supabase SDK with the API hostname, not the Studio hostname.

Edit your app .env file, for example /home/ubuntu/my-app/.env:

VITE_SUPABASE_URL=https://supabase-api.example.com
VITE_SUPABASE_ANON_KEY=<anon-key-from-supabase-env>

Example JavaScript client:

1
2
3
4
5
6
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY,
)

Never put the service_role key in a browser application.


Row-level security baseline

Supabase is only safe when RLS policies match your application model.

Example table migration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text,
  avatar_url text,
  created_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

create policy "profiles are readable by authenticated users"
  on public.profiles
  for select
  to authenticated
  using (true);

create policy "users can update their own profile"
  on public.profiles
  for update
  to authenticated
  using (auth.uid() = id)
  with check (auth.uid() = id);

Run migrations from Studio SQL editor or from a controlled migration tool. Back up first if the database has real data.


Backups

Create /home/ubuntu/supabase/backup.sh:

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env bash
set -euo pipefail

cd /home/ubuntu/supabase
mkdir -p backups
STAMP="$(date +%Y%m%d-%H%M%S)"

docker exec supabase-db pg_dumpall -U postgres   | gzip > "backups/supabase-${STAMP}.sql.gz"

find backups -type f -name 'supabase-*.sql.gz' -mtime +14 -delete

Make it executable:

1
chmod +x /home/ubuntu/supabase/backup.sh

Add a cron job:

15 3 * * * /home/ubuntu/supabase/backup.sh >> /home/ubuntu/supabase/backups/backup.log 2>&1

Updating Supabase

Before updating:

1
2
cd /home/ubuntu/supabase
./backup.sh

Then update carefully:

1
2
3
4
5
6
7
8
9
10
11
12
git -C /tmp/supabase-source pull --ff-only
# Review upstream changes before replacing local compose files.

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.yml \
  pull

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.yml \
  up -d

Do not blindly overwrite .env, volumes/api/kong.yml, or local overrides without reviewing changes.


Troubleshooting

Studio redirects but API does not work

Check that the app uses:

1
https://supabase-api.example.com

not:

1
https://supabase.example.com

Studio and API are different routes.

API is behind Authentik by mistake

Inspect labels:

1
docker inspect supabase-kong --format '{json .Config.Labels}' | jq

The Kong route should not include:

1
authentik@docker

Postgres is exposed publicly

Check published ports:

1
docker ps --format 'table {.Names}	{.Ports}' | grep supabase

Postgres and pooler should be localhost-only or internal-only, not 0.0.0.0.

DNS record is missing

If you use Cloudflare Companion, restart it after adding new Traefik hosts:

1
2
cd /home/ubuntu/companion
docker compose restart

Security checklist

  • Studio protected by Authentik.
  • API route public only through Kong.
  • Postgres and pooler not exposed on public interfaces.
  • Strong JWT secret and generated API keys.
  • SMTP configured for Auth emails.
  • RLS enabled on application tables.
  • service_role key never shipped to browsers.
  • Backups scheduled and restore-tested.
  • .env permissions restricted.
This post is licensed under CC BY 4.0 by the author.