diff --git a/blueprints/supabase/docker-compose.yml b/blueprints/supabase/docker-compose.yml index 8a193e6ca..02af67da7 100644 --- a/blueprints/supabase/docker-compose.yml +++ b/blueprints/supabase/docker-compose.yml @@ -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 @@ -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} @@ -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 @@ -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" @@ -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} diff --git a/blueprints/supabase/instructions.md b/blueprints/supabase/instructions.md index af95f2136..bcae4fd35 100644 --- a/blueprints/supabase/instructions.md +++ b/blueprints/supabase/instructions.md @@ -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=" > .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 . + ## Recommended configuration Review these variables in the **Environment** tab before using Supabase in production: diff --git a/blueprints/supabase/meta.json b/blueprints/supabase/meta.json index d604c052e..85a41d433 100644 --- a/blueprints/supabase/meta.json +++ b/blueprints/supabase/meta.json @@ -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", diff --git a/blueprints/supabase/template.toml b/blueprints/supabase/template.toml index bf64558a6..ea4d243fe 100644 --- a/blueprints/supabase/template.toml +++ b/blueprints/supabase/template.toml @@ -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 = """{ @@ -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__<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" @@ -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}', '', '', @@ -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"', @@ -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/ @@ -363,7 +434,7 @@ services: - name: cors - name: key-auth config: - hide_credentials: true + hide_credentials: false - name: request-transformer config: add: @@ -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 @@ -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 | 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 } } @@ -904,12 +1011,13 @@ async function isValidLegacyJWT(jwt: string): Promise { } async function isValidJWT(jwt: string): Promise { - 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