Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions blueprints/supabase/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ services:
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_ROUTER_FLAVOR: expressions
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_DNS_VALID_TTL: 5
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
Expand Down Expand Up @@ -175,7 +177,8 @@ services:
PGRST_DB_ANON_ROLE: anon
PGRST_ADMIN_SERVER_PORT: 3001
PGRST_ADMIN_SERVER_HOST: localhost
PGRST_JWT_SECRET: ${JWT_SECRET}
# Accepts a plain-text symmetric secret, a single JWK, or a JWKS.
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
Expand Down Expand Up @@ -208,8 +211,11 @@ services:
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
DB_ENC_KEY: ${REALTIME_DB_ENC_KEY:-supabaserealtime}
# Legacy symmetric HS256 key
API_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric)
API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
METRICS_JWT_SECRET: ${JWT_SECRET}
ERL_AFLAGS: -proto_dist inet_tcp
Expand Down Expand Up @@ -250,7 +256,10 @@ services:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
# Legacy symmetric HS256 key
AUTH_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric)
JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
Expand Down Expand Up @@ -313,7 +322,10 @@ services:
kong:
condition: service_healthy
environment:
# Legacy symmetric HS256 key
JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric)
SUPABASE_JWKS: ${JWT_JWKS:-{"keys":[]}}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
Expand Down
46 changes: 46 additions & 0 deletions blueprints/supabase/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,52 @@ To connect an application (for example with `supabase-js`):
- **anon key**: the value of `ANON_KEY` in the Environment tab
- **service_role key**: the value of `SERVICE_ROLE_KEY` in the Environment tab (server-side only, never expose it to browsers)

### New API keys (`sb_publishable_…` / `sb_secret_…`)

Dokploy also generates the newer opaque API keys, so you can use either style:

- **publishable key**: the value of `SUPABASE_PUBLISHABLE_KEY` (browser-safe, replaces the anon key)
- **secret key**: the value of `SUPABASE_SECRET_KEY` (server-side only, replaces the service_role key)

Kong exchanges these for the matching JWT before the request reaches Supabase,
so clients never hold a decodable token. Both styles stay valid at the same time
— existing apps on `ANON_KEY` / `SERVICE_ROLE_KEY` keep working.

## Optional: sign tokens with an ES256 key pair

Everything is signed with the symmetric `JWT_SECRET` (HS256) by default. Moving
to an asymmetric key pair needs an EC P-256 key, which Dokploy's variable
helpers cannot generate, so `JWT_KEYS` and `JWT_JWKS` ship empty. To switch:

1. Clone the Supabase repo and go to its `docker/` directory:

```bash
git clone --depth 1 https://github.com/supabase/supabase
cd supabase/docker
```

2. Put **this deployment's** `JWT_SECRET` (from the Environment tab) into a local `.env`:

```bash
echo "JWT_SECRET=<your-JWT_SECRET>" > .env
```

3. Generate the keys:

```bash
sh utils/add-new-auth-keys.sh
```

4. Replace all six values in the Environment tab with the ones it prints, then
redeploy: `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`,
`ANON_KEY_ASYMMETRIC`, `SERVICE_ROLE_KEY_ASYMMETRIC`, `JWT_KEYS`, `JWT_JWKS`.

Set them **all together**. `JWT_KEYS` makes Auth sign tokens with ES256, while
`JWT_JWKS` is what PostgREST, Realtime, Storage and Edge Functions use to verify
them — filling in one without the other makes every authenticated request fail.

See <https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys>.

## Recommended configuration

Review these variables in the **Environment** tab before using Supabase in production:
Expand Down
2 changes: 1 addition & 1 deletion blueprints/supabase/meta.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "supabase",
"name": "SupaBase",
"version": "2026.08.03 / dokploy >= 0.22.5",
"version": "2026.08.03-2 / dokploy >= 0.22.5",
"description": "The open source Firebase alternative. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications. This require at least version 0.22.5 of dokploy.",
"links": {
"github": "https://github.com/supabase/supabase",
Expand Down
140 changes: 124 additions & 16 deletions blueprints/supabase/template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ s3_protocol_access_key_id = "${password:24}"
s3_protocol_access_key_secret = "${password:48}"
secret_key_base = "${password:64}"
vault_enc_key = "${password:32}"
# Realtime requires this to be exactly 16 characters (dokploy's hash helper
# emits one hex character per unit of length).
realtime_db_enc_key = "${hash:16}"
jwt_secret = "${password:32}"
pooler_tenant_id = "${uuid}"
anon_key_payload = """{
Expand All @@ -21,6 +24,13 @@ service_role_key_payload = """{
"exp": ${timestamps:2030-01-01T00:00:00Z}
}
"""
# Defined here (not inline in env) so each JWT is generated once and can be
# reused: Kong swaps the opaque API keys below for these exact tokens.
anon_key = "${jwt:jwt_secret:anon_key_payload}"
service_role_key = "${jwt:jwt_secret:service_role_key_payload}"
# Opaque API keys, shaped like Supabase's: sb_<role>_<22 chars>_<8 char suffix>.
publishable_key = "sb_publishable_${hash:22}_${hash:8}"
secret_key = "sb_secret_${hash:22}_${hash:8}"

[[config.domains]]
serviceName = "kong"
Expand Down Expand Up @@ -49,13 +59,48 @@ env = [
'',
'SUPABASE_HOST=${main_domain}',
'POSTGRES_PASSWORD=${postgres_password}',
'',
'# Symmetric HS256 key and the legacy API keys derived from it.',
'JWT_SECRET=${jwt_secret}',
'ANON_KEY=${jwt:jwt_secret:anon_key_payload}',
'SERVICE_ROLE_KEY=${jwt:jwt_secret:service_role_key_payload}',
'ANON_KEY=${anon_key}',
'SERVICE_ROLE_KEY=${service_role_key}',
'',
'############',
'# New API keys. These are opaque strings: clients never see a decodable JWT,',
'# Kong swaps them for the *_ASYMMETRIC tokens below before proxying.',
'#',
'# Those tokens are HS256, signed with JWT_SECRET, so they verify against the',
'# same key everything else already uses. Real ES256 keys would need an EC P-256',
'# keypair, which the dokploy variable helpers cannot generate - see JWT_KEYS /',
'# JWT_JWKS below if you want to switch to them.',
'############',
'SUPABASE_PUBLISHABLE_KEY=${publishable_key}',
'SUPABASE_SECRET_KEY=${secret_key}',
'ANON_KEY_ASYMMETRIC=${anon_key}',
'SERVICE_ROLE_KEY_ASYMMETRIC=${service_role_key}',
'',
'############',
'# Optional: move signing to an ES256 key pair. Leave both empty to stay on',
'# HS256 (the default, and what the keys above are signed with).',
'#',
'# To switch, clone https://github.com/supabase/supabase, put this deployment',
'# JWT_SECRET in docker/.env, run `sh utils/add-new-auth-keys.sh`, then replace',
'# all six values above and below with the ones it prints. Set them together:',
'# JWT_KEYS makes Auth sign with ES256, JWT_JWKS is how everything else',
'# verifies those tokens, so filling in one without the other breaks auth.',
'# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys',
'############',
'# JSON array of signing JWKs (EC private + legacy symmetric), used by Auth.',
'JWT_KEYS=[]',
'# JWKS for token verification (EC public + legacy symmetric), used by',
'# PostgREST, Realtime, Storage and Edge Functions.',
'JWT_JWKS=',
'',
'DASHBOARD_USERNAME=supabase',
'DASHBOARD_PASSWORD=${dashboard_password}',
'SECRET_KEY_BASE=${secret_key_base}',
'VAULT_ENC_KEY=${vault_enc_key}',
'REALTIME_DB_ENC_KEY=${realtime_db_enc_key}',
'PG_META_CRYPTO_KEY=${pg_meta_crypto_key}',
'',
'',
Expand Down Expand Up @@ -107,7 +152,6 @@ env = [
'JWT_EXPIRY=3600',
'DISABLE_SIGNUP=false',
'API_EXTERNAL_URL=https://${main_domain}',
'JWT_KEYS=[]',
'',
'## Mailer Config',
'MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"',
Expand Down Expand Up @@ -321,7 +365,34 @@ services:
- admin
- anon

## Secure REST routes
## OpenAPI root - admin only
- name: rest-v1-openapi
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> http://rest:3000/ (admin only). See https://github.com/orgs/supabase/discussions/42949'
url: http://rest:3000/
routes:
- name: rest-v1-openapi-root
strip_path: true
expression: 'http.path == "/rest/v1/"'
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin

## Secure PostgREST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
Expand Down Expand Up @@ -363,7 +434,7 @@ services:
- name: cors
- name: key-auth
config:
hide_credentials: true
hide_credentials: false
- name: request-transformer
config:
add:
Expand Down Expand Up @@ -409,6 +480,39 @@ services:
allow:
- admin
- anon

# Block access to /realtime/v1/api/openapi
- name: realtime-v1-rest-openapi
_comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)'
url: http://realtime:4000/api/openapi
protocol: http
routes:
- name: realtime-v1-rest-openapi
strip_path: true
paths:
- /realtime/v1/api/openapi
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."

# Block access to /realtime/v1/api/tenants
- name: realtime-v1-rest-tenants
_comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)'
url: http://realtime:4000/api/tenants
protocol: http
routes:
- name: realtime-v1-rest-tenants
strip_path: true
paths:
- /realtime/v1/api/tenants
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."

- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
url: http://realtime:4000/api
Expand Down Expand Up @@ -856,22 +960,25 @@ serve(async () => {

[[config.mounts]]
filePath = "/volumes/functions/main/index.ts"
content = """import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
content = """import * as jose from 'jsr:@panva/jose@6'

console.log('main function started')

const JWT_SECRET = Deno.env.get('JWT_SECRET')
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
const SUPABASE_JWKS = parseJwks(Deno.env.get('SUPABASE_JWKS'))
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'

let SUPABASE_JWT_KEYS: ReturnType<typeof jose.createRemoteJWKSet> | null = null
if (SUPABASE_URL) {
// Only the bare array parsing is checked here, 'jose' does the key validation.
export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null {
if (!raw) return null
try {
SUPABASE_JWT_KEYS = jose.createRemoteJWKSet(
new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL)
)
} catch (e) {
console.error('Failed to fetch JWKS from SUPABASE_URL:', e)
const parsed = JSON.parse(raw)
if (parsed?.keys && Array.isArray(parsed.keys)) {
return parsed as jose.JSONWebKeySet
}
return null
} catch {
return null
}
}

Expand Down Expand Up @@ -904,12 +1011,13 @@ async function isValidLegacyJWT(jwt: string): Promise<boolean> {
}

async function isValidJWT(jwt: string): Promise<boolean> {
if (!SUPABASE_JWT_KEYS) {
if (!SUPABASE_JWKS) {
console.error('JWKS not available for ES256/RS256 token verification')
return false
}
try {
await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS)
const localJwks = jose.createLocalJWKSet(SUPABASE_JWKS)
await jose.jwtVerify(jwt, localJwks)
} catch (e) {
console.error('Asymmetric JWT verification error', e)
return false
Expand Down
Loading