From cb7c512c572e24dd49850b76194d3879f1a0839a Mon Sep 17 00:00:00 2001 From: Elzo codeur Date: Thu, 11 Jun 2026 21:28:31 +0000 Subject: [PATCH] first commit --- .env.example | 366 +++++ .env.old | 366 +++++ .gitattributes | 17 + .gitignore | 5 + CHANGELOG.md | 538 +++++++ CONFIG.md | 1454 +++++++++++++++++++ README.md | 95 ++ dev/data.sql | 48 + dev/docker-compose.dev.yml | 44 + docker-compose.caddy.yml | 53 + docker-compose.envoy.yml | 51 + docker-compose.logs.yml | 99 ++ docker-compose.nginx.yml | 52 + docker-compose.pg17.yml | 15 + docker-compose.rustfs.yml | 47 + docker-compose.s3.yml | 46 + docker-compose.yml | 574 ++++++++ docker-compose.yml.old | 572 ++++++++ reset.sh | 76 + run.sh | 297 ++++ setup.sh | 341 +++++ tests/docker-compose.rustfs.test.yml | 11 + tests/docker-compose.s3.test.yml | 13 + tests/test-auth-keys.sh | 369 +++++ tests/test-container-logs.sh | 165 +++ tests/test-pg17-upgrade.sh | 239 +++ tests/test-s3-backend.sh | 358 +++++ tests/test-s3.sh | 292 ++++ tests/test-self-hosted.sh | 351 +++++ utils/add-new-auth-keys.sh | 223 +++ utils/db-passwd.sh | 157 ++ utils/generate-keys.sh | 135 ++ utils/reassign-owner.sh | 153 ++ utils/rotate-new-api-keys.sh | 117 ++ utils/upgrade-pg17.sh | 754 ++++++++++ versions.md | 135 ++ volumes/api/envoy/cds.yaml | 223 +++ volumes/api/envoy/docker-entrypoint.sh | 34 + volumes/api/envoy/envoy.yaml | 27 + volumes/api/envoy/lds.template.yaml | 994 +++++++++++++ volumes/api/kong-entrypoint.sh | 49 + volumes/api/kong.yml | 408 ++++++ volumes/db/_supabase.sql | 3 + volumes/db/init/data.sql | 0 volumes/db/jwt.sql | 5 + volumes/db/logs.sql | 6 + volumes/db/pooler.sql | 6 + volumes/db/realtime.sql | 4 + volumes/db/roles.sql | 8 + volumes/db/webhooks.sql | 208 +++ volumes/functions/hello/index.ts | 14 + volumes/functions/main/index.ts | 168 +++ volumes/logs/vector.yml | 268 ++++ volumes/pooler/pooler.exs | 30 + volumes/proxy/caddy/Caddyfile | 18 + volumes/proxy/nginx/supabase-nginx.conf.tpl | 95 ++ volumes/snippets/.gitkeep | 0 volumes/snippets/Untitled query 224.sql | 16 + 58 files changed, 11212 insertions(+) create mode 100644 .env.example create mode 100644 .env.old create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONFIG.md create mode 100644 README.md create mode 100644 dev/data.sql create mode 100644 dev/docker-compose.dev.yml create mode 100644 docker-compose.caddy.yml create mode 100644 docker-compose.envoy.yml create mode 100644 docker-compose.logs.yml create mode 100644 docker-compose.nginx.yml create mode 100644 docker-compose.pg17.yml create mode 100644 docker-compose.rustfs.yml create mode 100644 docker-compose.s3.yml create mode 100644 docker-compose.yml create mode 100644 docker-compose.yml.old create mode 100755 reset.sh create mode 100755 run.sh create mode 100755 setup.sh create mode 100644 tests/docker-compose.rustfs.test.yml create mode 100644 tests/docker-compose.s3.test.yml create mode 100644 tests/test-auth-keys.sh create mode 100644 tests/test-container-logs.sh create mode 100644 tests/test-pg17-upgrade.sh create mode 100644 tests/test-s3-backend.sh create mode 100644 tests/test-s3.sh create mode 100644 tests/test-self-hosted.sh create mode 100644 utils/add-new-auth-keys.sh create mode 100644 utils/db-passwd.sh create mode 100644 utils/generate-keys.sh create mode 100644 utils/reassign-owner.sh create mode 100644 utils/rotate-new-api-keys.sh create mode 100644 utils/upgrade-pg17.sh create mode 100644 versions.md create mode 100644 volumes/api/envoy/cds.yaml create mode 100755 volumes/api/envoy/docker-entrypoint.sh create mode 100644 volumes/api/envoy/envoy.yaml create mode 100644 volumes/api/envoy/lds.template.yaml create mode 100755 volumes/api/kong-entrypoint.sh create mode 100644 volumes/api/kong.yml create mode 100644 volumes/db/_supabase.sql create mode 100755 volumes/db/init/data.sql create mode 100644 volumes/db/jwt.sql create mode 100644 volumes/db/logs.sql create mode 100644 volumes/db/pooler.sql create mode 100644 volumes/db/realtime.sql create mode 100644 volumes/db/roles.sql create mode 100644 volumes/db/webhooks.sql create mode 100644 volumes/functions/hello/index.ts create mode 100644 volumes/functions/main/index.ts create mode 100644 volumes/logs/vector.yml create mode 100644 volumes/pooler/pooler.exs create mode 100644 volumes/proxy/caddy/Caddyfile create mode 100644 volumes/proxy/nginx/supabase-nginx.conf.tpl create mode 100644 volumes/snippets/.gitkeep create mode 100644 volumes/snippets/Untitled query 224.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..417e384 --- /dev/null +++ b/.env.example @@ -0,0 +1,366 @@ +############ +# Docker compose override files to layer on top of docker-compose.yml. +# Native docker compose COMPOSE_FILE: colon-separated list, base file first. +# Manage with: ./run.sh config add|remove +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +############ +COMPOSE_FILE=docker-compose.yml + + +############ +# Secrets +# +# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING +# THE CONTAINERS FOR THE FIRST TIME! +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase +# +# To generate secrets and API keys: +# 1. sh utils/generate-keys.sh +# 2. sh utils/add-new-auth-keys.sh +# +############ + +# Postgres +POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password + +# Legacy symmetric HS256 key +JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +# Legacy API keys (HS256-signed JWTs) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + +# Asymmetric key pair (ES256) and opaque API keys +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys +# +# To generate: +# sh ./utils/add-new-auth-keys.sh +# +# Opaque API key for client-side use (anon role). +SUPABASE_PUBLISHABLE_KEY= +# Opaque API key for server-side use (service_role). Never expose in client code. +SUPABASE_SECRET_KEY= +# 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 to verify tokens. +JWT_JWKS= + +# Access to Dashboard +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated + +# Used by Realtime and Supavisor +SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + +# Used by Supavisor +VAULT_ENC_KEY=your-32-character-encryption-key + +# Used by Studio to access Postgres via postgres-meta +PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min + +# Analytics - API tokens for log ingestion/querying, and for management +# If Logflare has to be externally exposed - configure securely! +# Used in the docker-compose.logs.yml override. +LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public +LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private + +# Access to Storage via S3 protocol endpoint (see below) +S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c +S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 + + +############ +# URLs - Configure hostnames below to reflect your actual domain name +############ + +# Access to Dashboard and REST API +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Full external URL of the Auth service, used to construct OAuth callbacks, +# SAML endpoints, and email links +API_EXTERNAL_URL=http://localhost:8000 + +# See also the Auth section below for Site URL and Redirect URLs configuration + + +############ +# Database - Postgres configuration +############ + +# Using default user (postgres) +POSTGRES_HOST=db +POSTGRES_DB=postgres + +# Default configuration includes Supavisor exposing POSTGRES_PORT +# Postgres uses POSTGRES_PORT inside the container +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres-through-supavisor +POSTGRES_PORT=5432 + + +############ +# Supavisor - Database pooler +############ + +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres +POOLER_TENANT_ID=your-tenant-id + +# Pool size for internal metadata storage used by Supavisor +# This is separate from client connections and used only by Supavisor itself +POOLER_DB_POOL_SIZE=5 + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +# Add your OpenAI API key to enable AI Assistant +OPENAI_API_KEY=sk-proj-xxxxxxxx + + +############ +# Auth - Configuration for the authentication server +############ + +## General settings + +# Equivalent to "Site URL" and "Redirect URLs" platform configuration options +# Documentation: https://supabase.com/docs/guides/auth/redirect-urls +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= + +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + +## OAuth / Social login providers + +# Uncomment and fill in the providers you want to enable. +# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth +# GOOGLE_ENABLED=false +# GOOGLE_CLIENT_ID= +# GOOGLE_SECRET= + +# GITHUB_ENABLED=false +# GITHUB_CLIENT_ID= +# GITHUB_SECRET= + +# AZURE_ENABLED=false +# AZURE_CLIENT_ID= +# AZURE_SECRET= + +# Phone / SMS provider configuration +# Uncomment to configure SMS delivery for phone auth and phone MFA. +# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa +# SMS_PROVIDER=twilio +# SMS_OTP_EXP=60 +# SMS_OTP_LENGTH=6 +# SMS_MAX_FREQUENCY=60s +# SMS_TEMPLATE=Your code is {{ .Code }} + +# SMS_TWILIO_ACCOUNT_SID= +# SMS_TWILIO_AUTH_TOKEN= +# SMS_TWILIO_MESSAGE_SERVICE_SID= + +# Test OTP: map phone numbers to fixed OTP codes for development +# Format: phone1:code1,phone2:code2 +# SMS_TEST_OTP= + +# Multi-factor authentication (MFA) +# Uncomment to change MFA defaults. +# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml + +# App Authenticator (TOTP) - enabled by default +# MFA_TOTP_ENROLL_ENABLED=true +# MFA_TOTP_VERIFY_ENABLED=true + +# Phone MFA - disabled by default (opt-in) +# MFA_PHONE_ENROLL_ENABLED=false +# MFA_PHONE_VERIFY_ENABLED=false + +# Maximum MFA factors a user can enroll +# MFA_MAX_ENROLLED_FACTORS=10 + +## SAML SSO + +# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso + +# SAML_ENABLED=true +# SAML_PRIVATE_KEY= + +# Optional: accept encrypted SAML assertions from IdPs (default: false) +# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false + +# Optional: how long relay state tokens remain valid (default: 2m0s) +# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s + +# Optional: override the SAML entity ID / ACS base URL +# Defaults to API_EXTERNAL_URL if not set +# SAML_EXTERNAL_URL=https://supabase.example.com:8000 + +# Optional: rate limit on the ACS endpoint (requests per second, default: 15) +# SAML_RATE_LIMIT_ASSERTION=15 + + +############ +# Storage - Configuration for Storage +############ + +# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and +# refer to the documentation at: +# https://supabase.com/docs/guides/self-hosting/self-hosted-s3 +# to learn how to configure the S3 protocol endpoint + +# S3 bucket when using S3 backend, directory name when using 'file' +GLOBAL_S3_BUCKET=stub + +# Used for S3 protocol endpoint configuration +REGION=stub + +# Used by MinIO when added via: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=secret1234 + +# Equivalent to project_ref as described here: +# https://supabase.com/docs/guides/storage/s3/authentication#session-token +STORAGE_TENANT_ID=stub + + +############ +# Functions - Configuration for Edge functions +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-functions + +# NOTE: VERIFY_JWT applies to all functions +FUNCTIONS_VERIFY_JWT=false + + +############ +# API - Configuration for PostgREST +############ + +# Postgres schemas exposed via the REST API +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# Max number of rows returned by a request +PGRST_DB_MAX_ROWS=1000 + +# Extra schemas added to the search_path of every request +PGRST_DB_EXTRA_SEARCH_PATH=public + + +############ +# Logs and Analytics +############ + +## Vector log collection and routing + +# Docker socket location - required for proper Vector operation +DOCKER_SOCKET_LOCATION=/var/run/docker.sock +# For Podman use the following: +# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock + +## Analytics (Logflare) + +# Check the LOGFLARE_* access token configuration _above_. +# If Logflare has to be externally exposed - configure securely! + +# Google Cloud Project details +# Documentation: +# https://supabase.com/docs/reference/self-hosting-analytics/introduction +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER + + +############ +# API gateway +############ + +# Kong configuration variables +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + +# Used internally by the API gateway - DO NOT use in any client or server code. +# Pre-signed ES256 JWT "API key" for anon role. +ANON_KEY_ASYMMETRIC= +# Pre-signed ES256 JWT "API key" for service_role. +SERVICE_ROLE_KEY_ASYMMETRIC= + + +############ +# imgproxy +############ + +# Enable webp support +IMGPROXY_AUTO_WEBP=true + + +############ +# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https + +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + +# Domain name for the proxy (must point to your server) +PROXY_DOMAIN=your-domain.example.com + +# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN). +# This should be a valid email, not a placehoder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/.env.old b/.env.old new file mode 100644 index 0000000..9fbf77f --- /dev/null +++ b/.env.old @@ -0,0 +1,366 @@ +############ +# Docker compose override files to layer on top of docker-compose.yml. +# Native docker compose COMPOSE_FILE: colon-separated list, base file first. +# Manage with: ./run.sh config add|remove +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +############ +COMPOSE_FILE=docker-compose.yml + + +############ +# Secrets +# +# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING +# THE CONTAINERS FOR THE FIRST TIME! +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase +# +# To generate secrets and API keys: +# 1. sh utils/generate-keys.sh +# 2. sh utils/add-new-auth-keys.sh +# +############ + +# Postgres +POSTGRES_PASSWORD=7da49c3b4a62d446430082bd0cb3c238 + +# Legacy symmetric HS256 key +JWT_SECRET=gNAGgi6vYfyMqFMyHT5nbFow1MklGE57/fiap9pA +# Legacy API keys (HS256-signed JWTs) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgwNjY1NDc0LCJleHAiOjE5MzgzNDU0NzR9.IdSVAHLcELmrQj19YU-7Tb_oI0BwQyADfRDu4qdW1sk +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3ODA2NjU0NzQsImV4cCI6MTkzODM0NTQ3NH0.jvJnzgYVbx1aQOhhs3nBL99LdfPTGoa2haDtpIYW-dU + +# Asymmetric key pair (ES256) and opaque API keys +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys +# +# To generate: +# sh ./utils/add-new-auth-keys.sh +# +# Opaque API key for client-side use (anon role). +SUPABASE_PUBLISHABLE_KEY=sb_publishable_PNDlYaG13ZGdG67ZlpKlC3_bc8PpuRg +# Opaque API key for server-side use (service_role). Never expose in client code. +SUPABASE_SECRET_KEY=sb_secret_ePbz8YDnis8hpLJb4z7dMY_oRwPtoUh +# JSON array of signing JWKs (EC private + legacy symmetric). +# Used by Auth. +JWT_KEYS=[{"kty":"EC","kid":"ed13496a-53df-43e8-afb3-2016fe217508","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext":true,"crv":"P-256","x":"2eIZ3MgewDAJwxrmYJRXjOvZsf3VkWKz8THYYfyHlK8","y":"qad03ZZhMadwdlCkNgI8Lyatjh22EKvNVGsCPq84B18","d":"TPZ3pFwn0bmgKSo6T81PCuJm8BDcNOTqUXbKN6NVkQc"},{"kty":"oct","k":"Z05BR2dpNnZZZnlNcUZNeUhUNW5iRm93MU1rbEdFNTcvZmlhcDlwQQ","alg":"HS256"}] +# JWKS for token verification (EC public + legacy symmetric). +# Used by PostgREST, Realtime, Storage to verify tokens. +JWT_JWKS={"keys":[{"kty":"EC","kid":"71dc613b-7ab3-493c-8df2-cd6c66026e70","use":"sig","key_ops":["verify"],"alg":"ES256","ext":true,"crv":"P-256","x":"TOy2AYvt1WuUNSrxy2eWZQmT5yMmDUKNv74Kz9Yt964","y":"sxNABQp1bA12RZjoVSvp4wWO3GTDDF_U54YrfPP3YlY"},{"kty":"oct","k":"QzFiRUpHNElqcE51dlIxODJZUDZFeFZjN1g0Y2dQU2dsOXF5UGlUQg","alg":"HS256"}]} + +# Access to Dashboard +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=909f5980a3542c84c6ae6bcb82fdf8a0 + +# Used by Realtime and Supavisor +SECRET_KEY_BASE=cnrV6e3IdLYYQRMXW7ya+Q5nNAKj6sX23NDcWPQ1X8za/elrvWI/r96iIa/MRZgo + +# Used by Supavisor +VAULT_ENC_KEY=5a7707d9068ca3fc7473eee5ac372e35 + +# Used by Studio to access Postgres via postgres-meta +PG_META_CRYPTO_KEY=Q7yvLaLiy3T9A97LBmnDUJMjfE3HTbei + +# Analytics - API tokens for log ingestion/querying, and for management +# If Logflare has to be externally exposed - configure securely! +# Used in the docker-compose.logs.yml override. +LOGFLARE_PUBLIC_ACCESS_TOKEN=dJ78MZD//eVLtP1y0YAA4p+vjTjEvukX +LOGFLARE_PRIVATE_ACCESS_TOKEN=3PK3vgvRoX5Np9CxBQ1aQgL7OEC+/SS+ + +# Access to Storage via S3 protocol endpoint (see below) +S3_PROTOCOL_ACCESS_KEY_ID=7791a77abdfbd8dbbb4855ec341388e0 +S3_PROTOCOL_ACCESS_KEY_SECRET=46fbaccdd6188dec014200b89e43e5f1c98d95c3be40196defc504190d1d98c0 + + +############ +# URLs - Configure hostnames below to reflect your actual domain name +############ + +# Access to Dashboard and REST API +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Full external URL of the Auth service, used to construct OAuth callbacks, +# SAML endpoints, and email links +API_EXTERNAL_URL=http://localhost:8000 + +# See also the Auth section below for Site URL and Redirect URLs configuration + + +############ +# Database - Postgres configuration +############ + +# Using default user (postgres) +POSTGRES_HOST=db +POSTGRES_DB=postgres + +# Default configuration includes Supavisor exposing POSTGRES_PORT +# Postgres uses POSTGRES_PORT inside the container +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres-through-supavisor +POSTGRES_PORT=5432 + + +############ +# Supavisor - Database pooler +############ + +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres +POOLER_TENANT_ID=your-tenant-id + +# Pool size for internal metadata storage used by Supavisor +# This is separate from client connections and used only by Supavisor itself +POOLER_DB_POOL_SIZE=5 + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +# Add your OpenAI API key to enable AI Assistant +OPENAI_API_KEY=sk-proj-xxxxxxxx + + +############ +# Auth - Configuration for the authentication server +############ + +## General settings + +# Equivalent to "Site URL" and "Redirect URLs" platform configuration options +# Documentation: https://supabase.com/docs/guides/auth/redirect-urls +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= + +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + +## OAuth / Social login providers + +# Uncomment and fill in the providers you want to enable. +# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth +# GOOGLE_ENABLED=false +# GOOGLE_CLIENT_ID= +# GOOGLE_SECRET= + +# GITHUB_ENABLED=false +# GITHUB_CLIENT_ID= +# GITHUB_SECRET= + +# AZURE_ENABLED=false +# AZURE_CLIENT_ID= +# AZURE_SECRET= + +# Phone / SMS provider configuration +# Uncomment to configure SMS delivery for phone auth and phone MFA. +# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa +# SMS_PROVIDER=twilio +# SMS_OTP_EXP=60 +# SMS_OTP_LENGTH=6 +# SMS_MAX_FREQUENCY=60s +# SMS_TEMPLATE=Your code is {{ .Code }} + +# SMS_TWILIO_ACCOUNT_SID= +# SMS_TWILIO_AUTH_TOKEN= +# SMS_TWILIO_MESSAGE_SERVICE_SID= + +# Test OTP: map phone numbers to fixed OTP codes for development +# Format: phone1:code1,phone2:code2 +# SMS_TEST_OTP= + +# Multi-factor authentication (MFA) +# Uncomment to change MFA defaults. +# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml + +# App Authenticator (TOTP) - enabled by default +# MFA_TOTP_ENROLL_ENABLED=true +# MFA_TOTP_VERIFY_ENABLED=true + +# Phone MFA - disabled by default (opt-in) +# MFA_PHONE_ENROLL_ENABLED=false +# MFA_PHONE_VERIFY_ENABLED=false + +# Maximum MFA factors a user can enroll +# MFA_MAX_ENROLLED_FACTORS=10 + +## SAML SSO + +# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso + +# SAML_ENABLED=true +# SAML_PRIVATE_KEY= + +# Optional: accept encrypted SAML assertions from IdPs (default: false) +# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false + +# Optional: how long relay state tokens remain valid (default: 2m0s) +# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s + +# Optional: override the SAML entity ID / ACS base URL +# Defaults to API_EXTERNAL_URL if not set +# SAML_EXTERNAL_URL=https://supabase.example.com:8000 + +# Optional: rate limit on the ACS endpoint (requests per second, default: 15) +# SAML_RATE_LIMIT_ASSERTION=15 + + +############ +# Storage - Configuration for Storage +############ + +# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and +# refer to the documentation at: +# https://supabase.com/docs/guides/self-hosting/self-hosted-s3 +# to learn how to configure the S3 protocol endpoint + +# S3 bucket when using S3 backend, directory name when using 'file' +GLOBAL_S3_BUCKET=stub + +# Used for S3 protocol endpoint configuration +REGION=stub + +# Used by MinIO when added via: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=2cc00cf3c9aa04b63d22b8e9549deae6 + +# Equivalent to project_ref as described here: +# https://supabase.com/docs/guides/storage/s3/authentication#session-token +STORAGE_TENANT_ID=stub + + +############ +# Functions - Configuration for Edge functions +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-functions + +# NOTE: VERIFY_JWT applies to all functions +FUNCTIONS_VERIFY_JWT=false + + +############ +# API - Configuration for PostgREST +############ + +# Postgres schemas exposed via the REST API +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# Max number of rows returned by a request +PGRST_DB_MAX_ROWS=1000 + +# Extra schemas added to the search_path of every request +PGRST_DB_EXTRA_SEARCH_PATH=public + + +############ +# Logs and Analytics +############ + +## Vector log collection and routing + +# Docker socket location - required for proper Vector operation +DOCKER_SOCKET_LOCATION=/var/run/docker.sock +# For Podman use the following: +# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock + +## Analytics (Logflare) + +# Check the LOGFLARE_* access token configuration _above_. +# If Logflare has to be externally exposed - configure securely! + +# Google Cloud Project details +# Documentation: +# https://supabase.com/docs/reference/self-hosting-analytics/introduction +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER + + +############ +# API gateway +############ + +# Kong configuration variables +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + +# Used internally by the API gateway - DO NOT use in any client or server code. +# Pre-signed ES256 JWT "API key" for anon role. +ANON_KEY_ASYMMETRIC=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImVkMTM0OTZhLTUzZGYtNDNlOC1hZmIzLTIwMTZmZTIxNzUwOCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgwNjY1NDk0LCJleHAiOjE5MzgzNDU0OTR9.rhh-E42KhEp6F_znI-fT9PrBFOANyk89xuzcdlwrMfQ5tBVnHr-oHt088yLn0G_qzpuG-3p8L3RpCBCjiQC3yw +# Pre-signed ES256 JWT "API key" for service_role. +SERVICE_ROLE_KEY_ASYMMETRIC=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImVkMTM0OTZhLTUzZGYtNDNlOC1hZmIzLTIwMTZmZTIxNzUwOCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3ODA2NjU0OTQsImV4cCI6MTkzODM0NTQ5NH0.qaoG8kfkwXDw1ap0R3k1mqn-NLItjxxrOn9BWiXXwIs40dXTfcW1hEx2NlUWKR_-VJuFg1GpiS3kBrrGs-WqjA + + +############ +# imgproxy +############ + +# Enable webp support +IMGPROXY_AUTO_WEBP=true + + +############ +# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https + +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + +# Domain name for the proxy (must point to your server) +PROXY_DOMAIN=your-domain.example.com + +# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN). +# This should be a valid email, not a placehoder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..95aa9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto + +*.md eol=lf + +*.env eol=lf +.env.example eol=lf + +*.sh eol=lf +*.sql eol=lf +*.yml eol=lf +*.yaml eol=lf +*.ts eol=lf +*.exs eol=lf +*.conf eol=lf +*.tpl eol=lf +Caddyfile eol=lf +Dockerfile* eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1e9dc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +volumes/db/data +volumes/storage +.env +test.http +docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0149332 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,538 @@ +# Changelog + +All notable changes to the Supabase self-hosted Docker configuration. + +Changes are grouped by service rather than by change type. See [versions.md](./versions.md) for complete image version history and rollback information. + +See per-service updates below for details. Only the most important changes relevant to [self-hosted Supabase](https://supabase.com/docs/guides/self-hosting) are included here. For the full list of changes, refer to the release notes and changelogs of each individual service. + +**Note:** Configuration updates marked with "requires [...] update" are already included in the latest version of the repository. Pull the latest changes or refer to the linked PR for manual updates. After updating `docker-compose.yml`, pull the latest images and recreate containers - use `docker compose pull && docker compose down && docker compose up -d`. + +--- + +## Unreleased + +⚠️ **Upcoming changes:** Check the main Supabase [changelog](https://github.com/orgs/supabase/discussions/categories/changelog?discussions_q=is%3Aopen+category%3AChangelog+label%3Aself-hosted) for updates: + +- [Upgrading from PG 15 to 17 (breaking change)](https://github.com/orgs/supabase/discussions/46080) +- [Switching Studio from `supabase_admin` to `postgres` (breaking change)](https://github.com/orgs/supabase/discussions/46081) + +--- + +## [2026-06-03] + +⚠️ **Note:** This update includes **important changes**. Please check the details below. + +### Configuration +- ⚠️ Logs and analytics are now [optional](https://github.com/orgs/supabase/discussions/46084) and were removed from the default `docker-compose.yml`. A new `docker-compose.logs.yml` override has been added. Check the main [configuration guide](https://supabase.com/docs/guides/self-hosting/docker#enabling-analytics) and the changes to Studio below for more information - PR [#45327](https://github.com/supabase/supabase/pull/45327) (via [@luizfelmach](https://github.com/luizfelmach/)) +- ⚠️ Added `COMPOSE_FILE` to `.env.example` for configuring compose overrides (also used by `run.sh`) - PR [#45603](https://github.com/supabase/supabase/pull/45603) + +### Documentation +- Added a new [reference list](https://github.com/supabase/supabase/blob/master/docker/CONFIG.md) of all configuration environment variables - PR [#46124](https://github.com/supabase/supabase/pull/46124) +- Updated the main installation and configuration [guide](https://supabase.com/docs/guides/self-hosting/docker) (added "quick start" path and opt-in for logs and analytics; removed the legacy JWT secrets generator) - PR [#46416](https://github.com/supabase/supabase/pull/46416), PR [#45359](https://github.com/supabase/supabase/pull/45359) +- Updated the logs and analytics [how-to guide](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#46452](https://github.com/supabase/supabase/pull/46452) + +### Utils +- Added `setup.sh` and `run.sh` to support quick start and easier management of the compose configuration - PR [#45603](https://github.com/supabase/supabase/pull/45603) +- Updated `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-key.sh` to remove the dependency on OpenSSL and Node.js - PR [#45941](https://github.com/supabase/supabase/pull/45941) +- Updated `tests/test-container-logs.sh` to skip checks for `kong`, `analytics` and `vector` when the services are not running - PR [#46099](https://github.com/supabase/supabase/pull/46099) + +### API gateway +- Updated Envoy version to `1.38.0` (see `docker-compose.envoy.yml`) - PR [#46023](https://github.com/supabase/supabase/pull/46023) +- Updated Envoy configuration to address a discrepancy in API key checking (requires `volumes/api/envoy` update) - PR [#46023](https://github.com/supabase/supabase/pull/46023) + +### Studio +- Updated to `2026.06.03-sha-0bca601` +- ⚠️ Added `ENABLED_FEATURES_LOGS_ALL` to Studio service configuration (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327) +- ⚠️ Added `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` to Studio service configuration (requires `docker-compose.yml` update) - PR [#46173](https://github.com/supabase/supabase/pull/46173) +- ⚠️ Added `start_period` to Studio healthcheck for more reliable cold-boot on slower hosts (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327) +- Fixed incorrect connection strings in the connect sheet for self-hosted environments - PR [#46217](https://github.com/supabase/supabase/pull/46217) +- Updated project home and functions page, and added a minimal project settings implementation - PR [#46544](https://github.com/supabase/supabase/pull/46544), PR [#46550](https://github.com/supabase/supabase/pull/46550), PR [#46554](https://github.com/supabase/supabase/pull/46554) + +### Auth +- Updated to `v2.189.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.189.0) +- ⚠️ Added `GOTRUE_JWT_ISSUER` to Auth service configuration (requires `docker-compose.yml` update) - PR [#46020](https://github.com/supabase/supabase/pull/46020) + +### PostgREST +- Updated to `v14.12` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.12) + +### Realtime +- Updated to `v2.102.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.102.3) + +### Storage +- Updated to `v1.60.4` - [Release](https://github.com/supabase/storage/releases/tag/v1.60.4) + +### Postgres Meta +- Updated to `v0.96.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.6) + +### Edge Runtime +- Updated to `v1.74.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.74.0) + +### Supavisor +- Updated to `2.9.5` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.9.5) +- Added `POSTGRES_HOST` to Supavisor service configuration (requires `docker-compose.yml` and `volumes/pooler/pooler.exs` update) - PR [#41273](https://github.com/supabase/supabase/pull/41273) + +### Analytics (Logflare) +- Updated to `1.43.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.43.1) +- ⚠️ Changed default `docker-compose.yml` to no longer include logs & analytics. Read more in Supabase's [changelog](https://github.com/orgs/supabase/discussions/46084) - PR [#45327](https://github.com/supabase/supabase/pull/45327) + +--- + +## [2026-04-27] + +### Configuration +- ⚠️ Added `docker-compose.envoy.yml` and `volumes/api/envoy`. See also the API gateway updates below - PR [#43838](https://github.com/supabase/supabase/pull/43838) +- ⚠️ Changed Studio healthcheck and some other configuration for better compatibility with Podman (requires `docker-compose.yml` update) - PR [#44754](https://github.com/supabase/supabase/pull/44754) +- ⚠️ Changed Studio configuration to bind to all IPv4 interfaces only (requires `docker-compose.yml` update) - PR [#44772](https://github.com/supabase/supabase/pull/44772) + +### Documentation +- Added a new [how-to](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) describing how to switch from `supabase_admin` to `postgres` role for Studio - PR [#42975](https://github.com/supabase/supabase/pull/42975) (via [@singh-inder](https://github.com/singh-inder/)) +- Added a new [how-to](https://github.com/supabase/supabase/pull/45152) for configuring Envoy as the new API gateway - PR [#45152](https://github.com/supabase/supabase/pull/45152) +- Updated the main [setup guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos to reflect the state of the self-hosted Supabase configuration - PR [#45011](https://github.com/supabase/supabase/pull/45011) + +### Utils +- ⚠️ Added `utils/reassign-owner.sh` to update database objects. Read more in the "[Remove superuser access](https://supabase.com/docs/guides/self-hosting/remove-superuser-access)" how-to guide - PR [#42975](https://github.com/supabase/supabase/pull/42975) +- ⚠️ Changed `utils/add-new-auth-keys.sh` to also update `docker-compose.yml` - PR [#45056](https://github.com/supabase/supabase/pull/45056) + +### API gateway +- ⚠️ Added Envoy as the new optional API gateway (requires `docker-compose.envoy.yml`, `volumes/api/envoy`, and `volumes/logs/vector.yml` update) - PR [#43838](https://github.com/supabase/supabase/pull/43838) (via [@luizfelmach](https://github.com/luizfelmach/)) + +### Studio +- Updated to `2026.04.27-sha-5f60601` +- ⚠️ Added 4 new lints to the Security Advisor. Read more about lint rules 0026 - 0029 in the [Performance and Security Advisors](https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0026_pg_graphql_anon_table_exposed) section of the Supabase documentation - PR [#45253](https://github.com/supabase/supabase/pull/45253), PR [#45260](https://github.com/supabase/supabase/pull/45260) +--- + +## [2026-04-08] + +### Documentation +- Added new how-to guides for configuring [custom email templates](https://supabase.com/docs/guides/self-hosting/custom-email-templates), setting up [SAML SSO](https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso), and [using Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) - PR [#42832](https://github.com/supabase/supabase/pull/42832), PR [#43386](https://github.com/supabase/supabase/pull/43386), PR [#44147](https://github.com/supabase/supabase/pull/44147) + +### Utils +- ⚠️ Added `utils/upgrade-pg17.sh`. Read more in the "[Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17)" how-to guide - PR [#44147](https://github.com/supabase/supabase/pull/44147) + +### API gateway +- ⚠️ Added configuration for SAML SSO (requires `.env`, `docker-compose.yml` and `volumes/api/kong.yml` update) - PR [#43385](https://github.com/supabase/supabase/pull/43385) (via [@luizfelmach](https://github.com/luizfelmach/)) + +### Studio +- Updated to `2026.04.08-sha-205cbe7` + +### PostgREST +- Updated to `v14.8` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.8) + +### Storage +- Updated to `v1.48.26` - [Release](https://github.com/supabase/storage/releases/tag/v1.48.26) + +### imgproxy +- Changed `IMGPROXY_ENABLE_WEBP_DETECTION` environment variable to `IMGPROXY_AUTO_WEBP` (requires `.env` and `docker-compose.yml` update) - PR [#43919](https://github.com/supabase/supabase/pull/43919) + +### Postgres Meta +- Updated to `v0.96.3` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.3) + +### Analytics (Logflare) +- Updated to `1.36.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.36.1) + +### Postgres +- ⚠️ Added `docker-compose.pg17.yml` override - PR [#44147](https://github.com/supabase/supabase/pull/44147) +- ⚠️ Added `utils/upgrade-pg17.sh` - PR [#44147](https://github.com/supabase/supabase/pull/44147) +- ⚠️ Added [documentation](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) explaining the upgrade to Postgres 17 + +--- + +## [2026-03-16] + +⚠️ **Note:** This update includes **important changes**. Please check the details below. The following configuration files have been added/updated: `utils/add-new-auth-keys.sh`, `utils/rotate-new-api-keys.sh`, `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `docker-compose.rustfs.yml`, `volumes/api/kong.yml`, `volumes/api/kong-entrypoint.sh`, `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/functions/main/index.ts`, and `volumes/proxy`. + +### Configuration +- ⚠️ Added scripts and templates to support the new API key format (`sb_` API keys) and the new asymmetric authentication. Check the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) for detailed instructions - PR [#43554](https://github.com/supabase/supabase/pull/43554) +- Added optional proxy configuration for Caddy and nginx. Read the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) to learn more - PR [#43291](https://github.com/supabase/supabase/pull/43291) + +### Documentation +- Added several new how-to guides to the self-hosted Supabase [documentation](https://supabase.com/docs/guides/self-hosting) - PR [#42745](https://github.com/supabase/supabase/pull/42745), PR [#42953](https://github.com/supabase/supabase/pull/42953), PR [#43177](https://github.com/supabase/supabase/pull/43177), PR [#43286](https://github.com/supabase/supabase/pull/43286), PR [#43293](https://github.com/supabase/supabase/pull/43293) + +### Utils and tests +- Added `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-keys.sh` - PR [#43554](https://github.com/supabase/supabase/pull/43554) +- Added `tests/` with 100+ test cases - PR [#43573](https://github.com/supabase/supabase/pull/43573) + +### Studio +- Updated to `2026.03.16-sha-5528817` +- ⚠️ Added the link to the Data API page in Integrations - PR [#43268](https://github.com/supabase/supabase/pull/43268) +- ⚠️ Added `PGRST_DB_SCHEMAS`, `PGRST_DB_EXTRA_SEARCH_PATH`, and `PGRST_DB_MAX_ROWS` to Studio configuration (requires `docker-compose.yml` update) - PR [#43268](https://github.com/supabase/supabase/pull/43268) + +### MCP Server +- Updated to `v0.7.0` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.7.0) + +### API gateway +- ⚠️ Updated Kong to `3.9.1` - PR [#43554](https://github.com/supabase/supabase/pull/43554) + +### PostgREST +- Updated to `v14.6` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.6) + +### Realtime +- ⚠️ Added **mandatory** `METRICS_JWT_SECRET` environment variable (requires `docker-compose.s3.yml` update) - PR [realtime#1729](https://github.com/supabase/realtime/pull/1729) + +### Storage +- Updated to `v1.44.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.44.2) +- ⚠️ Added `STORAGE_PUBLIC_URL` environment variable to simplify proxy configuration (requires `docker-compose.s3.yml` update) - PR [storage#900](https://github.com/supabase/storage/pull/900) +- ⚠️ Added RustFS as an optional S3 backend - PR [#42935](https://github.com/supabase/supabase/pull/42935) +- ⚠️ Changed Docker Compose configuration for S3 backends to use named volumes - PR [#43815](https://github.com/supabase/supabase/pull/43815) + +### Edge Runtime +- Updated to `v1.71.2` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.71.2) +- ⚠️ Added `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_PUBLIC_URL` environment variables (requires `docker-compose.yml` update) +- ⚠️ Added an option for a "hybrid" JWT verification following the addition of the new API keys and the new asymmetric authentication (requires `volumes/functions/main/index.ts` update) - PR [#42130](https://github.com/supabase/supabase/pull/42130) +- ⚠️ Added optional rate limiter - PR [edge-runtime#670](https://github.com/supabase/edge-runtime/pull/670) + +--- + +## [2026-02-18] + +### Storage +- Changed MinIO image to use Chainguard [minio](https://images.chainguard.dev/directory/image/minio/overview) and [minio-client](https://images.chainguard.dev/directory/image/minio-client/overview) (requires `docker-compose.s3.yml` update) - PR [#42942](https://github.com/supabase/supabase/pull/42942) +- Updated Storage image version to `v1.37.8` in `docker-compose.s3.yml` +- Removed `imgproxy` service from `docker-compose.s3.yml` to minimize redundancy - PR [#42942](https://github.com/supabase/supabase/pull/42942) +- Fixed inconsistent `storage` service entry ordering in `docker-compose.yml` and `docker-compose.s3.yml` to improve diff readability - PR [#42942](https://github.com/supabase/supabase/pull/42942) + +### Edge Runtime +- Added a `deno-cache` named volume to avoid re-downloading dependencies (requires `docker-compose.yml` and `volumes/functions/*` update) - PR [#40822](https://github.com/supabase/supabase/pull/40822) + +--- + +## [2026-02-16] + +⚠️ **Note:** This update includes several breaking changes, including a security fix for Analytics. Please check the details below. The following configuration files have been updated: `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `volumes/api/kong.yml`, and `volumes/logs/vector.yml`. + +### Studio +- Updated to `2026.02.16-sha-26c615c` +- Added Edge Functions management UI (requires `docker-compose.yml` update) - PR [#40690](https://github.com/supabase/supabase/pull/40690), PR [#42322](https://github.com/supabase/supabase/pull/42322), PR [#42349](https://github.com/supabase/supabase/pull/42349), PR [#42350](https://github.com/supabase/supabase/pull/42350) + +### MCP Server +- Updated to `v0.6.3` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.6.3) + +### Auth +- Updated to `v2.186.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.186.0) + +### PostgREST +- Updated to `v14.5` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.5) + +### Realtime +- Updated to `v2.76.5` - [Release](https://github.com/supabase/realtime/releases/tag/v2.76.5) + +### Storage +- Updated to `v1.37.8` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.8) +- ⚠️ Changed environment variable configuration for Storage (requires `docker-compose.yml`, `.env.example` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185), PR [#42862](https://github.com/supabase/supabase/pull/42862) +- ⚠️ Added **default** configuration to access buckets via `/storage/v1/s3` endpoint (requires `docker-compose.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185) +- ⚠️ Changed MinIO configuration for the S3 backend (requires `docker-compose.s3.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185) + +### Edge Runtime +- Updated to `v1.70.3` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.3) + +### Analytics (Logflare) +- Updated to `1.31.2` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.31.2) +- ⚠️ Changed default configuration to disable Logflare on `0.0.0.0:4000` to prevent access to `/dashboard` (requires `docker-compose.yml` update). Read more in the "Production Recommendations" section of Logflare [documentation](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#42857](https://github.com/supabase/supabase/pull/42857) +- ⚠️ Changed Kong routes to not include `/analytics/v1` by default (requires `/volumes/api/kong.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) + +### Vector +- Updated to `0.53.0-alpine` - [Changelog](https://vector.dev/releases/0.53.0/) | [Release](https://github.com/vectordotdev/vector/releases/tag/v0.53.0) +- ⚠️ Major version jump from `0.28.1` (requires `volumes/logs/vector.yml` update) - PR [#42525](https://github.com/supabase/supabase/pull/42525) +- ⚠️ Changed Postgres sink configuration to bypass Kong (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) +- ⚠️ Changed retry settings for all sinks to increase timeouts (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) + +--- + +## [2026-02-05] + +### Storage +- Updated to `v1.37.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.1) +- Fixed an issue with Storage not starting because of an issue with migrations - PR [storage#845](https://github.com/supabase/storage/pull/845) + +--- + +## [2026-01-27] + +### Studio +- Updated to `2026.01.27-sha-6aa59ff` +- Added SQL snippets (requires `docker-compose.yml` update) - PR [#41112](https://github.com/supabase/supabase/pull/41112), PR [#41557](https://github.com/supabase/supabase/pull/41557), discussion [#42031](https://github.com/orgs/supabase/discussions/42031) +- Fixed type generator - PR [#40481](https://github.com/supabase/supabase/pull/40481) +- Fixed minor UI discrepancies - PR [#40579](https://github.com/supabase/supabase/pull/40579), PR [#41936](https://github.com/supabase/supabase/pull/41936), PR [#41970](https://github.com/supabase/supabase/pull/41970), PR [#41971](https://github.com/supabase/supabase/pull/41971), PR [#41972](https://github.com/supabase/supabase/pull/41972), PR [#42015](https://github.com/supabase/supabase/pull/42015) + +### Auth +- Updated to `v2.185.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.185.0) +- ⚠️ Fixed security-related issues + +### PostgREST +- Updated to `v14.3` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.3) + +### Realtime +- Updated to `v2.72.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.72.0) +- Changed healthchecks logging to off by default (requires `docker-compose.yml` update) - PR [realtime#1677](https://github.com/supabase/realtime/pull/1677), PR [#42156](https://github.com/supabase/supabase/pull/42156) +- Changed logging configuration and healthcheck frequency to reduce log volume (requires `docker-compose.yml` update) - PR [#42112](https://github.com/supabase/supabase/pull/42112) + +### Storage +- Updated to `v1.33.5` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.5) + +### imgproxy +- Updated to `v3.30.1` - [Changelog](https://github.com/imgproxy/imgproxy/blob/master/CHANGELOG.md) | [Release](https://github.com/imgproxy/imgproxy/releases/tag/v3.30.1) + +### Postgres Meta +- Updated to `v0.95.2` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.2) + +### Edge Runtime +- Updated to `v1.70.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.0) + +### Analytics (Logflare) +- Updated to `1.30.3` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.30.3) + +### Postgres +- No image update +- Fixed Postgres logging configuration (requires `volumes/logs/vector.yml` update) - PR [#41800](https://github.com/supabase/supabase/pull/41800) + +--- + +## [2025-12-18] + +### Documentation +- Updated self-hosting installation and configuration guide - PR [#40901](https://github.com/supabase/supabase/pull/40901), PR [#41438](https://github.com/supabase/supabase/pull/41438) + +### Utils +- Added `utils/generate-keys.sh` - PR [#41363](https://github.com/supabase/supabase/pull/41363) +- Added `utils/db-passwd.sh` - PR [#41432](https://github.com/supabase/supabase/pull/41432) +- Changed `reset.sh` to POSIX and added more checks - PR [#41361](https://github.com/supabase/supabase/pull/41361) + +### Studio +- Updated to `2025.12.17-sha-43f4f7f` +- ⚠️ Fixed additional issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell) +- Fixed an issue with the Users page not being updated on changes - PR [#41254](https://github.com/supabase/supabase/pull/41254) + +### MCP Server +- Updated to `v0.5.10` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.5.10) + +### Auth +- Updated to `v2.184.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.184.0) + +### Postgres Meta +- Updated to `v0.95.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.1) + +### Analytics (Logflare) +- Updated to `1.27.0` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.27.0) +- Fixed multiple issues, including a race condition + +--- + +## [2025-12-10] + +### Studio +- Updated to `2025.12.09-sha-434634f` +- ⚠️ Fixed security issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell) + +### MCP Server +- Updated to `v0.5.9` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.5.9) +- ⚠️ Changed MCP tool `get_anon_key` to `get_publishable_keys` + +### PostgREST +- Updated to `v14.1` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.1) +- ⚠️ **Major upgrade from v13.x to v14.x** - please report any unexpected behavior + +### Realtime +- Updated to `v2.68.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.68.0) + +### Storage +- Updated to `v1.33.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.0) + +### Edge Runtime +- Updated to `v1.69.28` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.28) + +### Analytics (Logflare) +- Updated to `1.26.25` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.25) + +--- + +## [2025-12-08] + +### Realtime +- No image update +- Changed boolean values to strings in Docker Compose for better compatibility with Podman - PR [#40994](https://github.com/supabase/supabase/pull/40994), also PR [realtime#1614](https://github.com/supabase/realtime/pull/1614) +- Changed healthcheck in Docker Compose for better compatibility with Podman - PR [#41159](https://github.com/supabase/supabase/pull/41159) + +--- + +## [2025-11-26] + +### Studio +- Updated to `2025.11.26-sha-8f096b5` +- Fixed MCP `get_advisors` tool - PR [#40783](https://github.com/supabase/supabase/pull/40783) +- Fixed AI Assistant request schema - PR [#40830](https://github.com/supabase/supabase/pull/40830) +- Fixed log drains page - PR [#40835](https://github.com/supabase/supabase/pull/40835) + +### Realtime +- Updated to `v2.65.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.3) + +### Analytics (Logflare) +- Updated to `1.26.13` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.13) +- Fixed crashdump when `POSTGRES_BACKEND_URL` is malformed - PR [logflare#2954](https://github.com/Logflare/logflare/pull/2954) + +--- + +## [2025-11-25] + +### Studio +- Updated to `2025.11.24-sha-d990ae8` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40734) +- Fixed Queues configuration UI and added [documentation for exposed queue schema](https://supabase.com/docs/guides/queues/expose-self-hosted-queues) - PR [#40078](https://github.com/supabase/supabase/pull/40078) +- Fixed parameterized SQL queries in MCP tools - PR [#40499](https://github.com/supabase/supabase/pull/40499) +- Fixed Studio showing paid options for log drains - PR [#40510](https://github.com/supabase/supabase/pull/40510) +- Fixed AI Assistant authentication - PR [#40654](https://github.com/supabase/supabase/pull/40654) + +### Auth +- Updated to `v2.183.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.183.0) + +### Realtime +- Updated to `v2.65.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.2) +- Fixed handling of boolean configuration options - PR [realtime#1614](https://github.com/supabase/realtime/pull/1614) + +### Storage +- Updated to `v1.32.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.32.0) + +### Edge Runtime +- Updated to `v1.69.25` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.25) + +### Analytics (Logflare) +- Updated to `1.26.12` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.12) +- Fixed Auth logs query - PR [logflare#2936](https://github.com/Logflare/logflare/pull/2936) +- Fixed build configuration to prevent crashes with "Illegal instruction (core dumped)" - PR [logflare#2942](https://github.com/Logflare/logflare/pull/2942) + +--- + +## [2025-11-17] + +### Storage +- No image update +- Fixed resumable uploads for files larger than 6MB (requires `docker-compose.yml` update) - PR [#40500](https://github.com/supabase/supabase/pull/40500) + +--- + +## [2025-11-12] + +### Studio +- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083) +- Added log drains - PR [#28297](https://github.com/supabase/supabase/pull/28297) +- Fixed Studio using `postgres` role instead of `supabase_admin` - PR [#39946](https://github.com/supabase/supabase/pull/39946) + +### Auth +- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1) + +### Realtime +- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0) + +### Storage +- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0) + +### Edge Runtime +- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23) + +### Supavisor +- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4) + +--- + +## [2025-11-05] + +### Studio +- No image update +- Fixed Studio failing to connect to Postgres with non-default settings (requires `docker-compose.yml` update) - PR [#40169](https://github.com/supabase/supabase/pull/40169) + +### Realtime +- No image update +- Fixed realtime logs not showing in Studio (requires `volumes/logs/vector.yml` update) - PR [#39963](https://github.com/supabase/supabase/pull/39963) + +--- + +## [2025-10-28] + +### Studio +- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083) +- Fixed broken authentication when uploading files to Storage - PR [#39829](https://github.com/supabase/supabase/pull/39829) + +### Realtime +- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2) + +### Storage +- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2) + +### Postgres Meta +- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1) + +### Edge Runtime +- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15) + +--- + +## [2025-10-27] + +### Studio +- No image update +- Added Kong configuration for MCP server routes (requires `volumes/api/kong.yml` update) - PR [#39849](https://github.com/supabase/supabase/pull/39849) +- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) for MCP server configuration - PR [#39952](https://github.com/supabase/supabase/pull/39952) + +--- + +## [2025-10-21] + +### Studio +- Updated to `2025.10.20-sha-5005fc6` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709) +- Fixed issues with Edge Functions and cron logs not being visible in Studio - PR [#39388](https://github.com/supabase/supabase/pull/39388), PR [#39704](https://github.com/supabase/supabase/pull/39704), PR [#39711](https://github.com/supabase/supabase/pull/39711) + +### Realtime +- Updated to `v2.56.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.56.0) + +### Storage +- Updated to `v1.28.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.1) + +### Postgres Meta +- Updated to `v0.93.0` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.0) + +### Edge Runtime +- Updated to `v1.69.14` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.14) + +### Supavisor +- Updated to `2.7.3` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.3) + +--- + +## [2025-10-13] + +### Analytics (Logflare) +- Updated to `1.22.6` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.6) + +--- + +## [2025-10-08] + +### Studio +- Updated to `2025.10.01-sha-8460121` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709) +- Added "local" remote MCP server - PR [#38797](https://github.com/supabase/supabase/pull/38797), PR [#39041](https://github.com/supabase/supabase/pull/39041) +- ⚠️ Changed Studio connection method to `postgres-meta` - affects non-standard database port configurations + +### Auth +- Updated to `v2.180.0` - [Release](https://github.com/supabase/auth/releases/tag/v2.180.0) + +### PostgREST +- Updated to `v13.0.7` - [Release](https://github.com/PostgREST/postgrest/releases/tag/v13.0.7) | [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) + +### Realtime +- Updated to `v2.51.11` - [Release](https://github.com/supabase/realtime/releases/tag/v2.51.11) + +### Storage +- Updated to `v1.28.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.0) + +### Postgres Meta +- Updated to `v0.91.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.91.6) + +### Analytics (Logflare) +- Updated to `1.22.4` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.4) + +### Postgres +- Updated to `15.8.1.085` - [Release](https://github.com/supabase/postgres/releases/tag/15.8.1.085) + +### Supavisor +- Updated to `2.7.0` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.0) + +--- diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..d139fc9 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,1454 @@ +Last updated: 2026-05-23 + +# Self-hosted Supabase configuration reference + +This document is the aggregated reference for environment variables relevant to a self-hosted Supabase deployment. It aims to be comprehensive for the self-hosted use case rather than literally exhaustive - variables that only apply on the hosted platform are typically omitted or marked as such. For the complete set a given service can read, refer to its [upstream repositories](#upstream-repositories) below. + +The default self-hosted setup already includes explicit values for all required variables, and the remaining configuration inherits sensible defaults from the services themselves. Otherwise, it serves as a reference for advanced customization or educational purposes. For more guidance on the essential keys and secrets, see [Configuring secrets](https://supabase.com/docs/guides/self-hosting/docker#configuring-secrets) in the self-hosting guide. + +> **A note on accuracy.** This reference is compiled from each service's source code and upstream docs as a self-hosting overview - it is **not** maintained by the individual product teams and shouldn't be treated as canonical. Within each row: +> +> - **Type, defaults, formats, and where the variable is read** are derived directly from code (parse sites in Go / TypeScript / Elixir / Rust) and are usually reliable. The `Type` cell uses a closed vocabulary with embedded unit hints (e.g. `integer (seconds)`, `integer (ms)`, `string (duration)`) to surface unit conventions that vary across services. +> - The **prose description of what the variable is *for*** is more interpretive. It captures the immediate mechanical effect well, but the underlying *intent* - why the variable exists, when you should change it, how it interacts with other variables - is partly synthesized by an LLM and can be subtly off. +> +> When the *what* matters operationally, trust the row. When the *why* matters, defer to the upstream service's own documentation or source. + +## Verification status + +The Type column was derived from each service's parse-site code (Go struct fields, TypeScript conversions, Elixir parse calls, Rust `clap` declarations). The Description column is best-effort and was cross-checked against upstream prose where a fresh, trusted source exists: + +- **Auth** - against [supabase/auth/README.md](https://github.com/supabase/auth/blob/master/README.md) +- **PostgREST** - against [postgrest.org/en/stable](https://docs.postgrest.org/en/stable/references/configuration.html) +- **Realtime** - against [supabase/realtime/ENVS.md](https://github.com/supabase/realtime/blob/main/ENVS.md) +- **Storage** - against the Supabase docs [YAML spec](https://github.com/supabase/supabase/blob/master/apps/docs/spec/storage_v0_config.yaml) for the variables it covers; otherwise from code reads +- **Analytics (Logflare)** - against the Supabase docs [YAML spec](https://github.com/supabase/supabase/blob/master/apps/docs/spec/analytics_v0_config.yaml) and [docs.logflare.app/self-hosting](https://docs.logflare.app/self-hosting/) for the rest, with [logflare/config/runtime.exs](https://github.com/Logflare/logflare/blob/main/config/runtime.exs) and [logflare/config/config.exs](https://github.com/Logflare/logflare/blob/main/config/config.exs) used as tiebreakers +- **Supavisor** - against [supabase/supavisor/docs/configuration/env.md](https://github.com/supabase/supavisor/blob/main/docs/configuration/env.md) + +Other sections (Studio, Edge Functions, Postgres) appeared to have no comparable upstream prose documentation and were documented by reading the source repos. Corrections welcome via PR. + +## How to read this document + +Each table has five columns: + +| Column | Meaning | +|---|---| +| **Variable** | Exact env var name as the service's code reads it. Names are case-sensitive. | +| **Type** | Closed vocabulary: `string`, `integer`, `number`, `boolean`, `JSON`, `enum`, `URL`, `path`, `JWT`, `JWKS`. Numeric forms carry a unit hint where one applies - e.g. `integer (seconds)`, `integer (ms)`, `integer (bytes)`, `integer (MB)`, `integer (count)`, `number (ratio)`. String forms with a semantic hint: `string (duration)` (Go `time.Duration` strings like `10s`, `5m`, distinct from `integer (seconds)`), `string (regex)`, `string (CSV)`. | +| **Set by (CLI, Self-hosted)** | `Both` if the variable is set inside the corresponding container when you run `supabase start` (see the [Local development & CLI](https://supabase.com/docs/guides/local-development)) *and* in `docker/docker-compose.yml` / `docker/.env.example`. `Self-hosted` if only in the self-hosted compose/.env (including inside commented-out lines). `CLI` if only in the CLI runtime env. Blank if neither - the variable is documented because the service's code reads it, but no Supabase-side config pre-wires it. | +| **Description** | What the variable controls. | +| **Notes** | Default value, requirement, deprecation, alias, or scope. | + +A few caveats: + +- **"Set by = blank" does not mean "unusable in self-hosted".** It only means the default `docker-compose.yml` does not pass the variable through. You can almost always try to add it under the service's `environment:` block. +- **Defaults shown are from the upstream service code.** Some defaults are overridden by `docker-compose.yml`; where that happens it is called out in Notes. +- **The CLI does not run Supavisor** as part of `supabase start`, so every Supavisor variable's `Set by` is either `Self-hosted` or blank - never `Both` or `CLI`. +- **Auth/gotrue env vars are programmatically derived** from a Go config struct (envconfig), so most fields are reachable via two names: a prefixed form (`GOTRUE_API_API_EXTERNAL_URL`) and a bare-name alias (`API_EXTERNAL_URL`). Both are documented. + +## Upstream repositories + +The image tags below are pinned in `docker-compose.yml` at the time of this document; check that file for the current versions. + +| Service | Image | Source repo | +|---|---|---| +| Studio (Dashboard) | `supabase/studio` | [supabase/supabase/apps/studio](https://github.com/supabase/supabase/tree/master/apps/studio) | +| Auth | `supabase/gotrue` | [supabase/auth](https://github.com/supabase/auth) | +| PostgREST | `postgrest/postgrest` | [PostgREST/postgrest](https://github.com/PostgREST/postgrest) | +| Realtime | `supabase/realtime` | [supabase/realtime](https://github.com/supabase/realtime) | +| Storage | `supabase/storage-api` | [supabase/storage](https://github.com/supabase/storage) | +| Edge Functions | `supabase/edge-runtime` | [supabase/edge-runtime](https://github.com/supabase/edge-runtime) | +| Analytics | `supabase/logflare` | [logflare/logflare](https://github.com/logflare/logflare) | +| Postgres | `supabase/postgres` | [supabase/postgres](https://github.com/supabase/postgres) | +| Supavisor (Pooler) | `supabase/supavisor` | [supabase/supavisor](https://github.com/supabase/supavisor) | + +## Table of contents + +- [Studio (Dashboard)](#studio-dashboard) +- [Auth (GoTrue)](#auth) +- [PostgREST](#postgrest) +- [Realtime](#realtime) +- [Storage](#storage) +- [Edge Functions](#edge-functions) +- [Analytics (Logflare)](#analytics) +- [Postgres (supabase/postgres)](#postgres) +- [Supavisor](#supavisor) + +--- + +## Studio (Dashboard) + +> Studio is a Next.js (Pages Router) app. `NEXT_PUBLIC_*` variables are inlined into the client bundle at build time and are visible in the browser - never store secrets in them. + +### Core (database, API gateway, public URL) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DEFAULT_ORGANIZATION_NAME` | string | Self-hosted | Name shown for the single default organization on the dashboard. | Mapped from `STUDIO_DEFAULT_ORGANIZATION` in `.env.example`. Default: `Default Organization`. | +| `DEFAULT_PROJECT_NAME` | string | Self-hosted | Name shown for the single default project on the dashboard. | Mapped from `STUDIO_DEFAULT_PROJECT` in `.env.example`. Default: `Default Project`. | +| `HOSTNAME` | string | Both | Network interface Next.js binds to inside the container. | Set to `0.0.0.0` so the container is reachable from outside. | +| `POSTGRES_DB` | string | Self-hosted | Postgres database name used for Studio's internal connections. | Default: `postgres`. | +| `POSTGRES_HOST` | string | Self-hosted | Postgres host (service name in compose network). | Default: `db`. | +| `POSTGRES_PASSWORD` | string | Both | Postgres password for the `POSTGRES_USER_READ_WRITE` role. | Supports `_FILE` suffix for Docker secrets. | +| `POSTGRES_PORT` | integer | Self-hosted | Postgres TCP port. | Default: `5432`. | +| `POSTGRES_USER_READ_ONLY` | string | | Postgres role used for read-only queries from the SQL editor. | Default: `supabase_read_only_user`. Only takes effect if you've manually created the role per the "remove superuser access" guide. | +| `POSTGRES_USER_READ_WRITE` | string | Both | Postgres role used for read/write queries from the SQL editor. | Default: `supabase_admin`. Commented out in default compose. See "remove superuser access" guide. | +| `STUDIO_PG_META_URL` | URL | Both | URL of the `postgres-meta` service used for schema introspection. | E.g. `http://meta:8080`. Required. | +| `SUPABASE_PUBLIC_URL` | URL | Both | Public URL of the Supabase stack (Kong gateway) as seen by end users. | Used to construct REST API URLs and connection strings shown in the dashboard. | +| `SUPABASE_URL` | URL | Both | Internal URL Studio uses to reach Kong from inside the Docker network. | E.g. `http://kong:8000`. | + +### Auth / JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AUTH_JWT_SECRET` | string | Both | HS256 JWT secret used to mint/verify legacy API keys; surfaced to the UI for "JWT settings" and PostgREST config. | Mapped from `JWT_SECRET` in `.env.example`. Must be at least 32 characters. | +| `SUPABASE_ANON_KEY` | string | Both | Anon API key surfaced in the Project API Settings page and used by in-dashboard clients. | Mapped from `ANON_KEY`. Supports `_FILE` suffix for Docker secrets. | +| `SUPABASE_SERVICE_KEY` | string | Both | Service-role API key surfaced in the Project API Settings page. | Mapped from `SERVICE_ROLE_KEY`. Supports `_FILE` suffix for Docker secrets. Keep secret. | + +### PG Meta + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PG_META_CRYPTO_KEY` | string | Self-hosted | Encryption key used by Studio's pg-meta routes to encrypt sensitive values (vault, foreign-server credentials) before storing them. | Falls back to `SAMPLE_KEY` if unset - set this to a random 32+ char string. | + +### PostgREST passthrough + +These mirror the running PostgREST configuration so the dashboard can display correct settings on the "API" page. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGRST_DB_EXTRA_SEARCH_PATH` | string (CSV) | Both | Extra Postgres schemas added to `search_path` for every PostgREST request. | Default: `public`. | +| `PGRST_DB_MAX_ROWS` | integer (count) | Both | Maximum rows returned by any single PostgREST request. | Default: `1000`. | +| `PGRST_DB_SCHEMAS` | string (CSV) | Both | Comma-separated list of schemas exposed via PostgREST. | Default: `public,storage,graphql_public`. Also used as the list of "Exposed schemas" in the API settings UI. | + +### Analytics / Logflare + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_API_KEY` | string | Self-hosted | Legacy alias for `LOGFLARE_PUBLIC_ACCESS_TOKEN`. | Deprecated. Declared in `apps/studio/turbo.jsonc` but not read by Studio code; kept only for backward compatibility with older deployments. | +| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | string | Both | Private API token Studio uses server-side to query Logflare endpoints (logs, charts). | Required for logs/analytics features to work on self-hosted. | +| `LOGFLARE_PUBLIC_ACCESS_TOKEN` | string | Self-hosted | Public API token used by the analytics (supabase/logflare) container for ingestion. | Not read by Studio code (despite being in `apps/studio/turbo.jsonc`). Passed through the `studio` service env in `docker-compose.yml` for parity only. | +| `LOGFLARE_URL` | URL | Both | Base URL of the Logflare/analytics service. | E.g. `http://analytics:4000`. Used to build the `PROJECT_ANALYTICS_URL`. | +| `NEXT_ANALYTICS_BACKEND_PROVIDER` | enum | Both | Historically intended to select the analytics container's backend (`postgres` or `bigquery`). | No-op today: not read by Studio code, and the `analytics` (supabase/logflare) container chooses its backend via `POSTGRES_BACKEND_URL` / `LOGFLARE_FEATURE_FLAG_OVERRIDE` instead. Safe to ignore. | +| `NEXT_PUBLIC_ENABLE_LOGS` | boolean | Both | Historically intended to toggle visibility of log explorer pages. | Not read by Studio code today, and not declared in `apps/studio/turbo.jsonc`. Use `ENABLED_FEATURES_LOGS_ALL` (see Feature flags below) for runtime control of the logs section. | + +### Feature flags (runtime overrides) + +Self-hosted Studio reads `ENABLED_FEATURES_*` env vars at container start time to disable or re-enable individual feature flags without rebuilding the image. The mapping rule is: uppercase the feature key from `packages/common/enabled-features/enabled-features.json` and replace every non-alphanumeric character with `_` (e.g. `logs:all` → `ENABLED_FEATURES_LOGS_ALL`). See `packages/common/enabled-features/README.md` for the full mechanism and the canonical flag list (~90 flags). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLED_FEATURES_*` | boolean | | Per-flag runtime override. Set to `true` or `false` (case-insensitive); other values are logged and ignored. | One env var per flag. Full key list: `packages/common/enabled-features/enabled-features.json`. No-op when `NEXT_PUBLIC_IS_PLATFORM=true`. | +| `ENABLED_FEATURES_LOGS_ALL` | boolean | | Disable the entire Logs section of the dashboard. Maps to the `logs:all` feature flag. | Documented explicitly as the runtime replacement for the legacy build-time `NEXT_PUBLIC_ENABLE_LOGS`. | + +### AI features + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `OPENAI_API_KEY` | string | Both | OpenAI API key used by the AI Assistant and SQL generator. | Optional; AI panel is disabled if unset. | + +### Edge Functions / Snippets management + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` | path | Both | Filesystem directory inside the container where edge function source is read from / written to when using the dashboard editor. | Mounted as a volume in `docker-compose.yml` (`./volumes/functions:/app/edge-functions`). | +| `SNIPPETS_MANAGEMENT_FOLDER` | path | Both | Filesystem directory inside the container where SQL editor snippets are persisted. | Mounted as a volume in `docker-compose.yml` (`./volumes/snippets:/app/snippets`). | + +### Platform flags / runtime mode + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `CURRENT_CLI_VERSION` | string | CLI | Version string set when Studio is started by the Supabase CLI. | Renames the default project to "Supabase Studio (CLI)" when set. Exposed to client via Next.js passthrough. | +| `NEXT_PUBLIC_IS_PLATFORM` | boolean | | Master switch: `"true"` runs Studio in hosted (multi-project) mode, anything else runs in self-hosted single-project mode. | Self-hosted images are **built** with this unset/`false`. Exposed to client. Setting this to `true` in a self-hosted deployment will break the dashboard. | +| `NEXT_PUBLIC_NODE_ENV` | string | | Marks the build as a test build (used by E2E setup). | Set to `test` only by `generateLocalEnv.js`. Exposed to client. | +| `NODE_ENV` | enum | Both | Standard Node.js environment (`development` / `production` / `test`). | Set automatically by Next.js. | + +--- + +## Auth + +> Auth (gotrue) uses Go's [envconfig](https://github.com/kelseyhightower/envconfig) library - the env var names are programmatically derived from the `Configuration` struct in `internal/conf/configuration.go` by combining the `GOTRUE_` prefix with each nested struct's path. Aliased fields are reachable via two names: the prefixed form (`GOTRUE_API_API_EXTERNAL_URL`) and a bare-name fallback (`API_EXTERNAL_URL`). + +> Auth's upstream `README.md` documents many of these variables with additional prose context - operator/Netlify history, default email-template bodies, OAuth provider examples, glob-matching syntax. The rows below stay reference-style; for prose backstory and template defaults, see [supabase/auth/README.md](https://github.com/supabase/auth/blob/master/README.md). + +### API + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `API_EXTERNAL_URL` | URL | Both | Externally reachable URL of the Auth API; used in emails, OAuth callbacks, SAML, etc. | Required. Alias of `GOTRUE_API_API_EXTERNAL_URL` | +| `GOTRUE_API_API_EXTERNAL_URL` | URL | | Externally reachable URL of the Auth API (prefixed form). | Required. Same field as `API_EXTERNAL_URL` | +| `GOTRUE_API_ENDPOINT` | string | | Override of the API endpoint base. | | +| `GOTRUE_API_HOST` | string | Both | Bind address for the API server. | | +| `GOTRUE_API_MAX_REQUEST_DURATION` | string (duration) | | Maximum total duration of a single API request. | Default: `10s` | +| `GOTRUE_API_PORT` | integer | Both | TCP port for the API server. | Default: `8081`. Alias of `PORT` | +| `PORT` | integer (count) | | TCP port for the API server (bare alias). | Default: `8081` | +| `GOTRUE_API_REQUEST_ID_HEADER` | string | | HTTP header name to read the request ID from. | Alias of `REQUEST_ID_HEADER` | +| `REQUEST_ID_HEADER` | string | | HTTP header name to read the request ID from (bare alias). | | + +### Database + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DATABASE_URL` | string | | Database connection string (bare alias). | Required. Alias of `GOTRUE_DB_DATABASE_URL` | +| `GOTRUE_DB_ADVISOR_ENABLED` | boolean | | Enables the DB connection-pool advisor. | Default: `true` | +| `GOTRUE_DB_ADVISOR_OBSERVATION_INTERVAL` | string (duration) | | Observation window length for the DB advisor. | Default: `20s` | +| `GOTRUE_DB_ADVISOR_SAMPLING_INTERVAL` | string (duration) | | Sampling interval for the DB advisor. | Default: `200ms` | +| `GOTRUE_DB_CLEANUP_ENABLED` | boolean | | Enables periodic cleanup of expired auth rows. | Default: `false` | +| `GOTRUE_DB_CONN_MAX_IDLE_TIME` | string (duration) | | Max time a DB connection may sit idle. | | +| `GOTRUE_DB_CONN_MAX_LIFETIME` | string (duration) | | Max lifetime of a DB connection. | | +| `GOTRUE_DB_CONN_PERCENTAGE` | integer (percent) | | Percentage of available DB connections the Auth server may use (1-100). | | +| `GOTRUE_DB_DATABASE_URL` | string | Both | Database connection string. | Required. Alias of `DATABASE_URL` | +| `GOTRUE_DB_DB_NAMESPACE` | string | | Database schema to use (prefixed alias). | Default: `auth` | +| `DB_NAMESPACE` | string | | Database schema to use (bare alias). | Default: `auth` | +| `GOTRUE_DB_DRIVER` | string | Both | Database driver name. | Required. Typically `postgres` | +| `GOTRUE_DB_HEALTH_CHECK_PERIOD` | string (duration) | | Interval between DB connection health checks. | | +| `GOTRUE_DB_MAX_IDLE_POOL_SIZE` | integer (count) | | Maximum number of idle DB connections. | | +| `GOTRUE_DB_MAX_POOL_SIZE` | integer (count) | | Maximum total DB connections (0 = unlimited). | | +| `GOTRUE_DB_MIGRATIONS_PATH` | path | CLI | Filesystem path containing migration SQL files. | Default: `./migrations` | + +### JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_JWT_ADMIN_GROUP_NAME` | string | | Group claim value treated as admin. | Default: `admin` | +| `GOTRUE_JWT_ADMIN_ROLES` | string (CSV) | Both | Comma-separated roles treated as admin. | Default: `service_role,supabase_admin` | +| `GOTRUE_JWT_AUD` | string | Both | Default `aud` claim for issued JWTs. | | +| `GOTRUE_JWT_DEFAULT_GROUP_NAME` | string | Both | Default group assigned to users. | | +| `GOTRUE_JWT_EXP` | integer (seconds) | Both | Access token lifetime in seconds. | Default: `3600` | +| `GOTRUE_JWT_ISSUER` | string | Both | `iss` claim for issued JWTs. | | +| `GOTRUE_JWT_KEY_ID` | string | | Key ID assigned to the symmetric secret key. | | +| `GOTRUE_JWT_KEYS` | JWKS | Both | JSON array of JWKs used for signing/verification. | Required when using the new API keys and new auth. | +| `GOTRUE_JWT_SECRET` | string | Both | Symmetric HS256 signing secret. | Required | +| `GOTRUE_JWT_VALID_METHODS` | string (CSV) | CLI | Allowed JWT signing methods (e.g. `HS256,RS256`). | | +| `GOTRUE_JWT_VALIDMETHODS` | string (CSV) | CLI | Alternate spelling seen in CLI; same field. | Alias artifact; prefer `GOTRUE_JWT_VALID_METHODS` | + +### Site / Redirect + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_DISABLE_SIGNUP` | boolean | Both | Disable new user signups. | | +| `GOTRUE_SITE_URL` | URL | Both | Primary site URL used in email/redirect defaults. | Required | +| `GOTRUE_URI_ALLOW_LIST` | string (CSV) | Both | Comma-separated list of allowed redirect URIs (supports glob). | | + +### Email / SMTP + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MAILER_ALLOW_UNVERIFIED_EMAIL_SIGN_INS` | boolean | | Allow sign in before email is confirmed. | Default: `false` | +| `GOTRUE_MAILER_AUTOCONFIRM` | boolean | Both | Skip email confirmation flow. | | +| `GOTRUE_MAILER_EMAIL_BACKGROUND_SENDING` | boolean | | Send emails in background (experimental). | Default: `false` | +| `GOTRUE_MAILER_EMAIL_VALIDATION_BLOCKED_MX` | JSON | | JSON array of blocked MX records for email validation. | Experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_EXTENDED` | boolean | | Enable extended email validation (MX/SMTP). | Default: `false`, experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_SERVICE_HEADERS` | JSON | | JSON object of headers sent to email validation service. | Experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_SERVICE_URL` | URL | | External email-validation service URL. | Experimental | +| `GOTRUE_MAILER_EXTERNAL_HOSTS` | string (CSV) | | Additional hostnames allowed as the email-link host. | | +| `GOTRUE_MAILER_OTP_EXP` | integer (seconds) | CLI | OTP/email link expiry in seconds. | Default: `86400` | +| `GOTRUE_MAILER_OTP_LENGTH` | integer (count) | CLI | OTP code length (6-10). | Default: `6` | +| `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | boolean | Both | Require confirmation on both old and new emails when changing. | Default: `true`, commented out in compose | +| `GOTRUE_MAILER_TEMPLATE_MAX_AGE` | string (duration) | | Max age of a cached email template before refresh. | Default: `10m` | +| `GOTRUE_MAILER_TEMPLATE_MAX_SIZE` | integer (bytes) | | Max template size in bytes pulled from a URL. | Default: `1000000` | +| `GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED` | boolean | CLI | Enable background reloading of email templates. | Default: `false` | +| `GOTRUE_MAILER_TEMPLATE_RELOADING_MAX_IDLE` | string (duration) | | Max idle time before stopping template reload loop. | Default: `20m` | +| `GOTRUE_MAILER_TEMPLATE_RETRY_INTERVAL` | string (duration) | | Retry interval for failed template reloads. | Default: `10s` | +| `GOTRUE_SMTP_ADMIN_EMAIL` | string | Both | From address used as `admin_email`. | | +| `GOTRUE_SMTP_HEADERS` | JSON | | JSON object of extra headers added to outgoing emails. | | +| `GOTRUE_SMTP_HOST` | string | Both | SMTP relay hostname. | | +| `GOTRUE_SMTP_LOGGING_ENABLED` | boolean | | Verbose SMTP debug logging. | Default: `false` | +| `GOTRUE_SMTP_MAX_FREQUENCY` | string (duration) | Both | Minimum interval between emails per address. | Default: `1m`, commented out in compose | +| `GOTRUE_SMTP_PASS` | string | Self-hosted | SMTP password. | | +| `GOTRUE_SMTP_PORT` | integer | Both | SMTP relay port. | Default: `587` | +| `GOTRUE_SMTP_SENDER_NAME` | string | Both | From name displayed on emails. Falls back to `GOTRUE_SMTP_ADMIN_EMAIL` when unset. | | +| `GOTRUE_SMTP_USER` | string | Self-hosted | SMTP username. | | + +### Mailer notifications / subjects / templates / URL paths + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED` | boolean | | Send notification when email changes. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED` | boolean | | Send notification when an identity is linked. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED` | boolean | | Send notification when an identity is unlinked. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED` | boolean | | Send notification when an MFA factor is enrolled. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED` | boolean | | Send notification when an MFA factor is removed. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED` | boolean | | Send notification when password changes. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED` | boolean | | Send notification when phone changes. | Default: `false` | +| `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | string | | Subject for the confirmation email. | | +| `GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE` | string | | Subject for the email-change email. | | +| `GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION` | string | | Subject for the email-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION` | string | | Subject for the identity-linked notification. | | +| `GOTRUE_MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION` | string | | Subject for the identity-unlinked notification. | | +| `GOTRUE_MAILER_SUBJECTS_INVITE` | string | | Subject for the invite email. | | +| `GOTRUE_MAILER_SUBJECTS_MAGIC_LINK` | string | | Subject for the magic-link email. | | +| `GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | Subject for the MFA-enrolled notification. | | +| `GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | Subject for the MFA-unenrolled notification. | | +| `GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION` | string | | Subject for the password-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION` | string | | Subject for the phone-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_REAUTHENTICATION` | string | | Subject for the reauthentication email. | | +| `GOTRUE_MAILER_SUBJECTS_RECOVERY` | string | | Subject for the password-recovery email. | | +| `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` | string | | URL to the confirmation email template. | | +| `GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE` | string | | URL to the email-change email template. | | +| `GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION` | string | | URL to the email-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION` | string | | URL to the identity-linked notification template. | | +| `GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION` | string | | URL to the identity-unlinked notification template. | | +| `GOTRUE_MAILER_TEMPLATES_INVITE` | string | | URL to the invite email template. | | +| `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK` | string | | URL to the magic-link email template. | | +| `GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | URL to the MFA-enrolled notification template. | | +| `GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | URL to the MFA-unenrolled notification template. | | +| `GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` | string | | URL to the password-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION` | string | | URL to the phone-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_REAUTHENTICATION` | string | | URL to the reauthentication email template. | | +| `GOTRUE_MAILER_TEMPLATES_RECOVERY` | string | | URL to the password-recovery email template. | | +| `GOTRUE_MAILER_URLPATHS_CONFIRMATION` | string | Both | URL path appended to the email confirmation link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE` | string | Both | URL path appended to the email-change link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_EMAIL_CHANGED_NOTIFICATION` | string | | URL path for the email-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_IDENTITY_LINKED_NOTIFICATION` | string | | URL path for the identity-linked notification link. | | +| `GOTRUE_MAILER_URLPATHS_IDENTITY_UNLINKED_NOTIFICATION` | string | | URL path for the identity-unlinked notification link. | | +| `GOTRUE_MAILER_URLPATHS_INVITE` | string | Both | URL path appended to the invite link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_MAGIC_LINK` | string | | URL path for the magic-link redirect. | | +| `GOTRUE_MAILER_URLPATHS_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | URL path for the MFA-enrolled notification link. | | +| `GOTRUE_MAILER_URLPATHS_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | URL path for the MFA-unenrolled notification link. | | +| `GOTRUE_MAILER_URLPATHS_PASSWORD_CHANGED_NOTIFICATION` | string | | URL path for the password-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_PHONE_CHANGED_NOTIFICATION` | string | | URL path for the phone-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_REAUTHENTICATION` | string | | URL path for the reauthentication link. | | +| `GOTRUE_MAILER_URLPATHS_RECOVERY` | string | Both | URL path appended to the recovery link. | Default: `/verify` | + +### External OAuth providers + +The fields below are repeated for each provider. Substitute `` with one of: `APPLE`, `AZURE`, `BITBUCKET`, `DISCORD`, `FACEBOOK`, `FIGMA`, `FLY`, `GITHUB`, `GITLAB`, `GOOGLE`, `KAKAO`, `KEYCLOAK`, `LINKEDIN`, `LINKEDIN_OIDC`, `NOTION`, `SLACK`, `SLACK_OIDC`, `SNAPCHAT`, `SPOTIFY`, `TWITCH`, `TWITTER`, `VERCEL_MARKETPLACE`, `WORKOS`, `X`, `ZOOM`. Each provider supports: `_ENABLED`, `_CLIENT_ID`, `_SECRET`, `_REDIRECT_URI`, `_URL`, `_API_URL`, `_SKIP_NONCE_CHECK`, `_EMAIL_OPTIONAL`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_ALLOWED_ID_TOKEN_ISSUERS` | string (CSV) | | Additional issuers accepted when verifying external ID tokens. | Defaults include `https://appleid.apple.com`, `https://accounts.google.com` | +| `GOTRUE_EXTERNAL_APPLE_API_URL` | URL | | Override Apple OAuth API endpoint. | | +| `GOTRUE_EXTERNAL_APPLE_CLIENT_ID` | string (CSV) | CLI | Apple OAuth client ID(s) (comma-separated). | | +| `GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL` | boolean | CLI | Allow accounts without an email from Apple. | | +| `GOTRUE_EXTERNAL_APPLE_ENABLED` | boolean | CLI | Enable the Apple provider. | | +| `GOTRUE_EXTERNAL_APPLE_REDIRECT_URI` | URL | CLI | Override redirect URI for Apple. | | +| `GOTRUE_EXTERNAL_APPLE_SECRET` | string | CLI | Apple OAuth client secret. | | +| `GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK` | boolean | CLI | Skip OIDC nonce check for Apple. | | +| `GOTRUE_EXTERNAL_APPLE_URL` | URL | | Override Apple OAuth base URL. | | +| `GOTRUE_EXTERNAL_AZURE_API_URL` | URL | | Override Azure API endpoint. | | +| `GOTRUE_EXTERNAL_AZURE_CLIENT_ID` | string (CSV) | Self-hosted | Azure OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Azure. | | +| `GOTRUE_EXTERNAL_AZURE_ENABLED` | boolean | Self-hosted | Enable the Azure provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for Azure. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_SECRET` | string | Self-hosted | Azure OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Azure. | | +| `GOTRUE_EXTERNAL_AZURE_URL` | URL | | Override Azure OAuth base URL. | | +| `GOTRUE_EXTERNAL_BITBUCKET_API_URL` | URL | | Override Bitbucket API endpoint. | | +| `GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID` | string | | Bitbucket OAuth client ID. | | +| `GOTRUE_EXTERNAL_BITBUCKET_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_ENABLED` | boolean | | Enable the Bitbucket provider. | | +| `GOTRUE_EXTERNAL_BITBUCKET_REDIRECT_URI` | URL | | Override redirect URI for Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_SECRET` | string | | Bitbucket OAuth client secret. | | +| `GOTRUE_EXTERNAL_BITBUCKET_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_URL` | URL | | Override Bitbucket OAuth base URL. | | +| `GOTRUE_EXTERNAL_DISCORD_API_URL` | URL | | Override Discord API endpoint. | | +| `GOTRUE_EXTERNAL_DISCORD_CLIENT_ID` | string | | Discord OAuth client ID. | | +| `GOTRUE_EXTERNAL_DISCORD_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_ENABLED` | boolean | | Enable the Discord provider. | | +| `GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI` | URL | | Override redirect URI for Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_SECRET` | string | | Discord OAuth client secret. | | +| `GOTRUE_EXTERNAL_DISCORD_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_URL` | URL | | Override Discord OAuth base URL. | | +| `GOTRUE_EXTERNAL_EMAIL_AUTHORIZED_ADDRESSES` | string (CSV) | | Restrict email signup to a list of allowed addresses/domains. | | +| `GOTRUE_EXTERNAL_EMAIL_ENABLED` | boolean | Both | Enable email/password authentication. When disabled, OAuth providers can still be used to sign up / sign in. | Default: `true` | +| `GOTRUE_EXTERNAL_EMAIL_MAGIC_LINK_ENABLED` | boolean | | Enable email magic links. | Default: `true` | +| `GOTRUE_EXTERNAL_FACEBOOK_API_URL` | URL | | Override Facebook API endpoint. | | +| `GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID` | string | | Facebook OAuth client ID. | | +| `GOTRUE_EXTERNAL_FACEBOOK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_ENABLED` | boolean | | Enable the Facebook provider. | | +| `GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI` | URL | | Override redirect URI for Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_SECRET` | string | | Facebook OAuth client secret. | | +| `GOTRUE_EXTERNAL_FACEBOOK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_URL` | URL | | Override Facebook OAuth base URL. | | +| `GOTRUE_EXTERNAL_FIGMA_API_URL` | URL | | Override Figma API endpoint. | | +| `GOTRUE_EXTERNAL_FIGMA_CLIENT_ID` | string | | Figma OAuth client ID. | | +| `GOTRUE_EXTERNAL_FIGMA_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_ENABLED` | boolean | | Enable the Figma provider. | | +| `GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI` | URL | | Override redirect URI for Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_SECRET` | string | | Figma OAuth client secret. | | +| `GOTRUE_EXTERNAL_FIGMA_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_URL` | URL | | Override Figma OAuth base URL. | | +| `GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION` | string (duration) | | Lifetime of the PKCE flow state. | Default: `5m` (minimum enforced) | +| `GOTRUE_EXTERNAL_FLY_API_URL` | URL | | Override Fly.io API endpoint. | | +| `GOTRUE_EXTERNAL_FLY_CLIENT_ID` | string | | Fly.io OAuth client ID. | | +| `GOTRUE_EXTERNAL_FLY_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_ENABLED` | boolean | | Enable the Fly.io provider. | | +| `GOTRUE_EXTERNAL_FLY_REDIRECT_URI` | URL | | Override redirect URI for Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_SECRET` | string | | Fly.io OAuth client secret. | | +| `GOTRUE_EXTERNAL_FLY_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_URL` | URL | | Override Fly.io OAuth base URL. | | +| `GOTRUE_EXTERNAL_GITHUB_API_URL` | URL | | Override GitHub API endpoint. | | +| `GOTRUE_EXTERNAL_GITHUB_CLIENT_ID` | string | Self-hosted | GitHub OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from GitHub. | | +| `GOTRUE_EXTERNAL_GITHUB_ENABLED` | boolean | Self-hosted | Enable the GitHub provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for GitHub. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_SECRET` | string | Self-hosted | GitHub OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for GitHub. | | +| `GOTRUE_EXTERNAL_GITHUB_URL` | URL | | Override GitHub OAuth base URL. | | +| `GOTRUE_EXTERNAL_GITLAB_API_URL` | URL | | Override GitLab API endpoint. | | +| `GOTRUE_EXTERNAL_GITLAB_CLIENT_ID` | string | | GitLab OAuth client ID. | | +| `GOTRUE_EXTERNAL_GITLAB_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_ENABLED` | boolean | | Enable the GitLab provider. | | +| `GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI` | URL | | Override redirect URI for GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_SECRET` | string | | GitLab OAuth client secret. | | +| `GOTRUE_EXTERNAL_GITLAB_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_URL` | URL | | Override GitLab OAuth base URL. | | +| `GOTRUE_EXTERNAL_GOOGLE_API_URL` | URL | | Override Google API endpoint. | | +| `GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID` | string | Self-hosted | Google OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Google. | | +| `GOTRUE_EXTERNAL_GOOGLE_ENABLED` | boolean | Self-hosted | Enable the Google provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for Google. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_SECRET` | string | Self-hosted | Google OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Google. | | +| `GOTRUE_EXTERNAL_GOOGLE_URL` | URL | | Override Google OAuth base URL. | | +| `GOTRUE_EXTERNAL_IOS_BUNDLE_ID` | string | | Apple iOS bundle ID for the Apple provider. | | +| `GOTRUE_EXTERNAL_KAKAO_API_URL` | URL | | Override Kakao API endpoint. | | +| `GOTRUE_EXTERNAL_KAKAO_CLIENT_ID` | string | | Kakao OAuth client ID. | | +| `GOTRUE_EXTERNAL_KAKAO_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_ENABLED` | boolean | | Enable the Kakao provider. | | +| `GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI` | URL | | Override redirect URI for Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_SECRET` | string | | Kakao OAuth client secret. | | +| `GOTRUE_EXTERNAL_KAKAO_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_URL` | URL | | Override Kakao OAuth base URL. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_API_URL` | URL | | Override Keycloak API endpoint. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID` | string | | Keycloak OAuth client ID. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_ENABLED` | boolean | | Enable the Keycloak provider. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI` | URL | | Override redirect URI for Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` | string | | Keycloak OAuth client secret. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_URL` | URL | | Override Keycloak OAuth base URL (realm URL). | | +| `GOTRUE_EXTERNAL_LINKEDIN_API_URL` | URL | | Override LinkedIn API endpoint. | | +| `GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID` | string | | LinkedIn OAuth client ID. | | +| `GOTRUE_EXTERNAL_LINKEDIN_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_ENABLED` | boolean | | Enable the legacy LinkedIn provider. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_API_URL` | URL | | Override LinkedIn OIDC API endpoint. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID` | string | | LinkedIn OIDC client ID. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_ENABLED` | boolean | | Enable the LinkedIn OIDC provider. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_REDIRECT_URI` | URL | | Override redirect URI for LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_SECRET` | string | | LinkedIn OIDC client secret. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_URL` | URL | | Override LinkedIn OIDC base URL. | | +| `GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI` | URL | | Override redirect URI for LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_SECRET` | string | | LinkedIn OAuth client secret. | | +| `GOTRUE_EXTERNAL_LINKEDIN_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_URL` | URL | | Override LinkedIn OAuth base URL. | | +| `GOTRUE_EXTERNAL_NOTION_API_URL` | URL | | Override Notion API endpoint. | | +| `GOTRUE_EXTERNAL_NOTION_CLIENT_ID` | string | | Notion OAuth client ID. | | +| `GOTRUE_EXTERNAL_NOTION_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Notion. | | +| `GOTRUE_EXTERNAL_NOTION_ENABLED` | boolean | | Enable the Notion provider. | | +| `GOTRUE_EXTERNAL_NOTION_REDIRECT_URI` | URL | | Override redirect URI for Notion. | | +| `GOTRUE_EXTERNAL_NOTION_SECRET` | string | | Notion OAuth client secret. | | +| `GOTRUE_EXTERNAL_NOTION_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Notion. | | +| `GOTRUE_EXTERNAL_NOTION_URL` | URL | | Override Notion OAuth base URL. | | +| `GOTRUE_EXTERNAL_OIDC_PROVIDER_CACHE_TTL` | string (duration) | | Cache lifetime for OIDC discovery documents. | Default: `1h` | +| `GOTRUE_EXTERNAL_REDIRECT_URL` | URL | | Global override of OAuth redirect URL. | | +| `GOTRUE_EXTERNAL_SKIP_NONCE_CHECK` | string | Self-hosted | Listed (commented) in compose but does not map to a configuration field. | Commented out in compose; no effect - use per-provider `*_SKIP_NONCE_CHECK` | +| `GOTRUE_EXTERNAL_SLACK_API_URL` | URL | | Override Slack API endpoint. | | +| `GOTRUE_EXTERNAL_SLACK_CLIENT_ID` | string | | Legacy Slack OAuth client ID. | | +| `GOTRUE_EXTERNAL_SLACK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Slack. | | +| `GOTRUE_EXTERNAL_SLACK_ENABLED` | boolean | | Enable the legacy Slack provider. | Prefer `SLACK_OIDC` | +| `GOTRUE_EXTERNAL_SLACK_OIDC_API_URL` | URL | | Override Slack OIDC API endpoint. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID` | string | | Slack OIDC client ID. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_ENABLED` | boolean | | Enable the Slack OIDC provider. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_REDIRECT_URI` | URL | | Override redirect URI for Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_SECRET` | string | | Slack OIDC client secret. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_URL` | URL | | Override Slack OIDC base URL. | | +| `GOTRUE_EXTERNAL_SLACK_REDIRECT_URI` | URL | | Override redirect URI for Slack. | | +| `GOTRUE_EXTERNAL_SLACK_SECRET` | string | | Legacy Slack OAuth client secret. | | +| `GOTRUE_EXTERNAL_SLACK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Slack. | | +| `GOTRUE_EXTERNAL_SLACK_URL` | URL | | Override Slack OAuth base URL. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_API_URL` | URL | | Override Snapchat API endpoint. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_CLIENT_ID` | string | | Snapchat OAuth client ID. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_ENABLED` | boolean | | Enable the Snapchat provider. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_REDIRECT_URI` | URL | | Override redirect URI for Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_SECRET` | string | | Snapchat OAuth client secret. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_URL` | URL | | Override Snapchat OAuth base URL. | | +| `GOTRUE_EXTERNAL_SPOTIFY_API_URL` | URL | | Override Spotify API endpoint. | | +| `GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID` | string | | Spotify OAuth client ID. | | +| `GOTRUE_EXTERNAL_SPOTIFY_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_ENABLED` | boolean | | Enable the Spotify provider. | | +| `GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI` | URL | | Override redirect URI for Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_SECRET` | string | | Spotify OAuth client secret. | | +| `GOTRUE_EXTERNAL_SPOTIFY_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_URL` | URL | | Override Spotify OAuth base URL. | | +| `GOTRUE_EXTERNAL_TWITCH_API_URL` | URL | | Override Twitch API endpoint. | | +| `GOTRUE_EXTERNAL_TWITCH_CLIENT_ID` | string | | Twitch OAuth client ID. | | +| `GOTRUE_EXTERNAL_TWITCH_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_ENABLED` | boolean | | Enable the Twitch provider. | | +| `GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI` | URL | | Override redirect URI for Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_SECRET` | string | | Twitch OAuth client secret. | | +| `GOTRUE_EXTERNAL_TWITCH_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_URL` | URL | | Override Twitch OAuth base URL. | | +| `GOTRUE_EXTERNAL_TWITTER_API_URL` | URL | | Override Twitter API endpoint. | | +| `GOTRUE_EXTERNAL_TWITTER_CLIENT_ID` | string | | Twitter OAuth client ID. | | +| `GOTRUE_EXTERNAL_TWITTER_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_ENABLED` | boolean | | Enable the Twitter provider. | | +| `GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI` | URL | | Override redirect URI for Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_SECRET` | string | | Twitter OAuth client secret. | | +| `GOTRUE_EXTERNAL_TWITTER_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_URL` | URL | | Override Twitter OAuth base URL. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_API_URL` | URL | | Override Vercel Marketplace API endpoint. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_CLIENT_ID` | string | | Vercel Marketplace OAuth client ID. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_ENABLED` | boolean | | Enable the Vercel Marketplace provider. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_REDIRECT_URI` | URL | | Override redirect URI for Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_SECRET` | string | | Vercel Marketplace OAuth client secret. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_URL` | URL | | Override Vercel Marketplace OAuth base URL. | | +| `GOTRUE_EXTERNAL_WORKOS_API_URL` | URL | | Override WorkOS API endpoint. | | +| `GOTRUE_EXTERNAL_WORKOS_CLIENT_ID` | string | | WorkOS OAuth client ID. | | +| `GOTRUE_EXTERNAL_WORKOS_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_ENABLED` | boolean | | Enable the WorkOS provider. | | +| `GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI` | URL | | Override redirect URI for WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_SECRET` | string | | WorkOS OAuth client secret. | | +| `GOTRUE_EXTERNAL_WORKOS_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_URL` | URL | | Override WorkOS OAuth base URL. | | +| `GOTRUE_EXTERNAL_X_API_URL` | URL | | Override X (Twitter) API endpoint. | | +| `GOTRUE_EXTERNAL_X_CLIENT_ID` | string | | X (Twitter) OAuth client ID. | | +| `GOTRUE_EXTERNAL_X_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from X. | | +| `GOTRUE_EXTERNAL_X_ENABLED` | boolean | | Enable the X (Twitter) provider. | | +| `GOTRUE_EXTERNAL_X_REDIRECT_URI` | URL | | Override redirect URI for X. | | +| `GOTRUE_EXTERNAL_X_SECRET` | string | | X (Twitter) OAuth client secret. | | +| `GOTRUE_EXTERNAL_X_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for X. | | +| `GOTRUE_EXTERNAL_X_URL` | URL | | Override X (Twitter) OAuth base URL. | | +| `GOTRUE_EXTERNAL_ZOOM_API_URL` | URL | | Override Zoom API endpoint. | | +| `GOTRUE_EXTERNAL_ZOOM_CLIENT_ID` | string | | Zoom OAuth client ID. | | +| `GOTRUE_EXTERNAL_ZOOM_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_ENABLED` | boolean | | Enable the Zoom provider. | | +| `GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI` | URL | | Override redirect URI for Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_SECRET` | string | | Zoom OAuth client secret. | | +| `GOTRUE_EXTERNAL_ZOOM_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_URL` | URL | | Override Zoom OAuth base URL. | | + +### Anonymous + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED` | boolean | Both | Enable anonymous user signup. | Default: `false` | + +### Custom OAuth / OAuth Server + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_CUSTOM_OAUTH_ENABLED` | boolean | | Enable user-defined custom OAuth/OIDC providers. | Default: `true` | +| `GOTRUE_CUSTOM_OAUTH_MAX_PROVIDERS` | integer (count) | | Maximum number of custom providers allowed. | Default: `0` (unlimited) | +| `GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION` | boolean | | Allow dynamic client registration on the OAuth server. | | +| `GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH` | string | | Path prefix for the OAuth authorization endpoint. | | +| `GOTRUE_OAUTH_SERVER_AUTHORIZATION_TTL` | string (duration) | | Lifetime of an authorization code. | Default: `10m` | +| `GOTRUE_OAUTH_SERVER_DEFAULT_SCOPE` | string | | Default scope returned to clients. | Default: `email` | +| `GOTRUE_OAUTH_SERVER_ENABLED` | boolean | | Enable the built-in OAuth authorization server. | Default: `false` | + +### Phone / SMS + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_PHONE_ENABLED` | boolean | Both | Enable phone-based authentication. | Default: `false` | +| `GOTRUE_SMS_AUTOCONFIRM` | boolean | Both | Skip phone verification flow. | | +| `GOTRUE_SMS_MAX_FREQUENCY` | string (duration) | Both | Minimum interval between SMS messages per phone. | Default: `1m`, commented out in compose | +| `GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY` | string | | Messagebird API access key. | | +| `GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR` | string | | Messagebird originator (sender ID). | | +| `GOTRUE_SMS_OTP_EXP` | integer (seconds) | Both | SMS OTP expiry in seconds. | Default: `60`, commented out in compose | +| `GOTRUE_SMS_OTP_LENGTH` | integer (count) | Both | SMS OTP code length (6-10). | Default: `6`, commented out in compose | +| `GOTRUE_SMS_PROVIDER` | string | Self-hosted | SMS provider name (`twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`). | Commented out in compose | +| `GOTRUE_SMS_TEMPLATE` | string | Both | Message template for SMS OTP. | Commented out in compose | +| `GOTRUE_SMS_TEST_OTP` | JSON | Both | JSON map of phone-to-OTP overrides for testing. | Commented out in compose | +| `GOTRUE_SMS_TEST_OTP_VALID_UNTIL` | string | | Cutoff time after which test OTPs stop being accepted. | | +| `GOTRUE_SMS_TEXTLOCAL_API_KEY` | string | | Textlocal API key. | | +| `GOTRUE_SMS_TEXTLOCAL_SENDER` | string | | Textlocal sender ID. | | +| `GOTRUE_SMS_TWILIO_ACCOUNT_SID` | string | Self-hosted | Twilio account SID. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_AUTH_TOKEN` | string | Self-hosted | Twilio auth token. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_CONTENT_SID` | string | | Twilio content SID (template). | | +| `GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID` | string | Self-hosted | Twilio message service SID / phone number. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID` | string | | Twilio Verify account SID. | | +| `GOTRUE_SMS_TWILIO_VERIFY_AUTH_TOKEN` | string | | Twilio Verify auth token. | | +| `GOTRUE_SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID` | string | | Twilio Verify message service SID. | | +| `GOTRUE_SMS_VONAGE_API_KEY` | string | | Vonage API key. | | +| `GOTRUE_SMS_VONAGE_API_SECRET` | string | | Vonage API secret. | | +| `GOTRUE_SMS_VONAGE_FROM` | string | | Vonage `from` parameter (sender). | | + +### MFA + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MFA_CHALLENGE_EXPIRY_DURATION` | integer (seconds) | | Lifetime of an MFA challenge (seconds). | Default: `300` | +| `GOTRUE_MFA_FACTOR_EXPIRY_DURATION` | string (duration) | | Lifetime of an unverified MFA factor. | Default: `300s` | +| `GOTRUE_MFA_MAX_ENROLLED_FACTORS` | integer (count) | Both | Maximum factors a user may enroll. | Default: `10`, commented out in compose | +| `GOTRUE_MFA_MAX_VERIFIED_FACTORS` | integer (count) | | Maximum verified factors per user. | Default: `10` | +| `GOTRUE_MFA_PHONE_ENROLL_ENABLED` | boolean | Both | Allow enrolling a phone MFA factor. | Default: `false`, commented out in compose | +| `GOTRUE_MFA_PHONE_MAX_FREQUENCY` | string (duration) | | Minimum interval between MFA phone OTPs. | Default: `1m` | +| `GOTRUE_MFA_PHONE_OTP_LENGTH` | integer (count) | | Phone MFA OTP code length. | Default: `6` | +| `GOTRUE_MFA_PHONE_TEMPLATE` | string | | Template string for MFA phone OTP messages. | | +| `GOTRUE_MFA_PHONE_VERIFY_ENABLED` | boolean | Both | Allow verifying a phone MFA factor. | Default: `false`, commented out in compose | +| `GOTRUE_MFA_RATE_LIMIT_CHALLENGE_AND_VERIFY` | number | | Rate limit for MFA challenge + verify. | Default: `15` | +| `GOTRUE_MFA_TOTP_ENROLL_ENABLED` | boolean | Both | Allow enrolling a TOTP MFA factor. | Default: `true`, commented out in compose | +| `GOTRUE_MFA_TOTP_VERIFY_ENABLED` | boolean | Both | Allow verifying a TOTP MFA factor. | Default: `true`, commented out in compose | +| `GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED` | string | CLI | Allow enrolling a WebAuthn MFA factor. | Default: `false` | +| `GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED` | string | CLI | Allow verifying a WebAuthn MFA factor. | Default: `false` | + +### WebAuthn / Passkey + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_PASSKEY_ENABLED` | boolean | | Enable passkey (passwordless WebAuthn) authentication. | Default: `false` | +| `GOTRUE_PASSKEY_MAX_PASSKEYS_PER_USER` | integer (count) | | Maximum passkeys a user may register. | Default: `10` | +| `GOTRUE_WEBAUTHN_CHALLENGE_EXPIRY_DURATION` | string (duration) | | Lifetime of a WebAuthn challenge. | Default: `5m` | +| `GOTRUE_WEBAUTHN_RP_DISPLAY_NAME` | string | | WebAuthn relying party display name. | Required when WebAuthn/Passkey is enabled | +| `GOTRUE_WEBAUTHN_RP_ID` | string | | WebAuthn relying party ID (host). | Required when WebAuthn/Passkey is enabled. Alias of `RP_ID` | +| `RP_ID` | string | | WebAuthn relying party ID (bare alias). | | +| `GOTRUE_WEBAUTHN_RP_ORIGINS` | string (CSV) | | Allowed WebAuthn origins (https or http://localhost). | Required when WebAuthn/Passkey is enabled | + +### SAML + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS` | boolean | Self-hosted | Permit encrypted SAML assertions. | Commented out in compose | +| `GOTRUE_SAML_ENABLED` | boolean | Self-hosted | Enable SAML SSO. | Commented out in compose | +| `GOTRUE_SAML_EXTERNAL_URL` | URL | Self-hosted | External URL used in SAML metadata (defaults to `API_EXTERNAL_URL`). | Commented out in compose | +| `GOTRUE_SAML_PRIVATE_KEY` | string | Self-hosted | Base64-encoded PKCS#1 RSA private key (>= 2048 bits). | Commented out in compose | +| `GOTRUE_SAML_RATE_LIMIT_ASSERTION` | number | Self-hosted | Rate limit for SAML assertion submissions. | Default: `15`, commented out in compose | +| `GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD` | string (duration) | Self-hosted | Lifetime of SAML RelayState. | Default: `2m`, commented out in compose | + +### Hooks + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_HOOK_AFTER_USER_CREATED_ENABLED` | boolean | | Enable the after-user-created hook. | | +| `GOTRUE_HOOK_AFTER_USER_CREATED_SECRETS` | string | | Standard webhook secrets (pipe-separated) for after-user-created hook. | | +| `GOTRUE_HOOK_AFTER_USER_CREATED_URI` | string | | URI of the after-user-created hook (pg-functions or https). | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_ENABLED` | boolean | | Enable the before-user-created hook. | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_SECRETS` | string | | Standard webhook secrets for the before-user-created hook. | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_URI` | string | | URI of the before-user-created hook. | | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED` | boolean | Self-hosted | Enable the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS` | string | Self-hosted | Standard webhook secrets for the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI` | string | Self-hosted | URI of the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED` | boolean | Self-hosted | Enable the MFA verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_SECRETS` | string | | Standard webhook secrets for the MFA verification attempt hook. | | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI` | string | Self-hosted | URI of the MFA verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED` | boolean | Self-hosted | Enable the password verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_SECRETS` | string | | Standard webhook secrets for the password verification attempt hook. | | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI` | string | Self-hosted | URI of the password verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_ENABLED` | boolean | Self-hosted | Enable the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_SECRETS` | string | Self-hosted | Standard webhook secrets for the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_URI` | string | Self-hosted | URI of the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_ENABLED` | boolean | Self-hosted | Enable the send-SMS hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_SECRETS` | string | Self-hosted | Standard webhook secrets for the send-SMS hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_URI` | string | Self-hosted | URI of the send-SMS hook. | Commented out in compose | + +### Rate limits + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_RATE_LIMIT_ANONYMOUS_USERS` | number | CLI | Rate limit for anonymous user creation. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_EMAIL_SENT` | number | CLI | Rate limit for outgoing emails on `/signup`, `/invite`, `/magiclink`, `/recover`, `/otp`, `/user`. Accepts `n` or `n/duration` (burst). | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_HEADER` | string | | HTTP header used to rate-limit the `/token` endpoint (e.g. `X-Forwarded-For`). | | +| `GOTRUE_RATE_LIMIT_O_AUTH_DYNAMIC_CLIENT_REGISTER` | number | | Rate limit for OAuth dynamic client registration. | Default: `10` per hour | +| `GOTRUE_RATE_LIMIT_OTP` | number | CLI | Rate limit for OTP endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_PASSKEY` | number | | Rate limit for passkey endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_SMS_SENT` | number | CLI | Rate limit for outgoing SMS messages. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_SSO` | number | | Rate limit for SSO endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` | number | CLI | Rate limit for token refresh. | Default: `150` per hour | +| `GOTRUE_RATE_LIMIT_VERIFY` | number | CLI | Rate limit for the verify endpoint. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_WEB3` | number | CLI | Rate limit for Web3 sign-in. | Default: `30` per hour | + +### Sessions + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SESSIONS_ALLOW_LOW_AAL` | string (duration) | | Time during which a low-AAL session is still accepted. | | +| `GOTRUE_SESSIONS_INACTIVITY_TIMEOUT` | string (duration) | | Session inactivity timeout. | | +| `GOTRUE_SESSIONS_SINGLE_PER_USER` | boolean | | Allow only one active session per user. | | +| `GOTRUE_SESSIONS_TAGS` | string (CSV) | | Tags attached to created sessions. | | +| `GOTRUE_SESSIONS_TIMEBOX` | string (duration) | | Absolute session lifetime. | | + +### Web3 + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED` | boolean | CLI | Enable Ethereum sign-in (Sign-in with Ethereum). | Default: `false` | +| `GOTRUE_EXTERNAL_WEB3_ETHEREUM_MAXIMUM_VALIDITY_DURATION` | string (duration) | | Max validity of an Ethereum signed message. | Default: `10m` | +| `GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED` | boolean | CLI | Enable Solana sign-in. | Default: `false` | +| `GOTRUE_EXTERNAL_WEB3_SOLANA_MAXIMUM_VALIDITY_DURATION` | string (duration) | | Max validity of a Solana signed message. | Default: `10m` | + +### Security + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS` | string | | Map of `key_id:base64-key` used to decrypt previously encrypted columns. | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT` | string | | Enable column-level encryption for new writes. | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY` | string | | Active encryption key (256-bit, base64-RawURL-encoded). | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID` | string | | ID of the active encryption key. | | +| `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED` | boolean | CLI | Allow admins to link identities manually. | Default: `false` | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ALGORITHM_VERSION` | integer (count) | | Refresh token algorithm version (0, 1 or 2). | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ALLOW_REUSE` | boolean | | Allow refresh-token reuse without rotation. | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` | integer (seconds) | CLI | Grace period (s) during which the immediately-previous refresh token can be reused (supports concurrency / offline retries). Only applies when rotation is enabled. | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` | boolean | CLI | Rotate refresh tokens on use; detects malicious reuse and revokes the offending token's descendants. | Default: `true` | +| `GOTRUE_SECURITY_REFRESH_TOKEN_UPGRADE_PERCENTAGE` | integer (percent) | | Percentage of users to upgrade to a newer refresh-token format (0-100). | | +| `GOTRUE_SECURITY_SB_FORWARDED_FOR_ENABLED` | boolean | | Trust the `Sb-Forwarded-For` header. Auth parses the leftmost value as an IP address and uses it for IP tracking and rate limiting. | Default: `false` | +| `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD` | boolean | | Require the current password to change a password. | | +| `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` | boolean | CLI | Require reauthentication before changing a password. | | + +### CAPTCHA + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SECURITY_CAPTCHA_ENABLED` | boolean | | Enable CAPTCHA protection. | Default: `false` | +| `GOTRUE_SECURITY_CAPTCHA_PROVIDER` | string | | CAPTCHA provider (`hcaptcha` or `turnstile`). | Default: `hcaptcha` | +| `GOTRUE_SECURITY_CAPTCHA_SECRET` | string | | CAPTCHA provider secret. | | +| `GOTRUE_SECURITY_CAPTCHA_TIMEOUT` | string (duration) | | HTTP timeout for the CAPTCHA verify call. | Default: `10s` | + +### Password + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_PASSWORD_HIBP_BLOOM_ENABLED` | boolean | | Use a local bloom filter for HIBP lookups. | | +| `GOTRUE_PASSWORD_HIBP_BLOOM_FALSE_POSITIVES` | number (ratio) | | Target false positive rate for the HIBP bloom filter. | Default: `0.0000099` | +| `GOTRUE_PASSWORD_HIBP_BLOOM_ITEMS` | integer (count) | | Expected number of items in the HIBP bloom filter. | Default: `100000` | +| `GOTRUE_PASSWORD_HIBP_ENABLED` | boolean | | Reject pwned passwords using Have I Been Pwned. | | +| `GOTRUE_PASSWORD_HIBP_FAIL_CLOSED` | boolean | | Reject requests if the HIBP lookup fails. | | +| `GOTRUE_PASSWORD_HIBP_USER_AGENT` | string | | User-Agent sent to the HIBP API. | Default: `https://github.com/supabase/gotrue` | +| `GOTRUE_PASSWORD_MIN_LENGTH` | integer (count) | CLI | Minimum password length. | Default: `6` | +| `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` | string | CLI | Colon-separated character classes; a password must contain at least one character from each set. Escape a literal `:` with `\`. | | + +### CORS / Audit log + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_AUDIT_LOG_DISABLE_POSTGRES` | boolean | | Disable Postgres-backed audit log writes. | Default: `false` | +| `GOTRUE_CORS_ALLOWED_HEADERS` | string (CSV) | | Additional headers appended to the CORS allow-list. | | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_LOG_DISABLE_COLORS` | boolean | | Disable ANSI color in log output. | | +| `GOTRUE_LOG_FIELDS` | JSON | | Static log fields (JSON object) attached to every log line. | | +| `GOTRUE_LOG_FILE` | path | | Path to a file to write logs to. | | +| `GOTRUE_LOG_LEVEL` | string | | Logger level (`panic`, `fatal`, `error`, `warn`, `info`, `debug`). | | +| `GOTRUE_LOG_QUOTE_EMPTY_FIELDS` | boolean | | Quote empty log field values. | | +| `GOTRUE_LOG_SQL` | string | | SQL logger configuration. | | +| `GOTRUE_LOG_TSFORMAT` | string | | Timestamp format string for log output. | | + +### Profiler / Tracing / Metrics + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_METRICS_ENABLED` | boolean | | Enable metrics export. | | +| `GOTRUE_METRICS_EXPORTER` | string | | Metrics exporter (`opentelemetry` or `prometheus`). | Default: `opentelemetry` | +| `GOTRUE_METRICS_OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol for metrics. | Default: `http/protobuf`. Alias of `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `GOTRUE_METRICS_OTEL_EXPORTER_PROMETHEUS_HOST` | string | | Bind host for the Prometheus exporter. | Default: `0.0.0.0`. Alias of `OTEL_EXPORTER_PROMETHEUS_HOST` | +| `GOTRUE_METRICS_OTEL_EXPORTER_PROMETHEUS_PORT` | string | | Bind port for the Prometheus exporter. | Default: `9100`. Alias of `OTEL_EXPORTER_PROMETHEUS_PORT` | +| `GOTRUE_PROFILER_ENABLED` | boolean | | Expose the Go pprof HTTP endpoint. | Default: `false` | +| `GOTRUE_PROFILER_HOST` | string | | Bind host for the profiler endpoint. | Default: `localhost` | +| `GOTRUE_PROFILER_PORT` | string | | Bind port for the profiler endpoint. | Default: `9998` | +| `GOTRUE_TRACING_ENABLED` | boolean | | Enable distributed tracing. | | +| `GOTRUE_TRACING_EXPORTER` | string | | Tracing exporter (`opentelemetry`). | Default: `opentelemetry` | +| `GOTRUE_TRACING_HOST` | string | | OpenTelemetry collector host. | | +| `GOTRUE_TRACING_OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol for tracing. | Default: `http/protobuf`. Alias of `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `GOTRUE_TRACING_PORT` | string | | OpenTelemetry collector port. | | +| `GOTRUE_TRACING_SERVICE_NAME` | string | | Service name reported in traces. | Default: `gotrue` | +| `GOTRUE_TRACING_TAGS` | JSON | | Comma-separated `k=v` pairs attached to all spans. | | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol (bare alias, applies to metrics and tracing). | Default: `http/protobuf` | +| `OTEL_EXPORTER_PROMETHEUS_HOST` | string | | Prometheus host (bare alias). | Default: `0.0.0.0` | +| `OTEL_EXPORTER_PROMETHEUS_PORT` | string | | Prometheus port (bare alias). | Default: `9100` | + +### Config reloading + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_RELOADING_GRACE_PERIOD_INTERVAL` | string (duration) | | Idle period before processing a config reload (debounce). | Default: `5s` | +| `GOTRUE_RELOADING_NOTIFY_ENABLED` | boolean | | Use filesystem notifications to detect config changes. | Default: `true` | +| `GOTRUE_RELOADING_POLLER_INTERVAL` | string (duration) | | Polling interval when notifications are disabled. | Default: `10s` | +| `GOTRUE_RELOADING_POLLERENABLED` | string | | Enable filesystem polling for config changes (name is intentionally unsplit). | Default: `false` | +| `GOTRUE_RELOADING_SIGNAL_ENABLED` | boolean | | Trigger a config reload on receiving a Unix signal. | Default: `false` | +| `GOTRUE_RELOADING_SIGNAL_NUMBER` | integer (count) | | Unix signal number to listen for. | Default: `10` (SIGUSR1) | + +### Other + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXPERIMENTAL_PROVIDERS_WITH_OWN_LINKING_DOMAIN` | string | | Providers that do not participate in email-similarity identity linking. | Experimental | +| `GOTRUE_INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST` | boolean | | Always create user-search indexes on startup. | Default: `false` | +| `GOTRUE_INDEX_WORKER_MAX_USERS_THRESHOLD` | integer (count) | | Create user-search indexes only if user count is at or below this threshold. | Default: `0` (disabled) | +| `GOTRUE_INTERNAL_HTTP_TIMEOUT` | string (duration) | | HTTP client timeout used by external OAuth and SMS provider calls. | Read via `os.Getenv`, not envconfig | +| `GOTRUE_OPERATOR_TOKEN` | string | | Bearer token required for operator/admin endpoints. | | + +--- + + +## PostgREST + +> PostgREST's upstream documentation at [postgrest.org](https://postgrest.org/en/stable/references/configuration.html) covers each variable with prose context - security rationale, interaction notes, examples. The rows below stay reference-style; for backstory and detailed semantics, see the upstream docs. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGRST_ADMIN_SERVER_HOST` | string | Self-hosted | Hostname for the PostgREST admin server. | Defaults to `server-host` value | +| `PGRST_ADMIN_SERVER_PORT` | integer | Both | Port for the PostgREST admin server. The admin server is disabled unless a port is set, and it must differ from `PGRST_SERVER_PORT`. | No default (admin server disabled when unset) | +| `PGRST_APP_SETTINGS_*` | string | Self-hosted | Arbitrary settings exposed to PostgreSQL via `current_setting('app.settings.')`. The suffix after `PGRST_APP_SETTINGS_` becomes the setting name (case-insensitive). | Used for `PGRST_APP_SETTINGS_JWT_SECRET` and `PGRST_APP_SETTINGS_JWT_EXP` in self-hosted | +| `PGRST_CLIENT_ERROR_VERBOSITY` | enum | Self-hosted | Controls verbosity of client-facing error responses. | Default: `verbose` (other value: `minimal`) | +| `PGRST_DB_AGGREGATES_ENABLED` | boolean | Self-hosted | Allows the use of aggregate functions (`max`, `sum`, etc.) in queries. Disabled by default due to potential performance risks. | Default: `false` | +| `PGRST_DB_ANON_ROLE` | string | Both | Database role used for unauthenticated requests. When unset, anonymous access is blocked. | No default | +| `PGRST_DB_CHANNEL` | string | Self-hosted | Postgres `NOTIFY` channel name used for schema cache and config reloading. | Default: `pgrst` | +| `PGRST_DB_CHANNEL_ENABLED` | boolean | Self-hosted | Enables the Postgres `NOTIFY` listener channel. Disable when running behind a transaction-pooling connection pooler. | Default: `true` | +| `PGRST_DB_CONFIG` | boolean | Self-hosted | Enables loading in-database configuration via `db-pre-config` and role settings. | Default: `true` | +| `PGRST_DB_EXTRA_SEARCH_PATH` | string (CSV) | Both | Comma-separated list of extra schemas added to the `search_path` of every request. Schemas listed here do not get API endpoints. | Default: `public` | +| `PGRST_DB_HOISTED_TX_SETTINGS` | string (CSV) | Self-hosted | Comma-separated list of settings allowed to be applied as transaction-scoped function settings. | Default: `statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation` | +| `PGRST_DB_MAX_ROWS` | integer (count) | Both | Hard limit on the number of rows PostgREST returns for any table, view, or function; bounds payload size against accidental or malicious queries. | No default (unlimited); alias `PGRST_MAX_ROWS` | +| `PGRST_DB_PLAN_ENABLED` | boolean | Self-hosted | Allows clients to request the query execution plan with `Accept: application/vnd.pgrst.plan`. | Default: `false` | +| `PGRST_DB_POOL` | integer (count) | Self-hosted | Maximum number of database connections kept open in PostgREST's pool. | Default: `10` | +| `PGRST_DB_POOL_ACQUISITION_TIMEOUT` | integer (seconds) | Self-hosted | Time in seconds a request waits for a free connection from the pool. | Default: `10` | +| `PGRST_DB_POOL_AUTOMATIC_RECOVERY` | boolean | Self-hosted | Enables automatic retrying on connection loss. When disabled, PostgREST terminates after losing the database connection. | Default: `true` | +| `PGRST_DB_POOL_MAX_IDLETIME` | integer (seconds) | Self-hosted | Time in seconds after which idle pool connections are closed. | Default: `30`; alias `PGRST_DB_POOL_TIMEOUT` | +| `PGRST_DB_POOL_MAX_LIFETIME` | integer (seconds) | Self-hosted | Maximum lifetime in seconds of a connection in the pool before it is recycled. | Default: `1800` | +| `PGRST_DB_POOL_TIMEOUT` | integer (seconds) | Self-hosted | Deprecated alias for `PGRST_DB_POOL_MAX_IDLETIME`. | Deprecated; use `PGRST_DB_POOL_MAX_IDLETIME` | +| `PGRST_DB_PRE_CONFIG` | string | Self-hosted | Schema-qualified function name used for in-database configuration. | No default | +| `PGRST_DB_PRE_REQUEST` | string | Self-hosted | Schema-qualified function executed right after transaction settings are set, on every request. | No default; alias `PGRST_PRE_REQUEST` | +| `PGRST_DB_PREPARED_STATEMENTS` | boolean | Self-hosted | Enables prepared statements. Disable only when running behind an external connection pooler in transaction pooling mode. | Default: `true` | +| `PGRST_DB_ROOT_SPEC` | string | Self-hosted | Schema-qualified function used to override the OpenAPI response at the API root. | No default; alias `PGRST_ROOT_SPEC` | +| `PGRST_DB_SCHEMA` | string (CSV) | Self-hosted | Deprecated alias for `PGRST_DB_SCHEMAS`. | Deprecated; use `PGRST_DB_SCHEMAS` | +| `PGRST_DB_SCHEMAS` | string (CSV) | Both | Comma-separated list of database schemas exposed by the REST API. `pg_catalog` and `information_schema` are not allowed. | Default: `public` | +| `PGRST_DB_TIMEZONE_ENABLED` | boolean | Self-hosted | Enables the `Prefer: timezone` header for querying `pg_timezone_names`. | Default: `true` | +| `PGRST_DB_TX_END` | enum | Self-hosted | Controls how database transactions are terminated. Allowed values: `commit`, `commit-allow-override`, `rollback`, `rollback-allow-override`. | Default: `commit` | +| `PGRST_DB_URI` | URL | Both | PostgreSQL connection string (URI or key/value). Prefix with `@` to load from a file. Defaults read libpq env vars. | Default: `postgresql://`; required | +| `PGRST_DB_USE_LEGACY_GUCS` | boolean | Self-hosted | Toggles legacy text-based GUCs versus JSON GUCs for request context. | Deprecated; removed in PostgREST v12 (still set in self-hosted docker-compose) | +| `PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted while loading the schema cache. | Internal; no default | +| `PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted during schema cache query. | Internal; no default | +| `PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted while loading schema cache relationships. | Internal; no default | +| `PGRST_JWT_AUD` | string | Self-hosted | Expected value of the `aud` claim in JWTs. Must be a string or valid URI. | No default | +| `PGRST_JWT_CACHE_MAX_ENTRIES` | integer (count) | Self-hosted | Maximum entries in the JWT validation cache. Set to `0` to disable caching. | Default: `1000` | +| `PGRST_JWT_ROLE_CLAIM_KEY` | string | Self-hosted | JSPath expression locating the role claim inside the JWT. | Default: `.role`; alias `PGRST_ROLE_CLAIM_KEY` | +| `PGRST_JWT_SECRET` | string | Both | Secret, JWK, or JWKS used to verify JWTs. Must be at least 32 characters for symmetric secrets. Prefix with `@` to load from a file. | No default | +| `PGRST_JWT_SECRET_IS_BASE64` | boolean | Self-hosted | Treats `PGRST_JWT_SECRET` as base64-encoded. | Default: `false`; alias `PGRST_SECRET_IS_BASE64` | +| `PGRST_LOG_LEVEL` | enum | Self-hosted | Logging level. Allowed values: `crit`, `error`, `warn`, `info`, `debug`. | Default: `error` | +| `PGRST_LOG_QUERY` | boolean | Self-hosted | Logs the SQL query for each request at the current log level. | Default: `false` | +| `PGRST_MAX_ROWS` | integer (count) | Self-hosted | Deprecated alias for `PGRST_DB_MAX_ROWS`. | Deprecated; use `PGRST_DB_MAX_ROWS` | +| `PGRST_OPENAPI_MODE` | enum | Self-hosted | Controls OpenAPI output. Allowed values: `follow-privileges`, `ignore-privileges`, `disabled`. | Default: `follow-privileges` | +| `PGRST_OPENAPI_SECURITY_ACTIVE` | boolean | Self-hosted | Includes security definitions in the OpenAPI output. | Default: `false` | +| `PGRST_OPENAPI_SERVER_PROXY_URI` | URL | Self-hosted | Overrides the base URL in the OpenAPI self-documentation (useful behind a proxy). | No default | +| `PGRST_PRE_REQUEST` | string | Self-hosted | Deprecated alias for `PGRST_DB_PRE_REQUEST`. | Deprecated; use `PGRST_DB_PRE_REQUEST` | +| `PGRST_ROLE_CLAIM_KEY` | string | Self-hosted | Deprecated alias for `PGRST_JWT_ROLE_CLAIM_KEY`. | Deprecated; use `PGRST_JWT_ROLE_CLAIM_KEY` | +| `PGRST_ROOT_SPEC` | string | Self-hosted | Deprecated alias for `PGRST_DB_ROOT_SPEC`. | Deprecated; use `PGRST_DB_ROOT_SPEC` | +| `PGRST_SECRET_IS_BASE64` | boolean | Self-hosted | Deprecated alias for `PGRST_JWT_SECRET_IS_BASE64`. | Deprecated; use `PGRST_JWT_SECRET_IS_BASE64` | +| `PGRST_SERVER_CORS_ALLOWED_ORIGINS` | string (CSV) | Self-hosted | Comma-separated list of allowed CORS origins. When empty or unset, all origins are accepted. | No default | +| `PGRST_SERVER_HOST` | string | Self-hosted | Address the PostgREST web server binds to. Special values: `*` (any), `*4` (IPv4-preferred), `!4` (IPv4-only), `*6` (IPv6-preferred), `!6` (IPv6-only). | Default: `!4` | +| `PGRST_SERVER_PORT` | integer | Self-hosted | TCP port the PostgREST web server binds to. Use `0` to auto-assign. | Default: `3000` | +| `PGRST_SERVER_TIMING_ENABLED` | boolean | Self-hosted | Enables the `Server-Timing` HTTP response header. | Default: `false` | +| `PGRST_SERVER_TRACE_HEADER` | string | Self-hosted | HTTP header name used to trace requests (e.g. `X-Request-Id`). | No default | +| `PGRST_SERVER_UNIX_SOCKET` | path | Self-hosted | Path to a Unix domain socket the server binds to. Takes precedence over `PGRST_SERVER_PORT` when set. | No default | +| `PGRST_SERVER_UNIX_SOCKET_MODE` | string | Self-hosted | Octal file mode applied to the Unix socket. Must be between `600` and `777`. | Default: `660` | + +--- + +## Realtime + +> Realtime's upstream env-var reference is at [supabase/realtime ENVS.md](https://github.com/supabase/realtime/blob/main/ENVS.md). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `API_JWT_JWKS` | JWKS | Both | JSON Web Key Set used to verify tenant JWTs during self-host seeding. Read by `priv/repo/seeds.exs` and `priv/repo/dev_seeds.exs`. | Used only by the seed script (`SEED_SELF_HOST=true`). Required when using the new API keys and new auth. | +| `API_JWT_SECRET` | string | Both | Symmetric HS256 secret used to sign tokens for the tenant management API and the default self-host tenant. | Required for the tenant management API in production. | +| `API_TOKEN_BLOCKLIST` | string (CSV) | Self-hosted | Comma-separated list of tokens blocked from tenant management API access. | Default: empty list. | +| `APP_NAME` | string | Both | Application/node name. Used to build the Phoenix endpoint URL host, libcluster DNS basename, and Erlang `RELEASE_NODE`. | Required - raises `APP_NAME not available` if empty. Default: empty (build) / `realtime` (Erlang release script). | +| `BROADCAST_POOL_SIZE` | integer (count) | Self-hosted | Number of processes used to relay Phoenix.PubSub messages across the cluster. | Default: `10`. | +| `CHANNEL_ERROR_BACKOFF_MS` | integer (ms) | Self-hosted | Delay (ms) before returning a channel join error to the client. Slows down reconnect storms. | Default: `5000` (5 seconds). | +| `CLIENT_PRESENCE_MAX_CALLS` | integer (count) | Self-hosted | Maximum presence calls allowed per client (per WebSocket) within the time window. | Default: `5`. | +| `CLIENT_PRESENCE_WINDOW_MS` | integer (ms) | Self-hosted | Time window (ms) for per-client presence rate limiting. | Default: `30000`. | +| `CLUSTER` | string | Self-hosted | Cluster name added to log metadata. | No default. Read by `Realtime.Application.start/2`. | +| `CLUSTER_SECRET_ID` | string | Self-hosted | AWS Secrets Manager secret ID holding the cluster CA cert/key. | Used by `run.sh` `generate_certs` when `GENERATE_CLUSTER_CERTS` is set. | +| `CLUSTER_SECRET_REGION` | string | Self-hosted | AWS region for `CLUSTER_SECRET_ID`. | Used by `run.sh` `generate_certs` when `GENERATE_CLUSTER_CERTS` is set. | +| `CLUSTER_STRATEGIES` | string (CSV) | Self-hosted | Comma-separated list of libcluster backends to enable. Supported: `EPMD`, `DNS`, `POSTGRES`. | Default: `EPMD` outside production, `POSTGRES` in production. | +| `CONNECT_ERROR_BACKOFF_MS` | integer (ms) | Self-hosted | Delay (ms) before returning a WebSocket connection error to the client. Slows down reconnect storms. | Default: `2000` (2 seconds). | +| `CONNECT_PARTITION_SLOTS` | integer (count) | Self-hosted | Number of dynamic supervisor partitions for the `Connect` / `ReplicationConnect` processes. | Default: `System.schedulers_online() * 2`. | +| `DASHBOARD_AUTH` | enum | Self-hosted | Authentication method for the admin dashboard (`/admin`). Accepted: `basic_auth` (requires `DASHBOARD_USER` and `DASHBOARD_PASSWORD`) or `zta` (requires `CF_TEAM_DOMAIN`). | Default: `basic_auth`. | +| `DASHBOARD_PASSWORD` | string | Self-hosted | Password for admin dashboard basic auth. | Default: random hex string generated at boot. | +| `DASHBOARD_USER` | string | Self-hosted | Username for admin dashboard basic auth. | Default: random hex string generated at boot. | +| `DB_AFTER_CONNECT_QUERY` | string | Both | SQL query executed after every Postgres connection is established. | No default. Self-host sets `SET search_path TO _realtime`. | +| `DB_ENC_KEY` | string | Both | Key used to encrypt sensitive fields in the `_realtime.tenants` and `_realtime.extensions` tables. | Recommended: 16 characters. Required (consumed as `db_enc_key` by the app config). | +| `DB_HOST` | string | Both | Primary Postgres host. | Default: `127.0.0.1`. | +| `DB_IP_VERSION` | enum | Self-hosted | Forces the IP version for Postgres connections. Accepted: `ipv4`, `ipv6`. | When unset, IP version is auto-detected from `DB_HOST`. | +| `DB_MASTER_REGION` | string | Self-hosted | Overrides the primary region for region-aware routing and tenant placement. | When unset, the current `REGION` is used. | +| `DB_NAME` | string | Both | Postgres database name. | Default: `postgres`. | +| `DB_PASSWORD` | string | Both | Postgres password. | Default: `postgres`. | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Number of connections in the primary Postgres pool. | Default: `5`. | +| `DB_PORT` | string | Both | Postgres port. | Default: `5432`. | +| `DB_QUEUE_INTERVAL` | integer (ms) | Self-hosted | Ecto pool queue interval in ms. | Default: `5000`. | +| `DB_QUEUE_TARGET` | integer (ms) | Self-hosted | Ecto pool queue target in ms. | Default: `5000`. | +| `DB_REPLICA_HOST` | string | Self-hosted | Hostname for the main replica Postgres pool. | When set, enables the `Realtime.Repo.Replica` connection pool. | +| `DB_REPLICA_POOL_SIZE` | integer (count) | Self-hosted | Number of connections in the replica pool(s). | Default: `5`. | +| `DB_SSL` | boolean | Self-hosted | Enable SSL for Postgres connections. | Default: `false`. Accepts `true`/`false`/`1`/`0`. | +| `DB_SSL_CA_CERT` | path | Self-hosted | Path to a CA trust store used when `DB_SSL=true`. Enables server certificate verification. | When unset and `DB_SSL=true`, falls back to `verify: :verify_none`. | +| `DB_USER` | string | Both | Postgres user. | Default: `supabase_admin`. | +| `DISABLE_HEALTHCHECK_LOGGING` | boolean | Self-hosted | Disables request logging for `/healthcheck` and `/api/tenants/:tenant_id/health`. | Default: `false`. | +| `DNS_NODES` | string | Both | DNS query used by the libcluster `DNS` strategy. | No default. Only consulted when `CLUSTER_STRATEGIES` contains `DNS`. | +| `HTTP_DYNAMIC_BUFFER_MAX` | integer (bytes) | Self-hosted | Maximum buffer size (bytes) for HTTP connections (Cowboy dynamic buffer). | Must be set together with `HTTP_DYNAMIC_BUFFER_MIN`. | +| `HTTP_DYNAMIC_BUFFER_MIN` | integer (bytes) | Self-hosted | Minimum buffer size (bytes) for HTTP connections (Cowboy dynamic buffer). | Must be set together with `HTTP_DYNAMIC_BUFFER_MAX`. | +| `JANITOR_CHILDREN_TIMEOUT` | integer (ms) | Self-hosted | Timeout (ms) for each janitor child task. | Default: `5000`. Only used when `RUN_JANITOR=true`. | +| `JANITOR_CHUNK_SIZE` | integer (count) | Self-hosted | Number of tenants processed per chunk per janitor task. | Default: `10`. | +| `JANITOR_MAX_CHILDREN` | integer (count) | Self-hosted | Maximum number of concurrent janitor task children. | Default: `5`. | +| `JANITOR_RUN_AFTER_IN_MS` | integer (ms) | Self-hosted | Delay (ms) before the janitor first runs after boot. | Default: 10 minutes. | +| `JANITOR_SCHEDULE_RANDOMIZE` | boolean | Self-hosted | Add a random offset to the janitor schedule. | Default: `true`. | +| `JANITOR_SCHEDULE_TIMER_IN_MS` | integer (ms) | Self-hosted | Interval (ms) between janitor runs. | Default: 4 hours. | +| `JWT_CLAIM_VALIDATORS` | JSON | Self-hosted | JSON object of claim validators applied to incoming JWTs (e.g. `{"iss":"Issuer"}`). | Default: `{}`. Must be valid JSON object or boot fails. | +| `LOG_LEVEL` | enum | Self-hosted | Logger level. One of `info`, `emergency`, `alert`, `critical`, `error`, `warning`, `notice`, `debug`. | Default: `info`. | +| `LOG_THROTTLE_JANITOR_INTERVAL_IN_MS` | integer (ms) | Self-hosted | Cachex expiration interval (ms) for the log-throttle cache. | Default: 10 minutes. | +| `LOGFLARE_API_KEY` | string | Self-hosted | Logflare API key. | Required when `LOGS_ENGINE=logflare`. | +| `LOGFLARE_LOGGER_BACKEND_URL` | URL | Self-hosted | Endpoint for the Logflare logger backend. | Default: `https://api.logflare.app`. | +| `LOGFLARE_SOURCE_ID` | string | Self-hosted | Logflare source ID. | Required when `LOGS_ENGINE=logflare`. | +| `LOGS_ENGINE` | string | Self-hosted | Log backend selector. Set to `logflare` to enable the Logflare HTTP backend. | When unset, standard logger output is used. | +| `MAX_CONNECTIONS` | integer (count) | Self-hosted | Soft maximum number of WebSocket connections. | Default: `16384`. | +| `MAX_HEADER_LENGTH` | integer (bytes) | CLI | Maximum HTTP header value length (bytes). | Default: `4096`. | +| `METRICS_CLEANER_SCHEDULE_TIMER_IN_MS` | integer (ms) | Self-hosted | Interval (ms) between metrics cleaner runs. | Default: 30 minutes. | +| `METRICS_JWT_SECRET` | string | Both | Secret used to sign JWTs for the metrics endpoints. | Required - the app raises an exception if unset. | +| `METRICS_PUSHER_AUTH` | string | Self-hosted | Password used for Basic auth on metrics pushes. Used together with `METRICS_PUSHER_USER`. | When unset, requests are sent without authorization. | +| `METRICS_PUSHER_COMPRESS` | boolean | Self-hosted | Enable gzip compression for metrics payloads. | Default: `true`. | +| `METRICS_PUSHER_ENABLED` | boolean | Self-hosted | Enable periodic push of Prometheus metrics. | Default: `false`. Requires `METRICS_PUSHER_URL`. | +| `METRICS_PUSHER_EXTRA_LABELS` | string (CSV) | Self-hosted | Comma-separated `key=value` pairs appended as `extra_label` query parameters on every push. | Default: empty. | +| `METRICS_PUSHER_INTERVAL_MS` | integer (ms) | Self-hosted | Interval (ms) between metrics pushes. | Default: 30 seconds. | +| `METRICS_PUSHER_TIMEOUT_MS` | integer (ms) | Self-hosted | HTTP timeout (ms) for metrics push requests. | Default: 15 seconds. | +| `METRICS_PUSHER_URL` | URL | Self-hosted | Full URL endpoint to push metrics in Prometheus exposition format. | Required when `METRICS_PUSHER_ENABLED=true`. | +| `METRICS_PUSHER_USER` | string | Self-hosted | Username used for Basic auth on metrics pushes. | Default: `realtime`. | +| `METRICS_RPC_TIMEOUT_IN_MS` | integer (ms) | Self-hosted | Timeout (ms) for RPC calls that fetch metrics from other nodes. | Default: 15 seconds. | +| `METRICS_TOKEN_BLOCKLIST` | string (CSV) | Self-hosted | Comma-separated list of tokens blocked from accessing the metrics endpoints. | Default: empty list. | +| `PORT` | integer | Both | HTTP listener port. | Default: `4000`. | +| `PROM_POLL_RATE` | integer (ms) | Self-hosted | Poll interval (ms) for PromEx metrics collection. | Default: `5000`. | +| `REALTIME_IP_VERSION` | enum | Self-hosted | Forces the HTTP listener IP version. Accepted: `ipv4`, `ipv6`. | When unset, IPv6 is preferred when available. | +| `REBALANCE_CHECK_INTERVAL_IN_MS` | integer (ms) | Self-hosted | Interval (ms) used to check whether a process is in the right region. | Default: 10 minutes. | +| `REGION` | string | Self-hosted | Region name for the current node. Used in logs, latency reporting, and region-aware routing. | No default. Also rendered in the admin dashboard layout. | +| `REGION_MAPPING` | JSON | Self-hosted | Custom mapping of platform regions to tenant regions, as a JSON object with string keys and values. | When unset, the hardcoded default mapping is used. Must be a JSON object or boot fails. | +| `REQUEST_ID_BAGGAGE_KEY` | string | Self-hosted | OTEL Baggage key used as the request ID. | Default: `request-id`. | +| `RPC_TIMEOUT` | integer (ms) | Self-hosted | Timeout (ms) for generic RPC calls. | Default: 30 seconds. | +| `RUN_JANITOR` | boolean | Both | Enable the tenant janitor and metrics cleaner tasks. | Default: `false`. | +| `SECRET_KEY_BASE` | string | Both | Secret used by Phoenix to sign cookies and tokens. | Required - recommended length: 64 characters. | +| `SEED_SELF_HOST` | boolean | Both | If `true`, `run.sh` runs `Realtime.Release.seeds/1` to create the default tenant. | Default: not set (no seeding). Self-host enables this on first boot. | +| `SELF_HOST_TENANT_NAME` | string | Self-hosted | Tenant external_id used by the self-host seed script. | Default: `realtime-dev`. Must be URL-safe. | +| `SLOT_NAME_SUFFIX` | string | CLI | Suffix appended to the default replication slot name `supabase_realtime_replication_slot`. | Allowed: lowercase letters, numbers, underscore. Combined name must be 64 characters or fewer. | +| `TENANT_CACHE_EXPIRATION_IN_MS` | integer (ms) | Self-hosted | TTL (ms) for the in-process tenant cache. | Default: 30 seconds. | +| `TENANT_MAX_BYTES_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum bytes per second (used when a tenant is first created). | Default: `100000`. | +| `TENANT_MAX_CHANNELS_PER_CLIENT` | integer (count) | Self-hosted | Default per-tenant maximum channels per client (used when a tenant is first created). | Default: `100`. | +| `TENANT_MAX_CONCURRENT_USERS` | integer (count) | Self-hosted | Default per-tenant maximum concurrent users per channel (used when a tenant is first created). | Default: `200`. | +| `TENANT_MAX_EVENTS_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum events per second (used when a tenant is first created). | Default: `100`. | +| `TENANT_MAX_JOINS_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum channel joins per second (used when a tenant is first created). | Default: `100`. | +| `USERS_SCOPE_SHARDS` | integer (count) | Self-hosted | Number of partitions used by the Beacon `users` scope. | Default: `5`. | +| `WEBSOCKET_MAX_HEAP_SIZE` | integer (bytes) | Self-hosted | Maximum heap (bytes) for each WebSocket transport process; the process is killed if exceeded. | Default: `50000000` (50 MB). | + +--- + +## Storage + +### Server + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADMIN_API_KEYS` | string | | Comma-separated API keys accepted on the admin port. Legacy alias for `SERVER_ADMIN_API_KEYS`. | Default: empty | +| `ADMIN_PORT` | integer | | Port the admin HTTP server listens on. Legacy alias for `SERVER_ADMIN_PORT`. | Default: `5001` | +| `EXPOSE_DOCS` | boolean | | Expose `/docs` Swagger UI. | Default: `true` | +| `HOST` | string | | Host the public server binds to. Legacy alias for `SERVER_HOST`. | Default: `0.0.0.0` | +| `NODE_ENV` | enum | | Node.js runtime mode. When `production`, sets `isProduction` and forces HTTPS in TUS link generation. | Default: unset | +| `PORT` | integer | | Port the public HTTP server listens on. Legacy alias for `SERVER_PORT`. | Default: `5000` | +| `PROJECT_REF` | string | | Single-tenant project reference; used as `tenantId` when set. | Optional (single-tenant) | +| `REGION` | string | Self-hosted | Region label exposed in responses and used as fallback for `STORAGE_S3_REGION` / `SERVER_REGION`. | Default: `not-specified` | +| `REQUEST_ADMIN_TRACE_HEADER` | string | | Header carrying the admin request trace id. Legacy fallback for `REQUEST_TRACE_HEADER`. | Optional | +| `REQUEST_ALLOW_X_FORWARDED_PATH` | boolean | Self-hosted | Honor the `X-Forwarded-Path` header when computing public URLs. | Default: `false` | +| `REQUEST_ETAG_HEADERS` | string (CSV) | | Comma-separated list of request headers that carry an ETag for conditional GETs. | Default: `if-none-match` | +| `REQUEST_ID_HEADER` | string | | Legacy alias for `REQUEST_TRACE_HEADER`. | Optional | +| `REQUEST_TRACE_HEADER` | string | | Header name used to propagate the request trace id. | Default: unset | +| `REQUEST_URL_LENGTH_LIMIT` | integer | | Maximum object key URL length. | Default: `7500` | +| `REQUEST_X_FORWARDED_HOST_REGEXP` | string (regex) | | Regex applied to `X-Forwarded-Host` to derive the tenant id. | Optional | +| `RESPONSE_S_MAXAGE` | integer (seconds) | | `s-maxage` (CDN) cache lifetime added to public responses (seconds). | Default: `0` | +| `SERVER_ADMIN_API_KEYS` | string | | Comma-separated API keys accepted on the admin port. | Default: empty | +| `SERVER_ADMIN_PORT` | integer | | Port the admin HTTP server listens on. | Default: `5001` | +| `SERVER_HEADERS_TIMEOUT` | integer (seconds) | | Node `headersTimeout` (seconds) for the HTTP server. | Default: `65` | +| `SERVER_HOST` | string | | Host the public server binds to. | Default: `0.0.0.0` | +| `SERVER_KEEP_ALIVE_TIMEOUT` | integer (seconds) | | Node `keepAliveTimeout` (seconds) for the HTTP server. | Default: `61` | +| `SERVER_PORT` | integer | | Port the public HTTP server listens on. | Default: `5000` | +| `SERVER_REGION` | string | | Region label exposed in responses; falls back to `REGION`. | Default: `not-specified` | +| `STORAGE_PUBLIC_URL` | URL | Self-hosted | Public base URL prepended to generated object URLs. | Optional | +| `TENANT_ID` | string | Self-hosted | Single-tenant tenant id (fallback after `PROJECT_REF`). | Default: `storage-single-tenant` | +| `URL_LENGTH_LIMIT` | integer | | Legacy alias for `REQUEST_URL_LENGTH_LIMIT`. | Default: `7500` | +| `VERSION` | string | | Build version reported in logs and the default DB application name. | Default: `0.0.0` | +| `WORKERS_NUM` | integer | | Number of cluster workers to spawn. | Default: `1` | +| `X_FORWARDED_HOST_REGEXP` | string (regex) | | Legacy alias for `REQUEST_X_FORWARDED_HOST_REGEXP`. | Optional | + +### Database + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DATABASE_APPLICATION_NAME` | string | | Postgres `application_name` for the API connection pool. | Default: `Supabase Storage API ${VERSION}` | +| `DATABASE_CONNECTION_TIMEOUT` | integer (ms) | | Postgres connection acquire timeout (ms). | Default: `3000` | +| `DATABASE_ENABLE_QUERY_CANCELLATION` | boolean | | Issue a Postgres cancel on request abort. | Default: `false` | +| `DATABASE_FREE_POOL_AFTER_INACTIVITY` | integer (ms) | | Time (ms) after which an idle tenant pool is released. | Default: `60000` | +| `DATABASE_MAX_CONNECTIONS` | integer | | Max connections per tenant pool. Ignored when `DATABASE_POOL_URL` is set. | Default: `20` | +| `DATABASE_POOL_MODE` | enum | | `single_use` or `recycle`. | Optional | +| `DATABASE_POOL_URL` | URL | | External pooler (Supavisor/PgBouncer) connection string. When set, `DATABASE_MAX_CONNECTIONS` is ignored. | Optional | +| `DATABASE_POSTGRES_VERSION` | string | | Override the detected Postgres version string. | Optional | +| `DATABASE_SEARCH_PATH` | string (CSV) | | Comma-separated `search_path` prepended to every session. | Default: empty | +| `DATABASE_SSL_ROOT_CERT` | path | | PEM bundle used to verify the Postgres server certificate. | Optional | +| `DATABASE_STATEMENT_TIMEOUT` | integer (ms) | | Postgres `statement_timeout` (ms) applied per session. | Default: `30000` | +| `DATABASE_URL` | URL | Both | Primary Postgres connection string used by the API. | Required (single-tenant) | +| `DB_ALLOW_MIGRATION_REFRESH` | boolean | | Allow refreshing migration hashes when the hash recorded in the DB diverges. | Default: `true` | +| `DB_ANON_ROLE` | string | | Postgres role used when authenticating as anonymous. | Default: `anon` | +| `DB_AUTHENTICATED_ROLE` | string | | Postgres role used for authenticated requests. | Default: `authenticated` | +| `DB_INSTALL_ROLES` | boolean | | Run role install migrations on boot. | Default: `false` | +| `DB_MIGRATIONS_FREEZE_AT` | string | CLI | Stop applying migrations after the named migration. | Optional | +| `DB_SEARCH_PATH` | string (CSV) | | Legacy alias for `DATABASE_SEARCH_PATH`. | Default: empty | +| `DB_SERVICE_ROLE` | string | | Postgres role used by the service-role key. | Default: `service_role` | +| `DB_SUPER_USER` | string | | Postgres superuser used for migrations. | Default: `postgres` | +| `TENANT_POOL_CACHE_HIT_LOG_SAMPLE_RATE` | number (ratio) | | Sample rate (0-1) for logging tenant-pool cache hits. | Default: `0` | +| `TENANT_POOL_CACHE_MISS_LOG_SAMPLE_RATE` | number (ratio) | | Sample rate (0-1) for logging tenant-pool cache misses. | Default: `0` | +| `TENANT_POOL_CACHE_TTL_MS` | integer (ms) | | TTL (ms) for the per-tenant connection-pool cache. | Default: `10000` | + +### JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AUTH_JWT_ALGORITHM` | enum | | JWT algorithm used to verify tokens. | Default: `HS256` | +| `AUTH_JWT_SECRET` | string | Both | HS256 secret used to verify the legacy `ANON_KEY` / `SERVICE_KEY`. | Required (single-tenant) | +| `JWT_CACHING_ENABLED` | boolean | | Cache decoded JWTs in memory to reduce verification cost. | Default: `false` | +| `JWT_JWKS` | JWKS | Both | JSON Web Key Set used to verify asymmetric JWTs (e.g. ES256). | Required when using the new API keys and new auth. | +| `PGRST_JWT_ALGORITHM` | enum | | Legacy alias for `AUTH_JWT_ALGORITHM`. | Default: `HS256` | +| `PGRST_JWT_SECRET` | string | | JWT secret used by Storage to verify Postgres-issued tokens; legacy alias for `AUTH_JWT_SECRET`. | Required (single-tenant) | + +### Auth + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ANON_KEY` | JWT | Both | Anon JWT served to public clients. Auto-generated from `AUTH_JWT_SECRET` when blank in single-tenant mode. | Required for self-hosted single-tenant | +| `SERVICE_KEY` | JWT | Both | Service-role JWT (bypasses Row Level Security). Auto-generated from `AUTH_JWT_SECRET` when blank in single-tenant mode. | Required for self-hosted single-tenant | + +### S3 backend + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AWS_ACCESS_KEY_ID` | string | Self-hosted | AWS access key id consumed by the AWS SDK to sign S3 requests. | Required when `STORAGE_BACKEND=s3` | +| `AWS_SECRET_ACCESS_KEY` | string | Self-hosted | AWS secret key consumed by the AWS SDK to sign S3 requests. | Required when `STORAGE_BACKEND=s3` | +| `GLOBAL_S3_BUCKET` | string | Both | S3 bucket name; legacy alias for `STORAGE_S3_BUCKET`. | Required when `STORAGE_BACKEND=s3` | +| `GLOBAL_S3_ENDPOINT` | URL | Self-hosted | Legacy alias for `STORAGE_S3_ENDPOINT`. | Optional | +| `GLOBAL_S3_FORCE_PATH_STYLE` | boolean | Self-hosted | Legacy alias for `STORAGE_S3_FORCE_PATH_STYLE`. | Default: `false` | +| `GLOBAL_S3_MAX_SOCKETS` | integer | | Legacy alias for `STORAGE_S3_MAX_SOCKETS`. | Default: `200` | +| `GLOBAL_S3_PRIVATE_ASSET_ENDPOINT` | URL | | Legacy alias for `STORAGE_S3_PRIVATE_ASSET_ENDPOINT`. | Optional | +| `S3_ALLOW_FORWARDED_HEADER` | boolean | | Honor the `Forwarded` header when reconstructing canonical request URLs for SigV4. | Default: `false` | +| `S3_PROTOCOL_ACCESS_KEY_ID` | string | Both | Static SigV4 access key id (single-tenant). | Optional | +| `S3_PROTOCOL_ACCESS_KEY_SECRET` | string | Both | Static SigV4 secret (single-tenant). | Optional | +| `S3_PROTOCOL_ENABLED` | boolean | CLI | Enable the S3-compatible API. | Default: `true` | +| `S3_PROTOCOL_ENFORCE_REGION` | boolean | | Reject SigV4 requests whose region does not match `STORAGE_S3_REGION`. | Default: `false` | +| `S3_PROTOCOL_NON_CANONICAL_HOST_HEADER` | string | | Override host used during SigV4 canonicalization. | Optional | +| `S3_PROTOCOL_PREFIX` | string | CLI | URL prefix mounted in front of the S3 protocol routes. | Default: empty | +| `STORAGE_BACKEND` | enum | Both | Object backend driver: `s3` or `file`. | Default: `file` (compose) / unset (code) | +| `STORAGE_EMPTY_BUCKET_MAX` | integer | | Max objects deletable in a single empty-bucket call. | Default: `200000` | +| `STORAGE_S3_BUCKET` | string | | Bucket name used by the S3 backend. | Required when `STORAGE_BACKEND=s3` | +| `STORAGE_S3_CLIENT_TIMEOUT` | integer (ms) | | Per-request timeout (ms) for S3 SDK calls; `0` disables. | Default: `0` | +| `STORAGE_S3_DISABLE_CHECKSUM` | boolean | | Disable S3 SDK request checksums. | Default: `false` | +| `STORAGE_S3_ENABLED_METRICS` | boolean | | Enable internal S3 client tracing/metrics. | Default: `false` | +| `STORAGE_S3_ENDPOINT` | URL | | Custom S3 endpoint (e.g. MinIO). | Optional | +| `STORAGE_S3_FORCE_PATH_STYLE` | boolean | | Use path-style S3 addressing. | Default: `false` | +| `STORAGE_S3_MAX_SOCKETS` | integer | | Max concurrent sockets for the S3 HTTP agent. | Default: `200` | +| `STORAGE_S3_PRIVATE_ASSET_ENDPOINT` | URL | | Endpoint used only when signing private source URLs for internal consumers (e.g. imgproxy). | Optional | +| `STORAGE_S3_REGION` | string | CLI | AWS region for the S3 backend; falls back to `REGION`. | Required when `STORAGE_BACKEND=s3` | +| `STORAGE_S3_UPLOAD_PART_SIZE` | integer (bytes) | | Multipart upload part size in bytes. Values below the 5 MiB S3 minimum are clamped up. | Default: `16777216` (16 MiB); minimum: `5242880` (5 MiB) | +| `STORAGE_S3_UPLOAD_QUEUE_SIZE` | integer | | Concurrent part uploads per multipart object. | Default: `2` | + +### File backend + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `FILE_STORAGE_BACKEND_PATH` | path | Both | Filesystem path for the `file` backend; legacy alias for `STORAGE_FILE_BACKEND_PATH`. | Required when `STORAGE_BACKEND=file` | +| `STORAGE_FILE_BACKEND_PATH` | path | | Filesystem directory used by the `file` backend. | Required when `STORAGE_BACKEND=file` | +| `STORAGE_FILE_ETAG_ALGORITHM` | enum | | ETag algorithm for the `file` backend: `md5` or `mtime`. | Default: `md5` | + +### Image transformation + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLE_IMAGE_TRANSFORMATION` | boolean | Both | Legacy alias for `IMAGE_TRANSFORMATION_ENABLED`. | Default: `false` | +| `IMAGE_TRANSFORMATION_ENABLED` | boolean | | Enable image rendering via imgproxy. | Default: `false` | +| `IMAGE_TRANSFORMATION_LIMIT_MAX_SIZE` | integer | | Max requested dimension (px) for transformations. | Default: `2000` | +| `IMAGE_TRANSFORMATION_LIMIT_MIN_SIZE` | integer | | Min requested dimension (px) for transformations. | Default: `1` | +| `IMGPROXY_HTTP_KEEP_ALIVE_TIMEOUT` | integer (seconds) | | Keep-alive timeout (seconds) for the imgproxy HTTP agent. | Default: `61` | +| `IMGPROXY_HTTP_MAX_SOCKETS` | integer | | Max concurrent sockets for the imgproxy HTTP agent. | Default: `5000` | +| `IMGPROXY_REQUEST_TIMEOUT` | integer (seconds) | | Request timeout (seconds) for imgproxy calls. | Default: `15` | +| `IMGPROXY_URL` | URL | Both | imgproxy base URL. | Required when image transformation is enabled | +| `IMG_LIMITS_MAX_SIZE` | integer | | Legacy alias for `IMAGE_TRANSFORMATION_LIMIT_MAX_SIZE`. | Default: `2000` | +| `IMG_LIMITS_MIN_SIZE` | integer | | Legacy alias for `IMAGE_TRANSFORMATION_LIMIT_MIN_SIZE`. | Default: `1` | + +### Upload limits + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `FILE_SIZE_LIMIT` | integer (bytes) | Both | Maximum upload file size; legacy alias for `UPLOAD_FILE_SIZE_LIMIT`. | Required | +| `FILE_SIZE_LIMIT_STANDARD_UPLOAD` | integer (bytes) | | Legacy alias for `UPLOAD_FILE_SIZE_LIMIT_STANDARD`. | Default: `0` (disabled) | +| `SIGNED_UPLOAD_URL_EXPIRATION_TIME` | integer (seconds) | CLI | Legacy alias for `UPLOAD_SIGNED_URL_EXPIRATION_TIME`. | Default: `60` | +| `TUS_ALLOW_S3_TAGS` | boolean | | Propagate user metadata as S3 tags during TUS uploads. | Default: `true` | +| `TUS_LOCK_TYPE` | enum | | TUS upload lock backend: `postgres` or `s3`. | Default: `postgres` | +| `TUS_MAX_CONCURRENT_UPLOADS` | integer | | Max concurrent TUS upload sessions. | Default: `500` | +| `TUS_PART_SIZE` | integer (MB) | | TUS multipart part size (MB). | Default: `50` | +| `TUS_URL_EXPIRY_MS` | integer (ms) | | TUS upload-URL expiry (ms). | Default: `3600000` (1h) | +| `TUS_URL_PATH` | path | CLI | Path mount for TUS resumable uploads. | Default: `/upload/resumable` | +| `TUS_USE_FILE_VERSION_SEPARATOR` | boolean | | Include the object version in TUS storage keys. | Default: `false` | +| `UPLOAD_FILE_SIZE_LIMIT` | integer (bytes) | CLI | Max upload size in bytes. | Required | +| `UPLOAD_FILE_SIZE_LIMIT_STANDARD` | integer (bytes) | CLI | Max size in bytes for non-resumable uploads. | Default: `0` (disabled) | +| `UPLOAD_SIGNED_URL_EXPIRATION_TIME` | integer (seconds) | | Default lifetime (seconds) of signed upload URLs. | Default: `60` | + +### Rate limiting + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLE_RATE_LIMITER` | boolean | | Legacy alias for `RATE_LIMITER_ENABLED`. | Default: `false` | +| `RATE_LIMITER_DRIVER` | enum | | Rate limiter backend: `memory` or `redis`. | Default: `memory` | +| `RATE_LIMITER_ENABLED` | boolean | | Enable the image-transformation rate limiter. | Default: `false` | +| `RATE_LIMITER_REDIS_COMMAND_TIMEOUT` | integer (seconds) | | Per-command timeout (seconds) when using the Redis driver. | Default: `2` | +| `RATE_LIMITER_REDIS_CONNECT_TIMEOUT` | integer (seconds) | | Connect timeout (seconds) when using the Redis driver. | Default: `2` | +| `RATE_LIMITER_REDIS_URL` | URL | | Redis connection URL. | Required when `RATE_LIMITER_DRIVER=redis` | +| `RATE_LIMITER_RENDER_PATH_MAX_REQ_SEC` | integer | | Max requests per second on render paths. | Default: `5` | +| `RATE_LIMITER_SKIP_ON_ERROR` | boolean | | Allow requests through when the rate limiter errors. | Default: `false` | + +### Webhook + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `WEBHOOK_API_KEY` | string | | Bearer key sent with outbound webhooks. | Optional | +| `WEBHOOK_QUEUE_PULL_INTERVAL` | integer (ms) | | Polling interval (ms) for the webhook queue. | Default: `700` | +| `WEBHOOK_URL` | URL | | Endpoint that receives object events. | Optional | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_API_KEY` | string | | Logflare ingest API key. | Required when `LOGFLARE_ENABLED=true` | +| `LOGFLARE_BATCH_SIZE` | integer | | Max records per Logflare batch. | Default: `200` | +| `LOGFLARE_ENABLED` | boolean | | Forward logs to Logflare. | Default: `false` | +| `LOGFLARE_SOURCE_TOKEN` | string | | Logflare source identifier. | Required when `LOGFLARE_ENABLED=true` | +| `LOG_LEVEL` | enum | | pino log level. | Default: `info` | +| `METRICS_DISABLED` | string (CSV) | | Comma-separated list of metric names (or `all`) to drop. | Optional | +| `OTEL_EXPORTER_OTLP_COMPRESSION` | enum | | OTLP exporter compression algorithm (`gzip`, `none`). | Optional | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | URL | | OTLP endpoint used when a metrics-specific endpoint is not set. | Optional | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | URL | | OTLP endpoint for metrics export. | Optional | +| `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | string (CSV) | | Comma-separated `k=v` headers attached to OTLP metric requests. | Optional | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | URL | | OTLP endpoint for trace export. | Optional | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | string (CSV) | | Comma-separated `k=v` headers attached to OTLP trace requests. | Optional | +| `OTEL_METRICS_ENABLED` | boolean | | Enable the OpenTelemetry metrics SDK. | Default: `false` | +| `OTEL_METRICS_EXPORT_INTERVAL_MS` | integer (ms) | | OTLP metrics export interval (ms). | Default: `60000` | +| `OTEL_METRICS_TEMPORALITY` | enum | | OTLP metrics temporality: `DELTA` or `CUMULATIVE`. | Default: `CUMULATIVE` | +| `PROMETHEUS_METRICS_ENABLED` | boolean | | Expose Prometheus metrics on the admin port. | Default: `false` | +| `PROMETHEUS_METRICS_INCLUDE_TENANT` | boolean | | Include the tenant id label on Prometheus metrics. | Default: `false` | +| `TRACING_ENABLED` | boolean | | Enable OpenTelemetry tracing. | Default: `false` | +| `TRACING_FEATURE_UPLOAD` | boolean | | Emit detailed spans for the upload pipeline. | Default: `false` | +| `TRACING_MODE` | enum | | Tracing verbosity, e.g. `basic`, `debug`. | Default: `basic` | +| `TRACING_RETURN_SERVER_TIMINGS` | boolean | | Return `Server-Timing` response headers. | Default: `false` | +| `TRACING_SERVER_TIME_MIN_DURATION` | number | | Min span duration (ms) before it is reported in `Server-Timing`. | Default: `100.0` | + +### Tenant features (Vector) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `VECTOR_BUCKET_REGION` | string | | AWS region for vector buckets. | Optional | +| `VECTOR_ENABLED` | boolean | | Enable vector bucket support. | Default: `false` | +| `VECTOR_MAX_BUCKETS` | integer | | Max vector buckets per tenant. | Default: `10` | +| `VECTOR_MAX_INDEXES` | integer | | Max indexes per vector bucket. | Default: `20` | +| `VECTOR_S3_BUCKETS` | string (CSV) | | Comma-separated list of S3 buckets backing vector indexes. | Optional | + +### Other (tooling) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADMIN_API_KEY` | string | | API key used by the bundled `pprof-client` script. | Optional (tooling) | +| `ADMIN_URL` | URL | | Admin server URL used by the bundled `pprof-client` script. | Optional (tooling) | +| `FLAME_SOURCEMAPS_DIRS` | string | | Sourcemap directories used by the flamegraph tool. | Default: `dist` | +| `PPROF_FLAME_MD_FORMAT` | boolean | | Markdown format flag for the pprof flamegraph script. | Optional (tooling) | +| `PPROF_GENERATE_FLAME` | boolean | | Generate a flamegraph from a captured pprof profile. | Optional (tooling) | +| `PPROF_NODE_MODULES_SOURCE_MAPS` | boolean | | Include `node_modules` sourcemaps in flame output. | Optional (tooling) | +| `PPROF_OUTPUT` | path | | Output path for the pprof script. | Optional (tooling) | +| `PPROF_SECONDS` | integer | | Profile duration (seconds) for the pprof script. | Optional (tooling) | +| `PPROF_SOURCE_MAPS` | boolean | | Use sourcemaps when symbolicating pprof output. | Optional (tooling) | +| `PPROF_WORKER_ID` | string | | Worker id targeted by the pprof script. | Optional (tooling) | + +--- + +## Edge Functions + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ALL_PROXY` / `all_proxy` | URL | | Default outbound proxy for all schemes for `fetch()` from user functions. | Read by `vendor/deno_fetch/proxy.rs` | +| `DENO_AUTH_TOKENS` | string | | Authentication tokens used when fetching remote modules (`@` syntax). | Read by `deno/file_fetcher.rs` | +| `DENO_CERT` | path | | Path to a PEM file with extra CA certificates loaded into Deno's TLS store. | Read by `ext/runtime/cert.rs` | +| `DENO_DIR` | path | | Override location of Deno's module/transpile cache directory. | Defaults to OS cache dir + `/deno` | +| `DENO_DISABLE_PEDANTIC_NODE_WARNINGS` | boolean | | Suppress pedantic Node.js compatibility warnings. | Read by `deno/args/mod.rs` | +| `DENO_FETCH_TIMEOUT_SECS` | integer (seconds) | | Timeout (seconds) for HTTP fetches made by the runtime when resolving/downloading modules. | No default | +| `DENO_NO_DEPRECATION_WARNINGS` | boolean | | Disable Deno API deprecation warnings. | Read at startup via `cli/src/env.rs` | +| `DENO_NO_PACKAGE_JSON` | boolean | | Disable auto-discovery of `package.json`. | Read by `deno/lib.rs` (set to `1`) | +| `DENO_REPL_HISTORY` | path | | REPL history file path (REPL isn't exposed by edge-runtime, but the var is read by embedded Deno). | Read by `deno/cache/deno_dir.rs` | +| `DENO_TCP_KEEPALIVE_SECS` | integer (seconds) | | TCP keepalive duration (seconds) for outbound `fetch()` connections. | Default: `30` | +| `DENO_TLS_CA_STORE` | string (CSV) | | Comma-separated list of TLS root stores to use (`mozilla`, `system`). | Default: `mozilla` | +| `DENO_USE_WRITEV` | boolean | | Enable `writev` for HTTP responses (perf experiment). | Default: off | +| `DENO_VERBOSE_WARNINGS` | boolean | | Emit verbose stack traces on deprecation warnings. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_ALLOC_CHECK_INT` | integer (ms) | | Interval (ms) between memory allocation checks for user workers. | Default: `1000` | +| `EDGE_RUNTIME_BUNDLE_CHECKSUM` | enum | | Default hash kind for the `bundle` subcommand (`sha256`, `xxhash3`, or `nochecksum`). | Wired to `--checksum` flag | +| `EDGE_RUNTIME_EVENT_WORKER_INITIAL_HEAP_SIZE_MIB` | integer (MB) | | V8 initial heap size (MiB) for the event worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_EVENT_WORKER_MAX_HEAP_SIZE_MIB` | integer (MB) | | V8 max heap size (MiB) for the event worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_INCLUDE_MALLOCED_MEMORY_ON_MEMCHECK` | boolean | | If truthy, include `malloced_memory` in the per-worker memory limit check. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_MAIN_WORKER_INITIAL_HEAP_SIZE_MIB` | integer (MB) | | V8 initial heap size (MiB) for the main worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_MAIN_WORKER_MAX_HEAP_SIZE_MIB` | integer (MB) | | V8 max heap size (MiB) for the main worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_PORT` | integer | Both | Port to listen on. | Wired to `--port`/`-p` flag; default `9000` | +| `EDGE_RUNTIME_PRIMARY_WORKER_POOL_SIZE` | integer (count) | | Tokio LocalPool size for the main + event workers. | Default: `1` | +| `EDGE_RUNTIME_TLS` | integer | Both | TLS listening port (presence enables TLS). | Wired to `--tls` flag; default-missing-value `443` | +| `EDGE_RUNTIME_TLS_CERT_PATH` | path | Both | Path to PEM X.509 certificate (when TLS enabled). | Wired to `--cert` flag | +| `EDGE_RUNTIME_TLS_KEY_PATH` | path | Both | Path to PEM-encoded private key (when TLS enabled). | Wired to `--key` flag | +| `EDGE_RUNTIME_WORKER_POOL_SIZE` | integer (count) | | Tokio LocalPool size for the user worker pool. | Default: `available_parallelism()` in release | +| `EXT_AI_CACHE_DIR` | path | | Directory used to cache ONNX model files downloaded by `Supabase.ai`. | Defaults to OS cache dir | +| `HTTP_PROXY` / `http_proxy` | URL | | HTTP outbound proxy for `fetch()` from user functions. | Read by `vendor/deno_fetch/proxy.rs` | +| `HTTPS_PROXY` / `https_proxy` | URL | | HTTPS outbound proxy for `fetch()` and for the S3 filesystem backend. | Read by `vendor/deno_fetch/proxy.rs` and `crates/fs/impl/s3_fs.rs` | +| `JSR_URL` | URL | | Override JSR (`jsr.io`) registry base URL. | Default: `https://jsr.io/` | +| `JWT_SECRET` | JWT | Self-hosted | Legacy HS256 symmetric secret. Used by the bundled main service to verify legacy JWTs and injected into user functions. | Consumed by `docker/volumes/functions/main/index.ts` | +| `NO_PROXY` / `no_proxy` | string (CSV) | | Comma-separated bypass list for proxy variables. | Read by `vendor/deno_fetch/proxy.rs` | +| `NPM_CONFIG_REGISTRY` | URL | | Override the npm registry base URL used to resolve `npm:` specifiers. | Default: `https://registry.npmjs.org` | +| `OMP_NUM_THREADS` | integer (count) | | Number of intra-op threads for the ONNX runtime used by `Supabase.ai`. | Default: `1` | +| `OTEL_EXPORTER_OTLP_CERTIFICATE` | path | | Path to PEM CA file used to verify the OTLP collector's TLS certificate. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` | path | | Client cert for mTLS to the OTLP collector. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_CLIENT_KEY` | path | | Client key for mTLS to the OTLP collector. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | URL | | OTLP collector endpoint. Setting it enables both runtime-level OTel and the OTLP exporter. | Presence required to enable telemetry | +| `OTEL_EXPORTER_OTLP_HEADERS` | string (CSV) | | Comma-separated headers attached to OTLP exports. | Picked up automatically by the OTLP SDK | +| `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | enum | | OTel metrics temporality (`cumulative`, `delta`, `lowmemory`). | Default: `cumulative` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | enum | | OTLP protocol (`http/protobuf` or `http/json`). | Default: `http/protobuf` | +| `OTEL_METRIC_EXPORT_INTERVAL` | integer (ms) | | Metric export interval in milliseconds. | Default: `60000` | +| `OTEL_RESOURCE_ATTRIBUTES` | string (CSV) | | Comma-separated `key=value` attributes added to every span/metric/log. | Picked up automatically by the OTLP SDK | +| `OTEL_SERVICE_NAME` | string | | `service.name` resource attribute used by the OTel exporter. | Picked up automatically by the OTLP SDK | +| `RUST_LOG` | string | | Filter directive for the Rust logger (e.g. `info`, `base=debug`, `trace`). | Read by `env_logger` / `tracing-subscriber` | +| `SUPABASE_ANON_KEY` | JWT | Both | Public ("anonymous") Supabase API key. Injected for user functions to call the public API. | Injected for user functions | +| `SUPABASE_DB_URL` | URL | Both | Postgres connection string. Injected for user functions that connect directly to Postgres. | Injected for user functions | +| `SUPABASE_INTERNAL_FUNCTIONS_CONFIG` | JSON | CLI | JSON map of per-function options (e.g. `verify_jwt`, `import_map_path`) consumed by the CLI's bundled main service. | Set by CLI; consumed by main service | +| `SUPABASE_INTERNAL_HOST_PORT` | integer | CLI | Local API port the CLI's bundled main service forwards requests to. | Set by CLI | +| `SUPABASE_INTERNAL_JWT_SECRET` | JWT | CLI | HS256 secret used by the CLI's bundled main service to verify JWTs from the local stack. | Set by CLI | +| `SUPABASE_INTERNAL_PUBLISHABLE_KEY` | string | CLI | Opaque API key (publishable) used internally by the CLI's bundled main service. | Set by CLI | +| `SUPABASE_INTERNAL_SECRET_KEY` | string | CLI | Opaque API key (secret) used internally by the CLI's bundled main service. | Set by CLI | +| `SUPABASE_JWKS` | JWKS | CLI | JSON Web Key Set (asymmetric + legacy symmetric) used by the bundled main service to verify user JWTs. | Self-hosted can derive this from `SUPABASE_URL`'s `/auth/v1/.well-known/jwks.json`. | +| `SUPABASE_PUBLIC_URL` | URL | Self-hosted | External/public URL of the Supabase project. Injected for user functions. | Injected for user functions | +| `SUPABASE_PUBLISHABLE_KEYS` | JSON | Self-hosted | JSON map of opaque publishable API keys (new asymmetric-key format). | Injected for user functions | +| `SUPABASE_SECRET_KEYS` | JSON | Self-hosted | JSON map of opaque secret API keys (new asymmetric-key format). Never expose to client code. | Injected for user functions | +| `SUPABASE_SERVICE_ROLE_KEY` | JWT | Both | `service_role` API key (full database access). Injected for user functions for privileged calls. | Injected for user functions | +| `SUPABASE_URL` | URL | Both | Internal Supabase API URL (Kong gateway hostname in self-hosted setups). Injected for user functions. | Injected for user functions | +| `V8_FLAGS` | string | | Space-separated V8 command-line flags applied at startup (e.g. `--max-old-space-size=256`). | Read by `crates/base/src/runtime/mod.rs` | +| `VERIFY_JWT` | boolean | Self-hosted | If `true`, the bundled main service rejects requests whose JWT does not verify against `JWT_SECRET`/`SUPABASE_JWKS`. Applies to all functions. | Read by `docker/volumes/functions/main/index.ts`; supplied via `FUNCTIONS_VERIFY_JWT` in `.env.example` | + +--- + +## Analytics + +> The `analytics` container runs [logflare/logflare](github.com/Logflare/logflare), an Elixir/Phoenix application. Almost all runtime env reads live in [config/runtime.exs](https://github.com/Logflare/logflare/blob/main/config/runtime.exs). Self-hosted Supabase runs it in single-tenant Supabase mode with the Postgres backend; BigQuery support is available but commented out in `docker-compose.yml`. The container is the consumer of `LOGFLARE_PUBLIC_ACCESS_TOKEN`/`LOGFLARE_PRIVATE_ACCESS_TOKEN`. + +> **Heads-up - always-on admin UI:** Logflare's admin pages under `/admin/*` (sources, accounts, cluster view) **are reachable by default**. `LOGFLARE_SUPABASE_MODE=true` provisions an auto-admin user, and the `/admin/*` routes are gated by an auth pipeline rather than an env var - there is no flag to disable them. +> +> If the `analytics` container is exposed beyond your private Docker network, **block** `/admin/*` at the reverse proxy or API gateway level. + +> Analytics (Logflare) upstream self-hosting docs: [docs.logflare.app/self-hosting](https://docs.logflare.app/self-hosting/). + +### Self-host mode + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_SINGLE_TENANT` | boolean | Both | Run Logflare in single-tenant mode (no per-tenant isolation, no signup flow). | Default: `true`. Self-hosted: `true` | +| `LOGFLARE_SUPABASE_MODE` | boolean | Both | Enable the Supabase preset: auto-creates the default source, wires the `analytics` container to the Supabase stack. | Default: `false`. Self-hosted: `true` | +| `LOGFLARE_PUBLIC_ACCESS_TOKEN` | string | Both | Public API token used by ingestion clients (e.g. the `vector` container) to push log events. Falls back to `LOGFLARE_API_KEY`. | Required in single-tenant mode | +| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | string | Both | Private API token used by Studio server-side (and the management API) to query logs and run analytics endpoints. | Required in single-tenant mode | +| `LOGFLARE_FEATURE_FLAG_OVERRIDE` | string | Both | Comma-separated `key=value` pairs overriding feature flags at boot. Self-hosted sets `multibackend=true` so the Postgres backend is reachable. | E.g. `multibackend=true` | +| `LOGFLARE_NODE_HOST` | string | Both | Hostname used to form the Erlang `RELEASE_NODE` (`@`). The single-node default is fine for most self-hosted setups. | Default: `127.0.0.1`. | +| `LOGFLARE_API_KEY` | string | | Legacy fallback name for `LOGFLARE_PUBLIC_ACCESS_TOKEN`. | Deprecated; prefer `LOGFLARE_PUBLIC_ACCESS_TOKEN` | + +### Internal database (metadata) + +> These configure Logflare's own metadata Postgres connection (tenants, sources, endpoints). Self-hosted points them at the shared `supabase-db` container, schema `_analytics`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DB_HOSTNAME` | string | Both | Hostname for Logflare's metadata Postgres. | Self-hosted: from `POSTGRES_HOST` | +| `DB_PORT` | integer | Both | Port for Logflare's metadata Postgres. | Self-hosted: from `POSTGRES_PORT` | +| `DB_DATABASE` | string | Both | Database name for Logflare's metadata Postgres. | Self-hosted: `_supabase` | +| `DB_SCHEMA` | string | Both | Postgres schema used for Logflare metadata tables (set as `search_path`). | Self-hosted: `_analytics` | +| `DB_USERNAME` | string | Both | Postgres user for Logflare's metadata connection. | Self-hosted: `supabase_admin` | +| `DB_PASSWORD` | string | Both | Postgres password for the metadata connection. | Self-hosted: from `POSTGRES_PASSWORD` | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Ecto connection pool size for the metadata Postgres. | Default: `10` | +| `DB_SSL` | boolean | Self-hosted | Enable SSL/TLS for the metadata Postgres connection (requires cert files). | Default: `false` | + +### Postgres backend (log storage) + +> When `LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true`, Logflare stores log events in a separate Postgres backend rather than BigQuery. Self-hosted points this at the same `db` container, schema `_analytics`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `POSTGRES_BACKEND_URL` | URL | Both | Connection URL for the Postgres log-storage backend. | Required when `multibackend=true` | +| `POSTGRES_BACKEND_SCHEMA` | string | Both | Schema in the Postgres backend that holds log tables. | Self-hosted: `_analytics` | + +### BigQuery backend (log storage) + +> Disabled in the default self-hosted compose. To use BigQuery, comment out `POSTGRES_BACKEND_URL` / `POSTGRES_BACKEND_SCHEMA` / `LOGFLARE_FEATURE_FLAG_OVERRIDE` in `docker-compose.yml`, mount a `gcloud.json` service-account key, and set `GOOGLE_PROJECT_ID` and `GOOGLE_PROJECT_NUMBER` in the `.env` file. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOOGLE_PROJECT_ID` | string | Self-hosted | Google Cloud project ID hosting the BigQuery dataset. | Commented out in compose | +| `GOOGLE_PROJECT_NUMBER` | string | Self-hosted | Numeric Google Cloud project number. | Commented out in compose | +| `GOOGLE_DATASET_ID_APPEND` | string | Self-hosted | Suffix appended to BigQuery dataset IDs. | Default: `_default` | +| `GOOGLE_DATASET_LOCATION` | string | Self-hosted | BigQuery dataset location (e.g. `US`, `EU`). | Default: `US` | +| `GOOGLE_SERVICE_ACCOUNT` | string | Self-hosted | Service-account email used for BigQuery operations. | | +| `LOGFLARE_BIGQUERY_MANAGED_SA_POOL` | integer (count) | Self-hosted | Number of managed service accounts in the BigQuery SA pool. | Default: `0` | +| `LOGFLARE_BQ_WRITE_API_POOL_SIZE` | integer (count) | Self-hosted | Connection pool size for the BigQuery Write API. | Default: `10` | + +### Server / Phoenix endpoint + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PHX_HTTP_PORT` | integer | Self-hosted | HTTP port the Phoenix endpoint binds to. | Default: `4000` | +| `PHX_HTTP_IP` | string | Self-hosted | Bind IP for the HTTP endpoint. | | +| `PHX_URL_HOST` | string | Self-hosted | External host used to build absolute URLs. | | +| `PHX_URL_SCHEME` | string | Self-hosted | URL scheme (`http`/`https`). | | +| `PHX_URL_PORT` | integer | Self-hosted | External URL port. | | +| `PHX_SECRET_KEY_BASE` | string | Self-hosted | Phoenix session signing/encryption key. | Required in production; baked into the image for self-host | +| `PHX_CHECK_ORIGIN` | string (CSV) | Self-hosted | Comma-separated list of allowed origins for CSRF check. | | +| `PHX_LIVE_VIEW_SIGNING_SALT` | string | Self-hosted | Salt used for Phoenix LiveView token signing. | | +| `LOGFLARE_GRPC_PORT` | integer | Self-hosted | Port for the gRPC server (used for trace ingestion / OTLP). | Default: `50051` | +| `LOGFLARE_ENABLE_GRPC_SSL` | boolean | Self-hosted | Enable TLS for the gRPC server. | Default: `false` | +| `LOGFLARE_ENABLE_LIVE_DASHBOARD` | boolean | Self-hosted | Expose Phoenix LiveDashboard at `/admin`. | Default: `false` | +| `LOGFLARE_HTTP_CONNECTION_POOLS` | string (CSV) | Self-hosted | Comma-separated list of HTTP pool providers to enable. | Default: `all` | +| `LOGFLARE_PUBSUB_POOL_SIZE` | integer (count) | Self-hosted | PubSub connection pool size. | Default: `56` | +| `LOGFLARE_NODE_SHUTDOWN_CODE` | string | Self-hosted | Shutdown identifier code. | | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_LOG_LEVEL` | enum | CLI | Logger level (`debug`, `info`, `warning`, `error`). | Default: `info` | +| `LOGFLARE_LOGGER_JSON` | boolean | Self-hosted | Emit JSON-formatted log lines instead of plain text. | Default: `false` | +| `LOGFLARE_LOGGER_BACKEND_URL` | URL | Self-hosted | URL of a remote Logflare logger backend (forwards Logflare's own logs there). | | +| `LOGFLARE_LOGGER_BACKEND_API_KEY` | string | Self-hosted | API key for the remote logger backend. | | +| `LOGFLARE_LOGGER_BACKEND_SOURCE_ID` | string | Self-hosted | Source ID for the remote logger backend. | | + +### Telemetry / Observability + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_OTEL_ENDPOINT` | URL | Self-hosted | OTLP collector endpoint. Presence enables tracing. | | +| `LOGFLARE_OTEL_SAMPLE_RATIO` | number (ratio) | Self-hosted | Default sampling ratio (0.0-1.0) for OTel traces. | Default: `1.0` | +| `LOGFLARE_OTEL_INGEST_SAMPLE_RATIO` | number (ratio) | Self-hosted | Sampling ratio for ingest-path traces (falls back to default). | | +| `LOGFLARE_OTEL_ENDPOINT_SAMPLE_RATIO` | number (ratio) | Self-hosted | Sampling ratio for endpoint-path traces (falls back to default). | | +| `LOGFLARE_OTEL_SOURCE_UUID` | string | Self-hosted | Source UUID header attached to OTel exports. | | +| `LOGFLARE_OTEL_ACCESS_TOKEN` | string | Self-hosted | Access token header attached to OTel exports. | | +| `LOGFLARE_HEALTH_MAX_MEMORY_UTILIZATION` | number (ratio) | Self-hosted | Memory utilization threshold (0.0-1.0) reported by the health check. | Default: `0.80` | +| `LOGFLARE_ALERTS_ENABLED` | boolean | Self-hosted | Enable the alerting subsystem. | Default: `true` | + +### Encryption + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_DB_ENCRYPTION_KEY` | string | Self-hosted | Primary base64 key used to encrypt sensitive columns. | Image fallback baked in | +| `LOGFLARE_DB_ENCRYPTION_KEY_RETIRED` | string | Self-hosted | Previously-active key, kept for decryption during rotation. | | + +--- + +## Postgres + +> The `db` container runs the `supabase/postgres` image, a fork of the official `postgres` image that adds Supabase-specific extensions (`pgsodium`, `pg_graphql`, `pgjwt`, etc.), default roles, and seed migrations. Most variables are inherited from the upstream `postgres` image and read by its `docker-entrypoint.sh` on first boot (initdb). A few are added by the Supabase fork or by init SQL that the self-hosted compose mounts into `/docker-entrypoint-initdb.d/init-scripts/`. + +### Core (inherited from upstream `postgres` image) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `POSTGRES_PASSWORD` | string | Both | Password for the `POSTGRES_USER` superuser. Set on first boot during `initdb`. | Required unless `POSTGRES_HOST_AUTH_METHOD=trust` | +| `POSTGRES_USER` | string | CLI | Username for the initial superuser. The Supabase image overrides this. | Default: `supabase_admin` (Supabase image) | +| `POSTGRES_DB` | string | Both | Name of the first database to create. | Default: `postgres` | +| `POSTGRES_HOST` | string | Both | Unix socket directory or hostname Postgres listens on. The Supabase image hardcodes the socket path. | Default: `/var/run/postgresql` (Supabase image) | +| `POSTGRES_PORT` | integer | Self-hosted | TCP port Postgres listens on (Supabase migration scripts also read this). | Default: `5432` | +| `POSTGRES_INITDB_ARGS` | string | CLI | Extra arguments passed to `initdb` (locale, encoding, etc.). | Default in CLI: `--allow-group-access --locale-provider=icu --encoding=UTF-8 --icu-locale=en_US.UTF-8` | +| `POSTGRES_INITDB_WALDIR` | path | | Separate filesystem path used by `initdb` for the WAL directory. | When unset, WAL lives inside `PGDATA` | +| `POSTGRES_HOST_AUTH_METHOD` | enum | | Default `pg_hba.conf` authentication method (e.g. `trust`, `scram-sha-256`). | Defaults to `scram-sha-256` (Postgres 14+) | +| `PGDATA` | path | CLI | Data directory used by Postgres. | Default: `/var/lib/postgresql/data` | + +### libpq client variables (read by Supabase migration scripts on init) + +> The Supabase image's `migrations/db/migrate.sh` runs at first boot and reads the standard libpq env vars rather than the `POSTGRES_*` ones. The compose file sets both so the entrypoint and the migration runner both work. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGPORT` | integer | Self-hosted | TCP port for the migration runner's psql connection. | Self-hosted: mirrors `POSTGRES_PORT` | +| `PGPASSWORD` | string | Self-hosted | Password for the migration runner's psql connection. | Self-hosted: mirrors `POSTGRES_PASSWORD` | +| `PGDATABASE` | string | Self-hosted | Database name used by the migration runner. | Self-hosted: mirrors `POSTGRES_DB` | +| `PGHOST` | string | | Host used by the migration runner (defaults to socket path inside the container). | Self-hosted relies on `POSTGRES_HOST` | + +### Supabase init SQL (mounted by docker-compose) + +> These are consumed by SQL scripts the self-hosted compose mounts into `/docker-entrypoint-initdb.d/init-scripts/`. They are *not* read by the `supabase/postgres` image itself - they are read by init SQL under `docker/volumes/db/` (the orchestration layer). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `JWT_SECRET` | string | Both | HS256 secret stored as `app.settings.jwt_secret` on the `postgres` database. Read by `volumes/db/jwt.sql`. PostgREST and pgjwt-using functions read it via `current_setting()`. | Required. Sourced from `JWT_SECRET` in `.env.example` | +| `JWT_EXP` | integer (seconds) | Both | Default JWT expiry (seconds) stored as `app.settings.jwt_exp` on the `postgres` database. Read by `volumes/db/jwt.sql`. | Sourced from `JWT_EXPIRY` in `.env.example` | + +--- + +## Supavisor + +> Supavisor's upstream env-var reference is at [supabase/supavisor/docs/configuration/env.md](https://github.com/supabase/supavisor/blob/main/docs/configuration/env.md). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADDR_TYPE` | enum | | Socket address family for the HTTP endpoint. Must be `inet` or `inet6`. | Default: `inet` | +| `API_JWT_SECRET` | string | Self-hosted | JWT secret used to authenticate requests to Supavisor's management API. | Self-hosted sets this to `JWT_SECRET` | +| `API_TOKEN_BLOCKLIST` | string (CSV) | | Comma-separated list of API JWTs to reject. | Default: empty | +| `CACHE_BYPASS_USERS` | string (CSV) | | Comma-separated list of DB users that bypass the auth-query cache. | Default: empty | +| `CLUSTER_ID` | string | | Region identifier used in the libcluster Postgres channel name. First of `CLUSTER_ID`, `LOCATION_ID`, `REGION` wins. | Only used when `CLUSTER_POSTGRES` is set | +| `CLUSTER_NODES` | string (CSV) | | Comma-separated list of Erlang node names for static EPMD clustering. | Optional | +| `CLUSTER_POSTGRES` | boolean | Self-hosted | Enables libcluster Postgres strategy (heartbeats via `pg_notify`). | Set to `true` to enable. Self-hosted: `true` | +| `DATABASE_URL` | URL | Self-hosted | Ecto URL for Supavisor's metadata database (tenants, users). Also used by the Postgres clustering strategy. | Default: `ecto://postgres:postgres@localhost:6432/postgres` | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Pool size for Supavisor's internal metadata Ecto repo. | Default: `25`. Self-hosted: from `POOLER_DB_POOL_SIZE` (default `5`) | +| `DEBUG_LOAD_RUNTIME_CONFIG` | boolean | | If set, hot-upgrade loads `config/runtime.exs` from CWD instead of the release dir. | Debug only | +| `DNS_POLL` | string | | DNS name to poll for libcluster `DNSPoll` strategy. | Optional | +| `DOWNSTREAM_SERVER_ECDSA_CERT` | path | | Path to ECDSA certificate file served to downstream clients. | Optional, file must exist | +| `DOWNSTREAM_SERVER_ECDSA_KEY` | path | | Path to ECDSA private key file served to downstream clients. | Optional, file must exist | +| `GLOBAL_DOWNSTREAM_CERT_PATH` | path | | Path to TLS certificate file served to downstream Postgres clients. | Optional, file must exist | +| `GLOBAL_DOWNSTREAM_KEY_PATH` | path | | Path to TLS private key file served to downstream Postgres clients. | Optional, file must exist | +| `GLOBAL_UPSTREAM_CA_PATH` | path | | Path to upstream CA bundle used to verify upstream Postgres TLS certificates. | Optional | +| `INSTANCE_ID` | string | | Instance identifier added to logger metadata. | Optional | +| `JWT_CLAIM_VALIDATORS` | JSON | | JSON object of additional JWT claims to validate (e.g. `{"iss":"supabase"}`). | Default: `{}` | +| `LOCATION_ID` | string | | Region identifier used in the libcluster Postgres channel name. Falls back to `REGION` if unset. | Only used when `CLUSTER_POSTGRES` is set | +| `LOCATION_KEY` | string | | Location label added to logger metadata. Falls back to `region` if unset. | Optional | +| `LOGFLARE_API_KEY` | string | | Logflare API key. Required when `LOGS_ENGINE=logflare`. | Required when `LOGS_ENGINE=logflare`; optional otherwise | +| `LOGFLARE_SOURCE_ID` | string | | Logflare source ID. Required when `LOGS_ENGINE=logflare`. | Required when `LOGS_ENGINE=logflare`; optional otherwise | +| `LOGS_ENGINE` | string | | Logging backend. Set to `logflare` to enable the Logflare HTTP logger backend. | Optional | +| `MAX_CONNECTIONS` | integer (count) | | Max concurrent connections accepted by the HTTP endpoint and Ranch proxy listeners. | Default: `1000` (HTTP), `:infinity` (proxy listeners) | +| `METRICS_DISABLED` | boolean | | If `true`, disables Prometheus metrics children (PromEx, TenantsMetrics, MetricsCleaner). | Default: `false` | +| `METRICS_JWT_SECRET` | string | Self-hosted | JWT secret used to authenticate requests to the metrics endpoint. | Self-hosted sets this to `JWT_SECRET` | +| `METRICS_TOKEN_BLOCKLIST` | string (CSV) | | Comma-separated list of metrics JWTs to reject. | Default: empty | +| `NAMED_PREPARED_STATEMENTS_ENABLED` | boolean | | Feature flag enabling named prepared statement support in transaction mode. | Default: `false`. Accepts `true`/`false`/`1`/`0` | +| `NODE_IP` | string | | IP address used to build the Erlang `RELEASE_NODE` (`@`). Also stored as `node_host` in app config. | Default: `127.0.0.1`. In Fly, falls back to the `fly-local-6pn` entry in `/etc/hosts` | +| `NODE_NAME` | string | | Erlang node basename. Used as `@` for the release node. | Falls back to `FLY_APP_NAME`, then `supavisor` | +| `NO_WARM_POOL_USERS` | string (CSV) | | Comma-separated list of DB users for which Supavisor should not pre-warm a pool. | Default: empty | +| `NUM_ACCEPTORS` | integer (count) | | Number of acceptor processes per Ranch listener (HTTP endpoint and proxy listeners). | Default: `100` | +| `PORT` | integer | Self-hosted | HTTP port for the Phoenix endpoint (health, metrics, management API). | Default: `4000`. Self-hosted: `4000` | +| `POOLER_DEFAULT_POOL_SIZE` | integer (count) | Self-hosted | Default upstream pool size for the bootstrapped tenant. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POOLER_MAX_CLIENT_CONN` | integer (count) | Self-hosted | Maximum client connections for the bootstrapped tenant. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POOLER_POOL_MODE` | enum | Self-hosted | Pool mode for the bootstrapped tenant's user (`transaction` or `session`). Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Self-hosted hard-codes `transaction` | +| `POOLER_TENANT_ID` | string | Self-hosted | External tenant ID created at first startup. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Required | +| `POSTGRES_DB` | string | Self-hosted | Tenant Postgres database name. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POSTGRES_HOST` | string | Self-hosted | Tenant Postgres host. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Default in script: `db` | +| `POSTGRES_PASSWORD` | string | Self-hosted | Tenant Postgres password for the `pgbouncer` auth user. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POSTGRES_PORT` | integer | Self-hosted | Tenant Postgres port. Read by the self-hosted `pooler.exs` provisioning script and used to map the session-mode listener port. | Configured via pooler.exs | +| `PROM_POLL_RATE` | integer (ms) | | Prometheus metrics poll interval in milliseconds. | Default: `15000` | +| `PROXY_PORT` | integer | | Generic Postgres proxy listener port (mode: `proxy`). | Default: `5412` | +| `PROXY_PORT_SESSION` | integer | | Postgres session-mode proxy listener port. | Default: `5432` | +| `PROXY_PORT_TRANSACTION` | integer | | Postgres transaction-mode proxy listener port. | Default: `6543` | +| `REGION` | string | Self-hosted | Region label used in logger metadata and as the default for `LOCATION_KEY`. Also used in the libcluster Postgres channel name when `CLUSTER_ID`/`LOCATION_ID` are unset. | Falls back to `FLY_REGION`. Self-hosted: `local` | +| `RELEASE_COOKIE` | string | | Erlang distribution cookie. Read by the release scripts. | Optional | +| `RELEASE_DISTRIBUTION` | string | | Erlang release distribution mode. Set to `name` by `rel/env.sh.eex`. | Default: `name` | +| `RELEASE_NODE` | string | | Erlang release node name (`@`). Set by `rel/env.sh.eex`. | Computed at startup | +| `RELEASE_ROOT` | path | | Release root directory. Used to locate `runtime.exs` during hot upgrades. | Set by the release scripts | +| `RLIMIT_NOFILE` | integer (count) | | Open-file descriptor limit applied by the container entrypoint (`limits.sh`). | Default: `100000` (baked into image) | +| `SECRET_KEY_BASE` | string | Self-hosted | Phoenix endpoint secret used to sign/encrypt session and CSRF tokens. | Required in non-dev/test envs | +| `SESSION_PROXY_PORTS` | string (CSV) | | Comma-separated list of additional internal session-mode proxy listener ports. | Default: `12100,12101,12102,12103` | +| `SUBSCRIBE_RETRIES` | integer (count) | | Number of retries when subscribing to a tenant pool. | Default: `20` | +| `SUPAVISOR_DB_IP_VERSION` | enum | | Socket family for upstream Postgres connections. Set to `ipv6` to use `inet6`. | Default: `ipv4` (`inet`) | +| `SUPAVISOR_LOG_FILE_PATH` | path | | If set, the default logger writes logs to this file (rotated, 8 MiB each, 5 files). | Optional | +| `SUPAVISOR_LOG_FORMAT` | enum | | Set to `json` to emit logs in Logflare JSON format. | Optional | +| `SWITCH_ACTIVE_COUNT` | integer (count) | | Number of activity ticks before a pool is switched out. | Default: `100` | +| `TRANSACTION_PROXY_PORTS` | string (CSV) | | Comma-separated list of additional internal transaction-mode proxy listener ports. | Default: `12104,12105,12106,12107` | +| `VAULT_ENC_KEY` | string | Self-hosted | AES.GCM encryption key for the Cloak vault used to encrypt tenant credentials at rest. | Required | diff --git a/README.md b/README.md new file mode 100644 index 0000000..56ebaf0 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +
+ +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment) + +
+ +# Self-Hosted Supabase with Docker + +This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure. + +## Getting Started + +Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker) + +The guide covers: +- Prerequisites (Git and Docker) +- Initial setup and configuration +- Securing your installation +- Accessing services +- Updating your instance + +## What's Included + +This Docker Compose configuration includes the following services: + +- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project +- **[Kong](https://github.com/Kong/kong)** - Kong API gateway +- **[Auth](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management +- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API +- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets +- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions +- **[imgproxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server +- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries) +- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development +- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services +- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform +- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs +- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler + +## Documentation + +- **[Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides +- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services +- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference +- **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration +- **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables + +## Updates + +To update your self-hosted Supabase instance: + +1. Review [CHANGELOG.md](./CHANGELOG.md) for breaking changes +2. Check [versions.md](./versions.md) for new image versions +3. Update `docker-compose.yml` if there are configuration changes +4. Pull the latest images: `docker compose pull` +5. Stop services: `docker compose down` +6. Start services with new configuration: `docker compose up -d` + +**Note:** Consider to always backup your database before updating. + +## Community & Support + +For troubleshooting common issues, see: +- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds +- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues +- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides + +Self-hosted Supabase is community-supported. Get help and connect with other users: + +- [Discord](https://discord.supabase.com) - Real-time chat and community support +- [Reddit](https://www.reddit.com/r/Supabase/) - Official Supabase subreddit + +Share your self-hosting experience: + +- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - "Self-hosting: What's working (and what's not)?" + +## Important Notes + +### Security + +⚠️ **The default configuration is not secure for production use.** + +Before deploying to production, you must: +- [Update](https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase) all default passwords and secrets in the `.env` file +- Review and update CORS settings +- Consider setting up a secure proxy in front of self-hosted Supabase +- Review and adjust network security configuration (ACLs, etc.) +- Set up proper backup procedures + +See the [main installation guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos in the documentation. + +## License + +This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details. diff --git a/dev/data.sql b/dev/data.sql new file mode 100644 index 0000000..2328004 --- /dev/null +++ b/dev/data.sql @@ -0,0 +1,48 @@ +create table profiles ( + id uuid references auth.users not null, + updated_at timestamp with time zone, + username text unique, + avatar_url text, + website text, + + primary key (id), + unique(username), + constraint username_length check (char_length(username) >= 3) +); + +alter table profiles enable row level security; + +create policy "Public profiles are viewable by the owner." + on profiles for select + using ( auth.uid() = id ); + +create policy "Users can insert their own profile." + on profiles for insert + with check ( auth.uid() = id ); + +create policy "Users can update own profile." + on profiles for update + using ( auth.uid() = id ); + +-- Set up Realtime +begin; + drop publication if exists supabase_realtime; + create publication supabase_realtime; +commit; +alter publication supabase_realtime add table profiles; + +-- Set up Storage +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." + on storage.objects for select + using ( bucket_id = 'avatars' ); + +create policy "Anyone can upload an avatar." + on storage.objects for insert + with check ( bucket_id = 'avatars' ); + +create policy "Anyone can update an avatar." + on storage.objects for update + with check ( bucket_id = 'avatars' ); diff --git a/dev/docker-compose.dev.yml b/dev/docker-compose.dev.yml new file mode 100644 index 0000000..e6b2e76 --- /dev/null +++ b/dev/docker-compose.dev.yml @@ -0,0 +1,44 @@ +version: "3.8" + +services: + studio: + build: + context: .. + dockerfile: apps/studio/Dockerfile + target: dev + ports: + - 8082:8082 + develop: + watch: + - action: sync + path: ../apps/studio + target: /app/apps/studio + ignore: + - node_modules/ + - action: rebuild + path: package.json + + mail: + container_name: supabase-mail + image: inbucket/inbucket:3.0.3 + ports: + - '2500:2500' # SMTP + - '9000:9000' # web interface + - '1100:1100' # POP3 + auth: + environment: + - GOTRUE_SMTP_USER= + - GOTRUE_SMTP_PASS= + meta: + ports: + - 5555:8080 + db: + restart: 'no' + volumes: + # Always use a fresh database when developing + - /var/lib/postgresql/data + # Seed data should be inserted last (alphabetical order) + - ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql + storage: + volumes: + - /var/lib/storage diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml new file mode 100644 index 0000000..3551008 --- /dev/null +++ b/docker-compose.caddy.yml @@ -0,0 +1,53 @@ +services: + + # By default, Kong is used as the API gateway and its ports/env are reset + # below so Caddy can terminate TLS in front of it. + # + # When running Envoy instead, e.g.: + # docker compose -f docker-compose.yml -f docker-compose.envoy.yml \ + # -f docker-compose.caddy.yml up -d + # comment out the `kong:` block below and uncomment the `api-gw:` block + # (and the matching `depends_on` entry further down) so Caddy sits in front + # of Envoy rather than Kong. + + #api-gw: + # ports: !reset [] + + kong: + ports: !reset [] + environment: + KONG_PORT_MAPS: "443:8000,443:8443" + + caddy: + container_name: supabase-caddy + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + depends_on: + #api-gw: + # condition: service_healthy + kong: + condition: service_healthy + studio: + condition: service_healthy + environment: + PROXY_DOMAIN: ${PROXY_DOMAIN} + PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME} + PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD} + command: + - /bin/sh + - -c + - | + PROXY_AUTH_PASSWORD=$$(caddy hash-password --plaintext "$$PROXY_AUTH_PASSWORD") && \ + caddy run --config /etc/caddy/Caddyfile --adapter caddyfile + volumes: + - ./volumes/proxy/caddy:/etc/caddy + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.envoy.yml b/docker-compose.envoy.yml new file mode 100644 index 0000000..38391af --- /dev/null +++ b/docker-compose.envoy.yml @@ -0,0 +1,51 @@ +# Envoy override for Kong +# Usage: docker compose -f docker-compose.yml -f docker-compose.envoy.yml up + +services: + # Disable the original Kong service + kong: + profiles: + - disabled + + # Rewire dependencies that require Kong to Envoy + functions: + depends_on: !override + api-gw: + condition: service_healthy + + # Envoy API gateway + api-gw: + container_name: supabase-envoy + image: envoyproxy/envoy:v1.38.0 + restart: unless-stopped + ports: + - ${KONG_HTTP_PORT}:8000/tcp + volumes: + - ./volumes/api/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro + - ./volumes/api/envoy/cds.yaml:/etc/envoy/cds.yaml:ro + - ./volumes/api/envoy/lds.template.yaml:/etc/envoy/lds.template.yaml:ro + - ./volumes/api/envoy/docker-entrypoint.sh:/docker-entrypoint.sh:ro + depends_on: + studio: + condition: service_healthy + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: ["/bin/sh", "/docker-entrypoint.sh"] + healthcheck: + # Using a TCP port check because this image does not include curl or wget. + test: ["CMD-SHELL", "timeout 1 bash -c ' /etc/nginx/user_conf.d/dashboard-passwd && \ + envsubst '$${PROXY_DOMAIN}' < /etc/nginx/supabase-nginx.conf.tpl > /etc/nginx/user_conf.d/nginx.conf && \ + /scripts/start_nginx_certbot.sh + volumes: + - ./volumes/proxy/nginx/supabase-nginx.conf.tpl:/etc/nginx/supabase-nginx.conf.tpl:ro + - nginx_letsencrypt:/etc/letsencrypt + +volumes: + nginx_letsencrypt: diff --git a/docker-compose.pg17.yml b/docker-compose.pg17.yml new file mode 100644 index 0000000..d35f0b9 --- /dev/null +++ b/docker-compose.pg17.yml @@ -0,0 +1,15 @@ +# Postgres 17 override for self-hosted Supabase. +# +# Usage (new install or after upgrade): +# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d +# +# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first. +# +# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a +# leftover db-config volume from PG 15, you may see +# "FATAL: invalid secret key". Either remove the old volume or fix ownership. +# See: docs/guides/self-hosting/postgres-upgrade-17 + +services: + db: + image: supabase/postgres:17.6.1.084 diff --git a/docker-compose.rustfs.yml b/docker-compose.rustfs.yml new file mode 100644 index 0000000..147da67 --- /dev/null +++ b/docker-compose.rustfs.yml @@ -0,0 +1,47 @@ +services: + + rustfs: + image: rustfs/rustfs + environment: + RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER} + RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 + RUSTFS_CORS_ALLOWED_ORIGINS: "*" + RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*" + security_opt: + - "no-new-privileges:true" + healthcheck: + test: [ "CMD", "curl", "-f", "http://rustfs:9000/health" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - rustfs-data:/data + + rustfs-createbucket: + image: rustfs/rc + depends_on: + rustfs: + condition: service_healthy + entrypoint: > + /bin/sh -ec " + rc alias set supa-rustfs http://rustfs:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} && + rc mb --ignore-existing supa-rustfs/${GLOBAL_S3_BUCKET} + " + + storage: + depends_on: + rustfs-createbucket: + condition: service_completed_successfully + rustfs: + condition: service_healthy + environment: + STORAGE_BACKEND: s3 + GLOBAL_S3_ENDPOINT: http://rustfs:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER} + AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} + +volumes: + rustfs-data: diff --git a/docker-compose.s3.yml b/docker-compose.s3.yml new file mode 100644 index 0000000..165463f --- /dev/null +++ b/docker-compose.s3.yml @@ -0,0 +1,46 @@ +services: + + minio: + image: cgr.dev/chainguard/minio + #ports: + # - '9000:9000' + # - '9001:9001' + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "mc", "ready", "local" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - minio-data:/data + + minio-createbucket: + image: cgr.dev/chainguard/minio-client:latest-dev + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -ec " + mc alias set supa-minio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} && + mc mb --ignore-existing supa-minio/${GLOBAL_S3_BUCKET} + " + + storage: + depends_on: + minio-createbucket: + condition: service_completed_successfully + minio: + condition: service_healthy + environment: + STORAGE_BACKEND: s3 + GLOBAL_S3_ENDPOINT: http://minio:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER} + AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} + +volumes: + minio-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..65cc8b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,574 @@ +# Usage +# Start: docker compose up -d +# Stop: docker compose down +# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d +# Reset everything: sh reset.sh +# +# TODO: +# - Podman does not support nested variable interpolation (${A:-${B}}) +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.06.03-sha-0bca601 + restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\"" + ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 20s + environment: + # Listen on all IPv4 interfaces + HOSTNAME: "0.0.0.0" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + # See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access + #POSTGRES_USER_READ_WRITE: postgres + + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY} + + 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} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY} + + # See: docker-compose.logs.yml + ENABLED_FEATURES_LOGS_ALL: "false" + + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - ./volumes/snippets:/app/snippets:z + - ./volumes/functions:/app/edge-functions:ro,z + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + networks: + default: + aliases: + - api-gw + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + + depends_on: + studio: + condition: service_healthy + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z + #- ./volumes/api/server.crt:/home/kong/server.crt:ro + #- ./volumes/api/server.key:/home/kong/server.key:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + 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 + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + #KONG_SSL_CERT: /home/kong/server.crt + #KONG_SSL_CERT_KEY: /home/kong/server.key + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: /home/kong/kong-entrypoint.sh + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.189.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + # JSON array of signing JWKs (EC private + legacy symmetric) + # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} + GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true" + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true" + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + 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_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + # Uncomment to enable OAuth / social login providers. + + GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + # GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + # GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + # GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + # GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Uncomment to configure SMS delivery (phone auth and phone MFA). + # GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER} + # GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP} + # GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH} + # GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY} + # GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE} + + # Twilio credentials (when SMS_PROVIDER=twilio) + # GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID} + # GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN} + # GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID} + + # Test OTP mappings for development + # GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP} + + # Uncomment to configure multi-factor authentication (MFA). + # GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED} + # GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED} + # GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED} + # GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED} + # GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS} + + # SAML SSO + # GOTRUE_SAML_ENABLED: ${SAML_ENABLED} + # GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY} + # GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS} + # GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD} + # GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL} + # GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION} + + # Uncomment to enable custom access token hook. + # See: https://supabase.com/docs/guides/auth/auth-hooks for + # full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.12 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + 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_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + PGRST_DB_ANON_ROLE: anon + + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS. + # For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or + # PGRST_JWT_SECRET: ${JWT_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} + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.102.3 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + 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: supabaserealtime + + # Legacy symmetric HS256 key + API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: API_JWT_JWKS: ${JWT_JWKS} + API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + METRICS_JWT_SECRET: ${JWT_SECRET} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + DISABLE_HEALTHCHECK_LOGGING: "true" + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.60.4 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + start_period: 10s + environment: + 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). + # For Podman, use: JWT_JWKS: ${JWT_JWKS} + 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" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: "true" + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: ${REGION} + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration + S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} + S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.96.6 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + 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} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.74.0 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:z + - deno-cache:/root/.cache/deno + depends_on: + kong: + condition: service_healthy + environment: + # Legacy symmetric HS256 key + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - db-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + 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} + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.9.5 + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + 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 + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +volumes: + db-config: + deno-cache: diff --git a/docker-compose.yml.old b/docker-compose.yml.old new file mode 100644 index 0000000..fa92b26 --- /dev/null +++ b/docker-compose.yml.old @@ -0,0 +1,572 @@ +# Usage +# Start: docker compose up -d +# Stop: docker compose down +# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d +# Reset everything: sh reset.sh +# +# TODO: +# - Podman does not support nested variable interpolation (${A:-${B}}) +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.06.03-sha-0bca601 + restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\"" + ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 20s + environment: + # Listen on all IPv4 interfaces + HOSTNAME: "0.0.0.0" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + # See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access + #POSTGRES_USER_READ_WRITE: postgres + + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY} + + 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} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY} + + # See: docker-compose.logs.yml + ENABLED_FEATURES_LOGS_ALL: "false" + + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - ./volumes/snippets:/app/snippets:z + - ./volumes/functions:/app/edge-functions:ro,z + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + networks: + default: + aliases: + - api-gw + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + depends_on: + studio: + condition: service_healthy + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z + #- ./volumes/api/server.crt:/home/kong/server.crt:ro + #- ./volumes/api/server.key:/home/kong/server.key:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + 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 + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + #KONG_SSL_CERT: /home/kong/server.crt + #KONG_SSL_CERT_KEY: /home/kong/server.key + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: /home/kong/kong-entrypoint.sh + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.189.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + # JSON array of signing JWKs (EC private + legacy symmetric) + # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} + GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true" + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true" + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + 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_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + # Uncomment to enable OAuth / social login providers. + # GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + # GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + # GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + # GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + # GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + # GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + # GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + # GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Uncomment to configure SMS delivery (phone auth and phone MFA). + # GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER} + # GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP} + # GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH} + # GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY} + # GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE} + + # Twilio credentials (when SMS_PROVIDER=twilio) + # GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID} + # GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN} + # GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID} + + # Test OTP mappings for development + # GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP} + + # Uncomment to configure multi-factor authentication (MFA). + # GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED} + # GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED} + # GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED} + # GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED} + # GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS} + + # SAML SSO + # GOTRUE_SAML_ENABLED: ${SAML_ENABLED} + # GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY} + # GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS} + # GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD} + # GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL} + # GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION} + + # Uncomment to enable custom access token hook. + # See: https://supabase.com/docs/guides/auth/auth-hooks for + # full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.12 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + 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_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + PGRST_DB_ANON_ROLE: anon + + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS. + # For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or + # PGRST_JWT_SECRET: ${JWT_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} + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.102.3 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + 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: supabaserealtime + + # Legacy symmetric HS256 key + API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: API_JWT_JWKS: ${JWT_JWKS} + API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + METRICS_JWT_SECRET: ${JWT_SECRET} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + DISABLE_HEALTHCHECK_LOGGING: "true" + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.60.4 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + start_period: 10s + environment: + 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). + # For Podman, use: JWT_JWKS: ${JWT_JWKS} + 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" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: "true" + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: ${REGION} + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration + S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} + S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.96.6 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + 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} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.74.0 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:z + - deno-cache:/root/.cache/deno + depends_on: + kong: + condition: service_healthy + environment: + # Legacy symmetric HS256 key + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - db-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + 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} + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.9.5 + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + 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 + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +volumes: + db-config: + deno-cache: diff --git a/reset.sh b/reset.sh new file mode 100755 index 0000000..af0302a --- /dev/null +++ b/reset.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +set -e + +auto_confirm=0 + +confirm () { + if [ "$auto_confirm" = "1" ]; then + return 0 + fi + + printf "Are you sure you want to proceed? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) + ;; + *) + echo "Script canceled." + exit 1 + ;; + esac +} + +if [ "$1" = "-y" ]; then + auto_confirm=1 +fi + +echo "" +echo "*** WARNING: This will remove all containers and container data, and optionally reset .env ***" +echo "" + +confirm + +echo "===> Stopping and removing all containers..." + +if [ -f ".env" ]; then + docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans +elif [ -f ".env.example" ]; then + echo "No .env found, using .env.example for docker compose down..." + docker compose --env-file .env.example -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans +else + echo "Skipping 'docker compose down' because there's no env-file." +fi + +echo "===> Cleaning up bind-mounted directories..." +BIND_MOUNTS="./volumes/db/data ./volumes/storage" + +for dir in $BIND_MOUNTS; do + if [ -d "$dir" ]; then + echo "Removing $dir..." + confirm + rm -rf "$dir" + else + echo "$dir not found." + fi +done + +echo "===> Resetting .env file (will save backup to .env.old)..." +confirm +if [ -f ".env" ] || [ -L ".env" ]; then + echo "Renaming existing .env file to .env.old" + mv .env .env.old +else + echo "No .env file found." +fi + +if [ -f ".env.example" ]; then + echo "===> Copying .env.example to .env" + cp .env.example .env +else + echo "No .env.example found, can't restore .env to default values." +fi + +echo "Cleanup complete!" +echo "Re-run 'docker compose pull' to update images." +echo "" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..3978168 --- /dev/null +++ b/run.sh @@ -0,0 +1,297 @@ +#!/bin/sh +# +# Manage the self-hosted Supabase docker compose stack. +# +# Override files are layered via docker compose's native COMPOSE_FILE env +# var in .env. Format: colon-separated list with docker-compose.yml first. +# +# Examples in .env: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +# Manage with: sh run.sh config add | config remove +# (accepts either a short name like 'pg17' or 'docker-compose.pg17.yml') +# +# Usage: +# sh run.sh start # docker compose up -d --wait +# sh run.sh stop # docker compose down +# sh run.sh restart [service] # restart the stack (or named services) +# sh run.sh restart --except ... # restart all services except the named ones +# sh run.sh recreate [service] # stop then start (or force-recreate one service) +# sh run.sh recreate --except ... # force-recreate all services except the named ones +# sh run.sh status # docker compose ps +# sh run.sh logs [service] # follow logs (all or one service) +# sh run.sh inspect # docker inspect on a service's container +# sh run.sh printenv # print a service's environment variables +# sh run.sh pull # pull images +# sh run.sh config # show the active COMPOSE_FILE list +# sh run.sh config add # add an override to COMPOSE_FILE in .env +# sh run.sh config remove # remove an override from COMPOSE_FILE in .env +# sh run.sh compose-config # dump fully-resolved docker compose config +# sh run.sh secrets # print key passwords and API keys from .env +# + +set -e + +cd "$(dirname "$0")" + +if [ ! -f docker-compose.yml ]; then + echo "ERROR: docker-compose.yml not found in $(pwd)" >&2 + exit 1 +fi + +# Normalize an override argument: +# pg17 -> docker-compose.pg17.yml +# docker-compose.pg17.yml -> docker-compose.pg17.yml +# ./docker-compose.pg17.yml -> docker-compose.pg17.yml +# docker-compose.yml -> error (base file, always implicit) +normalize_override() { + arg="${1#./}" + case "$arg" in + docker-compose.yml) + echo "ERROR: docker-compose.yml is the base file, always included" >&2 + return 1 + ;; + docker-compose.*.yml) + echo "$arg" + ;; + *) + echo "docker-compose.${arg}.yml" + ;; + esac +} + +# Read COMPOSE_FILE from .env (stripping quotes and CR). +read_compose_file() { + [ -f .env ] || return 0 + grep '^COMPOSE_FILE=' .env | head -n1 | cut -d= -f2- | tr -d "\r\"'" +} + +# Pretty-print the effective compose file list. +print_config() { + val="$1" + [ -z "$val" ] && val="docker-compose.yml" + echo "COMPOSE_FILE=$val" + echo "compose files:" + OLD_IFS=$IFS + IFS=: + for f in $val; do + echo " $f" + done + IFS=$OLD_IFS + echo "" +} + +# Update or append COMPOSE_FILE in .env. +write_compose_file() { + new_value="$1" + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + new_line="COMPOSE_FILE=$new_value" + if grep -q '^COMPOSE_FILE=' .env; then + sed -i.old -e "s|^COMPOSE_FILE=.*$|$new_line|" .env + rm -f .env.old + else + cat >> .env <. +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +############ +$new_line +EOF + fi +} + +# Echoes the list of services (one per line) minus those passed as args. +# Warns on unknown names; returns 1 if no services remain. +services_except() { + all_services=$(docker compose config --services) + filtered="$all_services" + for ex in "$@"; do + echo "$all_services" | grep -qFx "$ex" \ + || echo "Warning: '$ex' is not a service in this project" >&2 + filtered=$(echo "$filtered" | grep -vFx "$ex" || true) + done + if [ -z "$filtered" ]; then + echo "No services left after applying --except" >&2 + return 1 + fi + printf '%s\n' "$filtered" +} + +CMD="${1:-help}" +[ "$#" -gt 0 ] && shift + +case "$CMD" in + start|up) + exec docker compose up -d --wait "$@" + ;; + stop|down) + exec docker compose down "$@" + ;; + restart) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") restart --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose restart $services + fi + exec docker compose restart "$@" + ;; + recreate) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") recreate --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose up -d --wait --force-recreate --no-deps $services + fi + if [ $# -eq 0 ]; then + docker compose down + exec docker compose up -d --wait + fi + # Single-service recreate: force-recreate the named services only, + # leave their dependencies running. + exec docker compose up -d --wait --force-recreate --no-deps "$@" + ;; + status|ps) + exec docker compose ps "$@" + ;; + logs) + exec docker compose logs -f "$@" + ;; + inspect) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") inspect [docker-inspect-args]" >&2; exit 1; } + svc="$1"; shift + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect "$cid" "$@" + ;; + printenv) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") printenv " >&2; exit 1; } + svc="$1" + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$cid" + ;; + pull) + exec docker compose pull "$@" + ;; + compose-config) + exec docker compose config "$@" + ;; + config) + sub="${1:-show}" + [ "$#" -gt 0 ] && shift + current=$(read_compose_file) + case "$sub" in + show) + print_config "$current" + ;; + add) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config add ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + if [ ! -f "$file" ]; then + echo "ERROR: $file not found" >&2 + exit 1 + fi + case ":$new_value:" in + *":$file:"*) echo "Already present: $file" ;; + *) new_value="$new_value:$file"; changed=true ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + remove|rm) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config remove ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + case ":$new_value:" in + *":$file:"*) + # Drop $file by rebuilding the colon list + tmp="" + OLD_IFS=$IFS + IFS=: + for tok in $new_value; do + [ "$tok" = "$file" ] || tmp="${tmp:+$tmp:}$tok" + done + IFS=$OLD_IFS + new_value="$tmp" + changed=true + ;; + *) echo "Not present: $file" ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + *) + echo "Unknown config subcommand: $sub" >&2 + echo "Use: config | config add ... | config remove ..." >&2 + exit 1 + ;; + esac + ;; + secrets) + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + for var in POSTGRES_PASSWORD DASHBOARD_PASSWORD \ + SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY \ + S3_PROTOCOL_ACCESS_KEY_ID S3_PROTOCOL_ACCESS_KEY_SECRET; do + line=$(grep "^${var}=" .env | head -n1) + if [ -n "$line" ]; then + echo "$line" + else + echo "${var}=" + fi + done + echo "" + ;; + help|-h|--help) + cat < + +Commands: + start Start the stack (docker compose up -d --wait) + stop Stop the stack (docker compose down) + restart [service] Restart the stack (or named services) + restart --except ... + Restart all services except the named ones + recreate [service] Stop then start, or force-recreate one service (--no-deps) + recreate --except ... + Force-recreate all services except the named ones (--no-deps) + status Show service status + logs [service] Follow logs (optionally for a single service) + inspect Inspect a service's container (forwards extra args to docker inspect) + printenv Print a service's environment variables (one per line) + pull Pull all images + config Show the active COMPOSE_FILE list + config add Add an override to COMPOSE_FILE in .env (short name or full filename) + config remove Remove an override from COMPOSE_FILE in .env + compose-config Dump the fully-resolved docker compose config + secrets Show key passwords and API keys from .env + +EOF + ;; + *) + echo "Unknown command: $CMD" >&2 + echo "Run '$0 help' for usage." >&2 + exit 1 + ;; +esac diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..9a22766 --- /dev/null +++ b/setup.sh @@ -0,0 +1,341 @@ +#!/bin/sh +# +# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora). +# +# What it does: +# 1. Installs prerequisites: git, openssl, jq, ca-certificates +# 2. Installs Docker Engine + Compose plugin (if missing) +# 3. Optionally installs the AWS CLI v2 (--with-aws) +# 4. Sparse-clones the repo to extract the contents of ./docker +# 5. Creates a project directory in CWD and copies docker/* into it +# 6. Prompts for the main URLs and writes them to .env +# 7. Generates secrets and asymmetric API keys via utils/*.sh +# +# Usage: +# sh setup.sh # interactive +# sh setup.sh -y # accept defaults, no prompts +# sh setup.sh --project-dir my-supabase # name the project directory +# sh setup.sh --skip-deps # skip system-package installation +# sh setup.sh --with-aws # also install the AWS CLI v2 +# +# curl -fsSL | sh # bootstrap from scratch in CWD +# + +set -e + +PROJECT_DIR="supabase-project" +SKIP_DEPS=0 +WITH_AWS=0 +ASSUME_YES=0 + +print_help() { + cat < Name of the project directory (default: supabase-project) + --skip-deps Skip installation of system packages + --with-aws Install the AWS CLI v2 + -y, --yes Non-interactive: accept defaults, no prompts + -h, --help Show this help and exit +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -p|--project-dir) PROJECT_DIR="$2"; shift 2 ;; + --skip-deps) SKIP_DEPS=1; shift ;; + --with-aws) WITH_AWS=1; shift ;; + -y|--yes) ASSUME_YES=1; shift ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; + esac +done + +if [ "$(id -u)" = "0" ]; then + SUDO="" +else + SUDO="sudo" +fi + +log() { printf "===> %s\n" "$*"; } +warn() { printf "WARNING: %s\n" "$*" >&2; } +die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } + +# Prompt with a default; echoes the chosen value on stdout. +# Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh). +# Falls back to the default with -y or when no controlling terminal exists. +ask() { + # ask -> echoes chosen value + if [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; then + printf '%s' "$2" + return + fi + printf "%s [%s]: " "$1" "$2" > /dev/tty + read -r reply < /dev/tty + [ -z "$reply" ] && reply="$2" + printf '%s' "$reply" +} + +OS_FAMILY="" +OS_ID="" +OS_CODENAME="" + +detect_os() { + [ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only." + # shellcheck disable=SC1091 + . /etc/os-release + OS_ID="$ID" + OS_CODENAME="${VERSION_CODENAME:-}" + case "$ID" in + ubuntu|debian) OS_FAMILY="debian" ;; + centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;; + *) + case "${ID_LIKE:-}" in + *debian*|*ubuntu*) OS_FAMILY="debian" ;; + *rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;; + *) die "Unsupported distribution: $ID" ;; + esac + ;; + esac + log "Detected OS: $ID ($OS_FAMILY)" +} + +pkg_update() { + if [ "$OS_FAMILY" = "debian" ]; then + $SUDO apt-get update -qq -y + else + $SUDO dnf makecache -q -y || true + fi +} + +pkg_install() { + if [ "$OS_FAMILY" = "debian" ]; then + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get install -qq -y "$@" + else + $SUDO dnf install -q -y "$@" + fi +} + +install_base_packages() { + log "Installing base packages: git, openssl, jq, ca-certificates" + pkg_update + if [ "$OS_FAMILY" = "debian" ]; then + pkg_install git openssl jq ca-certificates \ + apt-transport-https gnupg lsb-release + else + pkg_install git openssl jq ca-certificates dnf-plugins-core + fi +} + +docker_present() { + command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 +} + +install_docker() { + if docker_present; then + log "Docker already installed: $(docker --version)" + return 0 + fi + + log "Installing Docker Engine and Compose plugin" + if [ "$OS_FAMILY" = "debian" ]; then + $SUDO install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \ + | $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg + $SUDO chmod a+r /etc/apt/keyrings/docker.gpg + codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}" + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \ + | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null + $SUDO apt-get update -y + pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + else + # Amazon Linux + if [ "$OS_ID" = "amzn" ]; then + # Install Docker from the repo + pkg_install docker + # Install Docker Compose + $SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \ + $SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose && \ + $SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + else + repo_distro="centos" + case "$OS_ID" in + fedora) repo_distro="fedora" ;; + rhel) repo_distro="rhel" ;; + esac + $SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \ + || $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" + pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + fi + fi + + log "Enabling and starting docker service" + $SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually." + + docker_present || die "Docker installation finished but 'docker compose' is still unavailable." +} + +install_aws() { + if command -v aws >/dev/null 2>&1; then + log "AWS CLI already installed: $(aws --version 2>&1)" + return 0 + fi + + arch=$(uname -m) + case "$arch" in + x86_64|amd64) aws_arch="x86_64" ;; + aarch64|arm64) aws_arch="aarch64" ;; + *) die "Unsupported architecture for AWS CLI: $arch" ;; + esac + + log "Installing AWS CLI v2 (${aws_arch})" + command -v unzip >/dev/null 2>&1 || pkg_install unzip + tmp=$(mktemp -d) + ( + cd "$tmp" + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip + unzip -q -o awscliv2.zip + $SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update + ) + rm -rf "$tmp" +} + +SRC_DIR="" +SRC_TMP="" + +prepare_source() { + log "Sparse-cloning supabase repo" + SRC_TMP=$(mktemp -d) || return 1 + git clone --filter=blob:none --no-checkout --depth=1 --quiet \ + https://github.com/supabase/supabase "$SRC_TMP/supabase" 2>/dev/null || \ + { rm -rf "$SRC_TMP"; return 1; } + + cd "$SRC_TMP/supabase" || { rm -rf "$SRC_TMP"; return 1; } + git sparse-checkout init --cone && \ + git sparse-checkout set docker && \ + git checkout --quiet 2>/dev/null + SRC_DIR="$PWD/docker" + cd - > /dev/null +} + +cleanup_src_tmp() { + if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then + rm -rf "$SRC_TMP" + fi +} +trap cleanup_src_tmp EXIT + +read_env() { + grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2- +} + +# --- Main --- + +log "Setup starting in $(pwd)" +log "This may take several minutes..." + +if [ "$SKIP_DEPS" = "1" ]; then + log "Skipping system-package installation (--skip-deps)" +else + detect_os + install_base_packages + install_docker +fi + +if [ "$WITH_AWS" = "1" ]; then + install_aws +fi + +# Idempotent re-run: if CWD is already a set-up project, skip bootstrap. +# A clone has docker-compose.yml + utils/ but only .env.example; +# a set-up project also has a real .env. +if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then + log "Already in a Supabase project directory; skipping bootstrap." + exit 0 +fi + +prepare_source + +target="$(pwd)/$PROJECT_DIR" +if [ -e "$target" ]; then + die "Target $target already exists. Pick a different name with --project-dir" +fi + +log "Creating project at $target" +mkdir -p "$target" +cp -rf "$SRC_DIR/." "$target/" +if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then + cp "$target/.env.example" "$target/.env" +fi + +cd "$target" + +current_public_url=$(read_env SUPABASE_PUBLIC_URL) +current_api_url=$(read_env API_EXTERNAL_URL) +current_site_url=$(read_env SITE_URL) + +[ -z "$current_public_url" ] && current_public_url="http://localhost:8000" +[ -z "$current_api_url" ] && current_api_url="$current_public_url" +[ -z "$current_site_url" ] && current_site_url="http://localhost:3000" + +if [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; then + log "Non-interactive: keeping default URLs (edit .env to change)" +else + echo "" + echo "Configure the main URLs (press Enter to accept the default)." + echo "" +fi + +public_url=$(ask "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url") +api_url=$(ask "API_EXTERNAL_URL (Auth callbacks)" "$public_url") +site_url=$(ask "SITE_URL (default Auth redirect)" "$current_site_url") + +# Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish) +public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||') +case "$public_host" in + localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;; + *) current_proxy_domain="$public_host" ;; +esac +[ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com" + +proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain") + +# Derive CERTBOT_EMAIL = admin@. +# Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after. +domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }') +certbot_email="admin@${domain_root}" +log "Setting CERTBOT_EMAIL=${certbot_email}" + +sed -i.old \ + -e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \ + -e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \ + -e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \ + -e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \ + -e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \ + .env +rm -f .env.old + +log "Generating secrets and legacy API keys" +sh utils/generate-keys.sh --update-env + +log "Generating asymmetric key pair and opaque API keys" +sh utils/add-new-auth-keys.sh --update-env + +log "Pulling Docker images" +docker compose pull || warn "docker compose pull failed; you can retry later." + +echo "" +echo "Setup complete. Project ready at: $(pwd)" +echo "" +echo "Next steps:" +echo " cd $(pwd)" +echo " sh run.sh config" +echo " sh run.sh secrets" +echo " sh run.sh start" +echo "" +echo "To enable docker-compose overrides (pg17, envoy, caddy, nginx, rustfs, s3, logs):" +echo " sh run.sh config add pg17" +echo "" diff --git a/tests/docker-compose.rustfs.test.yml b/tests/docker-compose.rustfs.test.yml new file mode 100644 index 0000000..e7a7641 --- /dev/null +++ b/tests/docker-compose.rustfs.test.yml @@ -0,0 +1,11 @@ +# Test override: exposes the S3 backend port for direct testing. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.rustfs.yml \ +# -f ./tests/docker-compose.rustfs.test.yml up -d +# + +services: + rustfs: + ports: + - "${S3_BACKEND_TEST_PORT:-9100}:9000" diff --git a/tests/docker-compose.s3.test.yml b/tests/docker-compose.s3.test.yml new file mode 100644 index 0000000..c2e34f4 --- /dev/null +++ b/tests/docker-compose.s3.test.yml @@ -0,0 +1,13 @@ +# Test override: exposes the S3 backend port for direct testing. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml \ +# -f ./tests/docker-compose.s3.test.yml up -d +# +# When swapping to a different S3 backend (e.g. RustFS), update the +# service name and internal port to match the new backend. + +services: + minio: + ports: + - "${S3_BACKEND_TEST_PORT:-9100}:9000" diff --git a/tests/test-auth-keys.sh b/tests/test-auth-keys.sh new file mode 100644 index 0000000..03a90f9 --- /dev/null +++ b/tests/test-auth-keys.sh @@ -0,0 +1,369 @@ +#!/bin/sh +# +# Test API key types and asymmetric auth against a running self-hosted instance. +# +# Usage: +# sh test-auth-keys.sh # Uses http://localhost:8000 +# sh test-auth-keys.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with all keys configured +# - jq (for JSON parsing) +# - node >= 16 (for HS256 token minting test only) +# + +set -e + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in jq node; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-) +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name (HTTP $actual)" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +echo "" +echo "=== Testing against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Route tests with API key types +# --------------------------------------------- + +echo "--- REST API (/rest/v1/) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")" +check "Legacy SERVICE_ROLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" + check "New SECRET_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")" +else + echo " SKIP: Opaque keys not configured" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/")" +check "Invalid key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")" + +echo "" +echo "--- Auth (/auth/v1/settings) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/auth/v1/settings")" + +echo "" +echo "--- Storage (/storage/v1/bucket) ---" +# Storage has no key-auth - passes through, Storage returns its own errors +check "No key -> not 401 (Storage handles auth)" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # With opaque key, Kong translates to asymmetric JWT in Authorization + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- Storage S3 (/storage/v1/s3/) ---" +# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression +# passes the Authorization header through unchanged for non-sb_ values +check "S3 route accessible" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)" + +echo "" +echo "--- GraphQL (/graphql/v1) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +echo "" +echo "--- Realtime REST (/realtime/v1/api/) ---" +# Realtime REST API - expect 200 or other non-401 response with valid key +check "Legacy ANON_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" != "401" ] && echo true || echo false)" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/realtime/v1/api/tenants")" + +echo "" +echo "--- supabase-js style requests (apikey + Authorization) ---" +# supabase-js sends both apikey header AND Authorization: Bearer +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "apikey + Authorization: Bearer sb_ (replace path)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "Legacy apikey + Authorization: Bearer " "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ANON_KEY")" + +echo "" +echo "--- Edge cases ---" +# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "sb_ in Authorization only (no apikey) -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- JWKS endpoint ---" +check "JWKS public endpoint (no auth)" "200" \ + "$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")" + +# Verify JWKS content: should have EC key, should NOT have symmetric key +jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json") +jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null) +jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null) +check "JWKS contains EC public key" "true" "$jwks_has_ec" +check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct" + +#echo "" +#echo "--- OAuth metadata endpoint ---" +#check "well-known oauth (no auth)" "200" \ +# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")" + +echo "" +echo "--- Realtime WebSocket upgrade ---" +# Test that WebSocket upgrade request gets through (expect 101 or non-401) +# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open) +ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") +# 101 = upgrade success, 000 = timeout (connection stayed open = success) +check "WebSocket upgrade with legacy key -> not 401" "true" \ + "$([ "$ws_status" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") + check "WebSocket upgrade with opaque key -> not 401" "true" \ + "$([ "$ws_status_new" != "401" ] && echo true || echo false)" +fi + +# --------------------------------------------- +# 2. User session JWT tests +# --------------------------------------------- + +echo "" +echo "--- User session JWT ---" + +# Create user via admin API (works regardless of email autoconfirm setting) +test_email="test-keys-$$@example.com" +test_password="test-password-123456" + +create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +# Sign in to get session JWT +auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + +access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null) + +if [ -n "$access_token" ]; then + # Check the algorithm in the JWT header + jwt_alg=$(echo "$access_token" | cut -d. -f1 | \ + jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null) + + if [ -n "$jwt_alg" ]; then + echo " INFO: User session JWT signed with: $jwt_alg" + if [ "$jwt_alg" = "ES256" ]; then + check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg" + else + check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg" + fi + fi + + # Use the session JWT with PostgREST + check "Session JWT with PostgREST" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # Use the session JWT with Storage + check "Session JWT with Storage" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # CRITICAL: Authenticated user + opaque key (most common supabase-js flow) + # supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer + # The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT + if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + echo "" + echo "--- Authenticated user + opaque key (critical path) ---" + check "Opaque apikey + user JWT -> PostgREST uses user JWT" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + fi +else + check "Sign in test user" "true" "false" +fi + +# Clean up test user +if [ -n "$test_user_id" ]; then + curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" +fi + +# --------------------------------------------- +# 3. HS256 backward compatibility +# --------------------------------------------- + +echo "" +echo "--- HS256 backward compatibility ---" + +# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token) +hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e " +const crypto = require('crypto'); +const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url'); +const payload = Buffer.from(JSON.stringify({ + role:'anon',iss:'supabase', + iat:Math.floor(Date.now()/1000), + exp:Math.floor(Date.now()/1000)+3600 +})).toString('base64url'); +const sig = crypto.createHmac('sha256',process.env.JWT_SECRET) + .update(header+'.'+payload).digest('base64url'); +console.log(header+'.'+payload+'.'+sig); +" 2>/dev/null) + +if [ -n "$hs256_token" ]; then + check "HS256 token with PostgREST (backward compat)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $hs256_token")" +else + echo " SKIP: Could not mint HS256 token (node required)" +fi + +# --------------------------------------------- +# 4. JWT_KEYS format validation +# --------------------------------------------- + +echo "" +echo "--- JWT_KEYS format ---" + +JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-) +if [ -n "$JWT_KEYS_VAL" ]; then + # Auth expects a JSON array, not a JWKS object + jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array" + + jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign" +else + echo " SKIP: JWT_KEYS not configured" +fi + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-container-logs.sh b/tests/test-container-logs.sh new file mode 100644 index 0000000..75ecd10 --- /dev/null +++ b/tests/test-container-logs.sh @@ -0,0 +1,165 @@ +#!/bin/sh +# +# Verify all self-hosted Supabase services started correctly by checking log output. +# +# Usage: +# sh test-container-logs.sh +# +# Prerequisites: +# - Running self-hosted Supabase instance (docker compose up) +# + +set -e + +pass=0 +fail=0 +project_name="${COMPOSE_PROJECT_NAME:-supabase}" + +fail_msg() { + fail=$((fail + 1)) + echo " FAIL: $1" +} + +pass_msg() { + pass=$((pass + 1)) + echo " PASS: $1" +} + +# The `docker ps` fallback in the helpers below exists because the script +# doesn't know which compose `-f` flags the user ran `up` with. `docker compose +# ps` only sees services defined in the currently loaded compose files, but +# compose stamps `com.docker.compose.{project,service}` labels at `up` time - +# so a label-based lookup finds the container regardless of which override +# files are active in this shell. + +is_service_running() { + service="$1" + if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then + return 0 + fi + + docker ps --filter "label=com.docker.compose.project=$project_name" \ + --filter "label=com.docker.compose.service=$service" \ + --filter "status=running" \ + --quiet | grep -q '.' +} + +get_container_id() { + service="$1" + + container_id=$(docker compose ps -q "$service" 2>/dev/null || true) + if [ -n "$container_id" ]; then + printf '%s' "$container_id" + return + fi + + container_id=$(docker ps -a \ + --filter "label=com.docker.compose.project=$project_name" \ + --filter "label=com.docker.compose.service=$service" \ + --quiet) + + set -- $container_id + printf '%s' "$1" +} + +# Check that a service's logs contain all expected patterns +check_logs() { + service="$1" + shift + + logs=$(docker compose logs "$service" 2>/dev/null || true) + if [ -z "$logs" ]; then + container_id=$(get_container_id "$service") + if [ -n "$container_id" ]; then + logs=$(docker logs "$container_id" 2>&1 || true) + fi + fi + + if [ -z "$logs" ]; then + fail_msg "$service (no logs found)" + return + fi + + for pattern in "$@"; do + if ! echo "$logs" | grep -q -i -E "$pattern"; then + fail_msg "$service (missing: $pattern)" + return + fi + done + + pass_msg "$service" +} + +check_logs_if_running() { + service="$1" + shift + + if is_service_running "$service"; then + check_logs "$service" "$@" + else + pass_msg "$service (skipped: service not running)" + fi +} + +echo "" +echo "=== Checking service startup logs ===" +echo "" + +check_logs db \ + 'PostgreSQL init process complete; ready for start up.|Skipping initialization' + +check_logs auth \ + 'db worker started' + +check_logs_if_running kong \ + 'init.lua.*declarative config loaded' + +check_logs_if_running api-gw \ + 'Envoy configuration generated successfully' \ + 'Starting Envoy...' + +check_logs rest \ + 'Schema cache loaded in.*milliseconds' + +check_logs realtime \ + 'Starting Realtime' \ + 'Connected to Postgres database' \ + 'Janitor started' \ + 'Starting MetricsCleaner' + +check_logs storage \ + 'Started Successfully' + +check_logs studio \ + 'ready in.*s$' + +check_logs meta \ + 'Server listening at http' + +check_logs functions \ + 'main function started' + +check_logs_if_running analytics \ + 'Access LogflareWeb.Endpoint at http://localhost:4000' \ + 'Executing startup tasks' \ + 'Ensuring single tenant user is seeded' + +check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' + +check_logs_if_running vector \ + 'Vector has started' + +check_logs imgproxy \ + 'Starting server at :5001' + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + echo "Inspect logs: docker compose logs " + echo "" + exit 1 +fi diff --git a/tests/test-pg17-upgrade.sh b/tests/test-pg17-upgrade.sh new file mode 100644 index 0000000..0015b86 --- /dev/null +++ b/tests/test-pg17-upgrade.sh @@ -0,0 +1,239 @@ +#!/bin/sh +# +# Test Postgres 15 -> 17 upgrade for self-hosted Supabase. +# +# Seeds test data on a running Postgres 15 stack, runs the upgrade script, +# and verifies data integrity + service connectivity using pgTAP. +# +# Usage: +# cd docker/ +# sudo bash tests/test-pg17-upgrade.sh +# +# Prerequisites: +# - Running self-hosted Supabase with a clean, tests-only Postgres 15: +# docker compose up -d +# - .env file with POSTGRES_PASSWORD, ANON_KEY +# + +set -eu + +DB_CONTAINER="supabase-db" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the docker/ directory." + exit 1 +fi + +pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-) +anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true) + +if [ -z "$pg_password" ]; then + echo "Error: POSTGRES_PASSWORD not set in .env" + exit 1 +fi + +run_sql() { + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@" +} + +echo "" +echo "=== Postgres 15 -> 17 Upgrade Test ===" +echo "" + +# --- Verify we're starting from Postgres 15 -------------------------------- + +current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1) +case "$current_version" in + 15.*) echo "Starting version: PostgreSQL $current_version" ;; + 17.*) echo "Error: Already on Postgres 17. Start with a PG15 stack."; exit 1 ;; + *) echo "Error: Unexpected version: $current_version"; exit 1 ;; +esac + +# --- Seed test data -------------------------------------------------------- +# Note: this script is designed to run against a fresh docker-compose stack, +# not an existing database with user data. + +echo "" +echo "Seeding test data on Postgres 15..." + +run_sql <<'EOSQL' +-- Test table with various column types +CREATE TABLE IF NOT EXISTS public._upgrade_test ( + id serial PRIMARY KEY, + name text NOT NULL, + value numeric(10,2), + created_at timestamptz DEFAULT now(), + metadata jsonb +); + +TRUNCATE public._upgrade_test; +INSERT INTO public._upgrade_test (name, value, metadata) VALUES + ('alpha', 1.50, '{"tag": "a"}'), + ('bravo', 2.75, '{"tag": "b"}'), + ('charlie', 3.00, '{"tag": "c"}'), + ('delta', 4.25, '{"tag": "d"}'), + ('echo', 5.99, '{"tag": "e"}'); + +-- Index +CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name); + +-- Function +CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int) +RETURNS int LANGUAGE sql IMMUTABLE AS $$ + SELECT n * 2; +$$; + +-- Grant access so PostgREST can read it +GRANT SELECT ON public._upgrade_test TO anon, authenticated; +EOSQL + +pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]') +pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]') + +echo " Rows: $pre_count" +echo " Checksum: $pre_checksum" + +# --- Run upgrade ----------------------------------------------------------- + +echo "" +echo "Running upgrade script..." +echo "" + +bash utils/upgrade-pg17.sh --yes + +echo "" + +# --- Verify with pgTAP ---------------------------------------------------- + +echo "Running pgTAP verification..." +echo "" + +# Use a non-quoted heredoc so $pre_count and $pre_checksum are interpolated +run_sql </dev/null) || rest_status="000" + check "PostgREST connectivity" "200" "$rest_status" +fi + +# Auth health (needs apikey header through Kong) +if [ -n "$anon_key" ]; then + auth_status=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "apikey: $anon_key" \ + "http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000" + check "Auth service health" "200" "$auth_status" +fi + +echo "" +echo " Services: $pass passed, $fail failed" + +# --- Clean up test artifacts ---------------------------------------------- + +echo "" +echo "Cleaning up test artifacts..." + +run_sql <<'EOSQL' || true +DROP FUNCTION IF EXISTS public._upgrade_test_fn(int); +DROP TABLE IF EXISTS public._upgrade_test; +DROP EXTENSION IF EXISTS pgtap; +EOSQL + +# --- Summary -------------------------------------------------------------- + +echo "" +if [ "$fail" -gt 0 ]; then + echo "=== SOME TESTS FAILED ===" + exit 1 +fi + +echo "=== Upgrade test passed ===" +echo "" +echo "To reclaim disk space:" +echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz" +echo "" diff --git a/tests/test-s3-backend.sh b/tests/test-s3-backend.sh new file mode 100644 index 0000000..799994a --- /dev/null +++ b/tests/test-s3-backend.sh @@ -0,0 +1,358 @@ +#!/bin/sh +# +# Test S3 backend directly, bypassing the Storage service. +# +# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles +# all S3 operations that Storage relies on. Uses the aws cli so the test +# is backend-agnostic — no vendor-specific tools required. +# +# Usage: +# sh test-s3-backend.sh # Uses localhost:9100 +# sh test-s3-backend.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 backend + test override: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml \ +# -f ./tests/docker-compose.s3.test.yml up -d +# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET +# - aws cli v2 +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read backend credentials from .env +BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-) +BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-) +GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) +REGION="${REGION:-us-east-1}" + +if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then + echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws commands against the backend directly +s3() { + AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \ + aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1 +} + +bucket_name="backend-test-$$" + +echo "" +echo "=== S3 backend test against $BACKEND_URL ===" +echo "" + +# --------------------------------------------- +# 1. ListBuckets (backend reachable) +# --------------------------------------------- + +echo "--- Connectivity ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "Backend reachable (ListBuckets)" "true" "$list_ok" + +if [ "$list_ok" != "true" ]; then + echo " Cannot reach backend. Is the test override running?" + echo " Response: $list_output" + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# --------------------------------------------- +# 2. Verify GLOBAL_S3_BUCKET exists +# --------------------------------------------- + +echo "" +echo "--- Storage bucket ---" +if [ -n "$GLOBAL_S3_BUCKET" ]; then + storage_bucket_exists=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) + check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists" +else + echo " SKIP: GLOBAL_S3_BUCKET not set" +fi + +# --------------------------------------------- +# 3. CreateBucket +# --------------------------------------------- + +echo "" +echo "--- CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify in ListBuckets (separate call) +bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$bucket_found" + +# --------------------------------------------- +# 4. PutObject +# --------------------------------------------- + +echo "" +echo "--- PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from backend test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject" "true" "$put_ok" + +# --------------------------------------------- +# 5. ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 6. HeadObject +# --------------------------------------------- + +echo "" +echo "--- HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 7. GetObject + content verify +# --------------------------------------------- + +echo "" +echo "--- GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from backend test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 8. CopyObject +# --------------------------------------------- + +echo "" +echo "--- CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 9. DeleteObject +# --------------------------------------------- + +echo "" +echo "--- DeleteObject ---" +s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copy_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copy_gone" + +# --------------------------------------------- +# 10. Multipart upload (7MB) +# --------------------------------------------- + +echo "" +echo "--- Multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size" + +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 11. DeleteObjects (batch delete) +# --------------------------------------------- + +echo "" +echo "--- DeleteObjects (batch) ---" +batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file" +echo "batch-a" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null +echo "batch-b" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null +echo "batch-c" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null +rm -f "$batch_file" +delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \ + --delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \ + --output json) +deleted_count=$(echo "$delete_objects_output" | jq -r '.Deleted | length' 2>/dev/null) +check "DeleteObjects removed 3 objects" "3" "$deleted_count" + +# Verify all gone +batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json) +batch_remaining=$(echo "$batch_list" | jq -r '[.Contents[]?] | length' 2>/dev/null) +check "Batch-deleted objects gone" "0" "$batch_remaining" + +# --------------------------------------------- +# 12. Presigned URLs +# --------------------------------------------- + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 13. Conditional request (IfNoneMatch) +# --------------------------------------------- + +echo "" +echo "--- Conditional request (IfNoneMatch) ---" +# --if-none-match requires aws cli v2.22+ ; skip if not supported +if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then + cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file" + echo "conditional test" > "$cond_file" + + # First put should succeed (key doesn't exist) + first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \ + --body "$cond_file" --if-none-match '*' --output json 2>&1 || true) + first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true") + check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok" + + # Second put should fail with PreconditionFailed (key exists) + cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \ + --body "$cond_file" --if-none-match '*' --output json 2>&1 || true) + cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false") + check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected" + rm -f "$cond_file" +else + echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)" +fi + +# --------------------------------------------- +# 14. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 15. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$BACKEND_URL" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 16. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Test bucket deleted" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-s3.sh b/tests/test-s3.sh new file mode 100644 index 0000000..939547d --- /dev/null +++ b/tests/test-s3.sh @@ -0,0 +1,292 @@ +#!/bin/sh +# +# Test S3 protocol endpoint for self-hosted Supabase Storage. +# +# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with +# standard S3 clients — the same way end users interact with it via +# aws cli, rclone, or other S3-compatible tools. +# +# Usage: +# sh test-s3.sh # Uses http://localhost:8000 +# sh test-s3.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 enabled: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION +# - aws cli v2 (for S3 operations) +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" +S3_ENDPOINT="$BASE_URL/storage/v1/s3" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-) +S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) + +if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then + echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws s3/s3api commands with correct endpoint and credentials +s3() { + AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1 +} + +bucket_name="s3-test-$$" + +echo "" +echo "=== S3 protocol test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. S3 ListBuckets +# --------------------------------------------- + +echo "--- S3 ListBuckets ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "ListBuckets returns valid response" "true" "$list_ok" + +# --------------------------------------------- +# 2. S3 CreateBucket +# --------------------------------------------- + +echo "" +echo "--- S3 CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify bucket appears in ListBuckets (separate call) +s3_bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$s3_bucket_found" + +# --------------------------------------------- +# 3. S3 PutObject +# --------------------------------------------- + +echo "" +echo "--- S3 PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from s3 upload test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject upload" "true" "$put_ok" + +# --------------------------------------------- +# 4. S3 ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- S3 ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 5. S3 HeadObject +# --------------------------------------------- + +echo "" +echo "--- S3 HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 6. S3 GetObject (download) + content verify +# --------------------------------------------- + +echo "" +echo "--- S3 GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from s3 upload test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 7. S3 CopyObject (server-side copy) +# --------------------------------------------- + +echo "" +echo "--- S3 CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +# Verify copied content +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 8. S3 DeleteObject +# --------------------------------------------- + +echo "" +echo "--- S3 DeleteObject ---" +s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null +# Verify object is gone +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copied_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copied_gone" + +# --------------------------------------------- +# 9. Multipart upload (>5MB triggers multipart) +# --------------------------------------------- + +echo "" +echo "--- S3 multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +# Verify size via HeadObject +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size" + +# Download and verify size +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 10. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 11. Presigned URLs +# --------------------------------------------- +# Storage supports S3 presigned URLs (query-parameter auth), but Kong's +# request-transformer adds an empty Authorization header when the Lua +# expression evaluates to nil. Storage sees typeof "" === "string" and +# enters parseAuthorizationHeader instead of parseQuerySignature. +# This test will pass once the Kong config is fixed. + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 12. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$S3_ENDPOINT" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 13. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +# Verify bucket is gone +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Bucket deleted via S3" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-self-hosted.sh b/tests/test-self-hosted.sh new file mode 100644 index 0000000..87e3c5b --- /dev/null +++ b/tests/test-self-hosted.sh @@ -0,0 +1,351 @@ +#!/bin/sh +# +# Smoke test for self-hosted Supabase - verifies core functionality end-to-end. +# +# Usage: +# sh test-self-hosted.sh # Uses http://localhost:8000 +# sh test-self-hosted.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with keys configured +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/" + exit 1 +fi + +# Read keys from .env +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-) +DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +http_body() { + url="$1" + shift + curl -s "$@" "$url" +} + +echo "" +echo "=== Self-hosted smoke test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Container health (via docker compose) +# --------------------------------------------- + +echo "--- Container health ---" +if command -v docker >/dev/null 2>&1; then + container_status=$(docker compose ps --format json 2>/dev/null | jq -rs ' + [.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))] + | (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", ")) + ' 2>/dev/null || echo "?|") + unhealthy="${container_status%%|*}" + container_issues="${container_status#*|}" + if [ "$unhealthy" = "0" ]; then + check "All containers healthy" "0" "$unhealthy" + elif [ "$unhealthy" = "?" ]; then + echo " SKIP: Could not check container health" + else + check "All containers healthy ($container_issues)" "0" "$unhealthy" + fi +else + echo " SKIP: docker not available" +fi + +# --------------------------------------------- +# 2. Studio dashboard +# --------------------------------------------- + +echo "" +echo "--- Studio dashboard ---" +# Studio may redirect (307/302) after auth - follow redirects +check "Studio accessible with basic auth" "200" \ + "$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")" +check "Studio rejects without auth" "401" \ + "$(http_status "$BASE_URL/")" + +# --------------------------------------------- +# 3. Auth: create user, sign in, get user, public signup, delete +# --------------------------------------------- + +echo "" +echo "--- Auth: user lifecycle ---" + +test_email="smoke-test-$$@example.com" +test_password="smoke-test-password-123456" + +# Create user via admin API (works regardless of email autoconfirm setting) +create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +if [ -n "$user_id" ]; then + check "Create user (admin)" "true" "true" + + # Sign in via public endpoint + signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + + access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null) + + if [ -n "$access_token" ]; then + check "Sign in user" "true" "true" + + # Get user profile with session JWT + check "Get user profile" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + else + check "Sign in user" "true" "false" + fi + + # Delete user + delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete user (admin)" "200" "$delete_status" +else + check "Create user (admin)" "true" "false" +fi + +# Public signup (optional — depends on email autoconfirm setting) +signup_email="smoke-signup-$$@example.com" +signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}") + +signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null) +signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null) + +if [ -n "$signup_token" ]; then + check "Public signup (autoconfirm on)" "true" "true" +else + echo " SKIP: Public signup (autoconfirm is off)" +fi + +# Clean up signup user if created +if [ -n "$signup_user_id" ]; then + http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 +fi + +# --------------------------------------------- +# 4. PostgREST: query +# --------------------------------------------- + +echo "" +echo "--- PostgREST ---" +check "REST API query" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY")" + +# --------------------------------------------- +# 5. GraphQL +# --------------------------------------------- + +echo "" +echo "--- GraphQL ---" +gql_resp=$(http_body "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}') +gql_has_data=$(echo "$gql_resp" | jq -r 'if .data then "true" else "false" end' 2>/dev/null) +check "GraphQL introspection" "true" "$gql_has_data" + +# --------------------------------------------- +# 6. Storage: create bucket, upload >6MB file, download, cleanup +# --------------------------------------------- + +echo "" +echo "--- Storage: bucket + file lifecycle ---" + +bucket_name="smoke-test-$$" + +# Create bucket +create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}") +check "Create bucket" "200" "$create_bucket_status" + +if [ "$create_bucket_status" = "200" ]; then + # Generate a ~7MB file + tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" + dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null + + # Upload file + upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$tmpfile") + check "Upload 7MB file" "200" "$upload_status" + + # Download file and verify size + download_size=$(curl -s \ + "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" | wc -c | tr -d ' ') + original_size=$(wc -c < "$tmpfile" | tr -d ' ') + check "Download file (size matches)" "true" \ + "$([ "$download_size" = "$original_size" ] && echo true || echo false)" + + rm -f "$tmpfile" + + # Signed URL: upload a small file, create signed URL, fetch without auth + sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: text/plain" \ + --data-binary "signed url test content") + check "Upload file for signing" "200" "$sign_upload_status" + + if [ "$sign_upload_status" = "200" ]; then + sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"expiresIn": 600}') + signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null) + + if [ -n "$signed_path" ]; then + check "Create signed URL" "true" "true" + # Fetch signed URL without any auth headers (goes through Kong) + signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path") + check "Fetch signed URL (no auth)" "signed url test content" "$signed_content" + else + check "Create signed URL" "true" "false" + fi + fi + + # Delete file + delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete file" "200" "$delete_file_status" + + # Delete signed test file + http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 + + # Delete bucket + delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete bucket" "200" "$delete_bucket_status" +fi + +# --------------------------------------------- +# 7. Edge Functions +# --------------------------------------------- + +echo "" +echo "--- Edge Functions ---" +fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ + -X POST \ + -H "Authorization: Bearer $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{}') +check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp" + +# --------------------------------------------- +# 8. pg-meta (Studio backend) +# --------------------------------------------- + +echo "" +echo "--- pg-meta ---" +check "pg-meta with service_role key" "200" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $SERVICE_ROLE_KEY")" +check "pg-meta rejects anon key" "403" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $ANON_KEY")" +check "pg-meta rejects no key" "401" \ + "$(http_status "$BASE_URL/pg/schemas")" + +echo "" +echo "--- MCP (blocked by default) ---" +check "/api/mcp blocked" "403" \ + "$(http_status "$BASE_URL/api/mcp")" +check "/mcp blocked" "403" \ + "$(http_status "$BASE_URL/mcp")" + +# --------------------------------------------- +# 9. Realtime +# --------------------------------------------- + +echo "" +echo "--- Realtime ---" +check "Realtime health" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" \ + -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/utils/add-new-auth-keys.sh b/utils/add-new-auth-keys.sh new file mode 100644 index 0000000..b728237 --- /dev/null +++ b/utils/add-new-auth-keys.sh @@ -0,0 +1,223 @@ +#!/bin/sh +# +# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation. +# +# Reads JWT_SECRET from .env and generates: +# - EC P-256 key pair (JWT_KEYS, JWT_JWKS) +# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY) +# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC) +# +# Usage: +# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env +# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env +# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file with JWT_SECRET set (run generate-keys.sh first) +# - node (>= 16) or docker +# + +set -e + +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null +} + +# Resolve how to run node: local install (>= 16) preferred, docker fallback. +if node_ok; then + node_runner="node" +else + if command -v node >/dev/null 2>&1; then + echo "Local node $(node -v) is too old (need >= 16), falling back to docker." + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "Error: requires either node (>= 16) or docker." + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "Error: docker is installed but the daemon is not running." + exit 1 + fi + + if ! docker image inspect node:22-alpine >/dev/null 2>&1; then + echo "Pulling node:22-alpine (first-run only)..." + docker pull node:22-alpine + fi + + node_runner="docker run --rm node:22-alpine node" +fi + +# Read JWT_SECRET from .env +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2- | tr -d '\r') +if [ -z "$jwt_secret" ]; then + echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Node.js does the crypto-heavy work: +# - EC P-256 keypair generation +# - JWKS construction (with symmetric key included) +# - ES256 JWT signing +# - Opaque API key generation with checksum +$node_runner -e ' +const crypto = require("crypto"); + +const jwtSecret = process.argv[1]; + +// Generate EC P-256 keypair and export as JWK +const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); +const jwkPrivate = privateKey.export({ format: "jwk" }); + +const kid = crypto.randomUUID(); + +// Symmetric key as JWK (base64url-encoded) +const octKey = { + kty: "oct", + k: Buffer.from(jwtSecret).toString("base64url"), + alg: "HS256" +}; + +// JWKS with private key (for Auth to sign tokens) +const jwksKeypair = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d }, + octKey +]}; + +// JWKS with public key only (for PostgREST, Realtime, Storage to verify) +const jwksPublic = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y }, + octKey +]}; + +// Sign ES256 JWT +function signES256(payload) { + const header = { alg: "ES256", typ: "JWT", kid }; + const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url"); + const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const data = b64Header + "." + b64Payload; + const sig = crypto.sign("SHA256", Buffer.from(data), { + key: privateKey, + dsaEncoding: "ieee-p1363" + }).toString("base64url"); + return data + "." + sig; +} + +const iat = Math.floor(Date.now() / 1000); +const exp = iat + 5 * 365 * 24 * 3600; // 5 years + +const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp }); +const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp }); + +// Generate opaque API keys with checksum +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +// Output as KEY=value lines for shell to parse +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +console.log("ANON_KEY_ASYMMETRIC=" + anonJwt); +console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt); +console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys)); +console.log("JWT_JWKS=" + JSON.stringify(jwksPublic)); +' "$jwt_secret" > "$tmpdir/output" + +# Read generated values +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) +ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-) +JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" +echo "JWT_KEYS=${JWT_KEYS}" +echo "" +echo "JWT_JWKS=${JWT_JWKS}" +echo "" +echo "Ensure the following configuration is uncommented in docker-compose.yml for the asymmetric key pair to work:" +echo "" +echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}" +echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +# Append new variables if they don't exist, or update them if they do +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done + +# Uncomment new auth configuration in docker-compose.yml +echo "Updating docker-compose.yml..." +if [ ! -f docker-compose.yml ]; then + echo "Error: docker-compose.yml not found in $(pwd)" + exit 1 +fi + +# Always fall through to the grep check +sed -i.old \ + -e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \ + -e '/^[ ]*#API_JWT_JWKS:/ s/#//' \ + -e '/^[ ]*#JWT_JWKS:/ s/#//' \ + docker-compose.yml || true + +if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \ + grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \ + grep -q '^[ ]*JWT_JWKS:' docker-compose.yml; then + echo "Done." +else + echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually." +fi diff --git a/utils/db-passwd.sh b/utils/db-passwd.sh new file mode 100644 index 0000000..40c9122 --- /dev/null +++ b/utils/db-passwd.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# +# Portions of this code are derived from Inder Singh's update-db-pass.sh +# Copyright 2025 Inder Singh. Licensed under Apache License 2.0. +# Original source: +# https://github.com/singh-inder/supabase-automated-self-host/blob/main/docker/update-db-pass.sh +# +# GitHub discussion here: +# https://github.com/supabase/supabase/issues/22605#issuecomment-3323382144 +# +# Changed: +# - POSIX shell compatibility +# - No hardcoded values for database service and admin user +# - Use .env for the admin user and database service port +# - Does _not_ set password for supabase_read_only_user (this role is not +# supposed to have a password) +# - Print all values and confirm before updating +# - Stop on any errors +# +# Heads up: +# - Updating _analytics.source_backends is not needed after PR logflare#2069 +# - Newer Logflare versions use a different table and update connection string +# + +set -e + +if ! docker compose version > /dev/null 2>&1; then + echo "Docker Compose not found." + exit 1 +fi + +if [ ! -f .env ]; then + echo "Missing .env file. Exiting." + exit 1 +fi + +# Generate random hex-only password to avoid issues with SQL/shell +new_passwd="$(openssl rand -hex 16)" +# If replacing with a custom password, avoid using @/?#:& +# https://supabase.com/docs/guides/database/postgres/roles#passwords +# new_passwd="d0notUseSpecialSymbolsForPq123-" + +# Check Postgres service +db_image_prefix="supabase.postgres:" + +compose_output=$(docker compose ps \ + --format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | \ + grep -m1 "^$db_image_prefix" || true) + +if [ -z "$compose_output" ]; then + echo "Postgres container not found. Exiting." + exit 1 +fi + +db_image=$(echo "$compose_output" | cut -f1) +db_srv_name=$(echo "$compose_output" | cut -f2) +db_srv_status=$(echo "$compose_output" | cut -f3) + +case "$db_srv_status" in + Up*) + ;; + *) + echo "Postgres container status: $db_srv_status" + echo "Exiting." + exit 1 + ;; +esac + +db_srv_port=$(grep "^POSTGRES_PORT=" .env | cut -d '=' -f 2) +port_source=" (.env):" +if [ -z "$db_srv_port" ]; then + db_srv_port="5432" + port_source=" (default):" +fi + +db_admin_user="supabase_admin" + +echo "" +echo "*** Check configuration below before updating database passwords! ***" +echo "" +echo "Service name: $db_srv_name" +echo "Service status: $db_srv_status" +echo "Service port${port_source} $db_srv_port" +echo "Image: $db_image" +echo "" +echo "Admin user: $db_admin_user" + +if ! test -t 0; then + echo "" + echo "Running non-interactively. Not updating passwords." + exit 0 +fi + +echo "New database password: $new_passwd" +echo "" + +printf "Update database passwords? (y/N) " +read -r REPLY +case "$REPLY" in + [Yy]) + ;; + *) + echo "Canceled. Not updating passwords." + exit 0 + ;; +esac + +echo "Updating passwords..." +echo "Connecting to the database service container..." + +docker compose exec -T "$db_srv_name" psql -U "$db_admin_user" -d "_supabase" -v ON_ERROR_STOP=1 </dev/null 2>&1; then + echo "Error: openssl is required but not found." + exit 1 +fi + +jwt_secret="$(gen_base64 30)" + +# Used in gen_token() +header='{"alg":"HS256","typ":"JWT"}' +iat=$(date +%s) +exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years + +# Normalizes JSON formatting so that the token matches https://www.jwt.io/ results +anon_payload="{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}" +service_role_payload="{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}" + +#echo "anon_payload=$anon_payload" +#echo "service_role_payload=$service_role_payload" + +anon_key=$(gen_token "$anon_payload") +service_role_key=$(gen_token "$service_role_payload") + +secret_key_base=$(gen_base64 48) +vault_enc_key=$(gen_hex 16) +pg_meta_crypto_key=$(gen_base64 24) + +logflare_public_access_token=$(gen_base64 24) +logflare_private_access_token=$(gen_base64 24) + +s3_protocol_access_key_id=$(gen_hex 16) +s3_protocol_access_key_secret=$(gen_hex 32) + +minio_root_password=$(gen_hex 16) + +echo "" +echo "JWT_SECRET=${jwt_secret}" +echo "" +#echo "Issued at: $iat" +#echo "Expire: $exp" +echo "ANON_KEY=${anon_key}" +echo "SERVICE_ROLE_KEY=${service_role_key}" +echo "" +echo "SECRET_KEY_BASE=${secret_key_base}" +echo "VAULT_ENC_KEY=${vault_enc_key}" +echo "PG_META_CRYPTO_KEY=${pg_meta_crypto_key}" +echo "LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}" +echo "LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}" +echo "S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}" +echo "S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}" +echo "MINIO_ROOT_PASSWORD=${minio_root_password}" +echo "" + +postgres_password=$(gen_hex 16) +dashboard_password=$(gen_hex 16) + +echo "POSTGRES_PASSWORD=${postgres_password}" +echo "DASHBOARD_PASSWORD=${dashboard_password}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +sed \ + -i.old \ + -e "s|^JWT_SECRET=.*$|JWT_SECRET=${jwt_secret}|" \ + -e "s|^ANON_KEY=.*$|ANON_KEY=${anon_key}|" \ + -e "s|^SERVICE_ROLE_KEY=.*$|SERVICE_ROLE_KEY=${service_role_key}|" \ + -e "s|^SECRET_KEY_BASE=.*$|SECRET_KEY_BASE=${secret_key_base}|" \ + -e "s|^VAULT_ENC_KEY=.*$|VAULT_ENC_KEY=${vault_enc_key}|" \ + -e "s|^PG_META_CRYPTO_KEY=.*$|PG_META_CRYPTO_KEY=${pg_meta_crypto_key}|" \ + -e "s|^LOGFLARE_PUBLIC_ACCESS_TOKEN=.*$|LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}|" \ + -e "s|^LOGFLARE_PRIVATE_ACCESS_TOKEN=.*$|LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}|" \ + -e "s|^S3_PROTOCOL_ACCESS_KEY_ID=.*$|S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}|" \ + -e "s|^S3_PROTOCOL_ACCESS_KEY_SECRET=.*$|S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}|" \ + -e "s|^MINIO_ROOT_PASSWORD=.*$|MINIO_ROOT_PASSWORD=${minio_root_password}|" \ + -e "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=${postgres_password}|" \ + -e "s|^DASHBOARD_PASSWORD=.*$|DASHBOARD_PASSWORD=${dashboard_password}|" \ + .env diff --git a/utils/reassign-owner.sh b/utils/reassign-owner.sh new file mode 100644 index 0000000..d13b170 --- /dev/null +++ b/utils/reassign-owner.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# +# Reassign ownership of public schema objects from supabase_admin to postgres. +# +# Context and documentation: +# https://supabase.com/docs/guides/self-hosting/remove-superuser-access +# +# Credits: +# Original version by Inder Singh. +# +# Usage: +# sh utils/reassign-owner.sh +# + +set -e + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose not found." + exit 1 +fi + +# Check Postgres service +db_image_prefix="supabase.postgres:" + +compose_output=$(docker compose ps \ + --format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | + grep -m1 "^$db_image_prefix" || true) + +if [ -z "$compose_output" ]; then + echo "Postgres container not found. Exiting." + exit 1 +fi + +db_srv_name=$(echo "$compose_output" | cut -f2) +db_srv_status=$(echo "$compose_output" | cut -f3) + +case "$db_srv_status" in + Up*) + ;; + *) + echo "Postgres container status: $db_srv_status" + echo "Exiting." + exit 1 + ;; +esac + +if ! test -t 0; then + echo "" + echo "Running non-interactively. Not reassigning ownership." + exit 0 +fi + +printf "Reassign public schema objects to postgres user? (y/N) " +read -r REPLY +case "$REPLY" in + [Yy]) + ;; + *) + echo "Canceled. Not reassigning ownership." + exit 0 + ;; +esac + +docker compose exec -T "$db_srv_name" psql -v ON_ERROR_STOP=1 -U supabase_admin -d postgres <<'EOF' +\echo 'Current supabase_admin-owned objects in public schema:' +SELECT c.relname, c.relkind, c.relowner::regrole +FROM pg_class c +WHERE c.relnamespace = 'public'::regnamespace +AND c.relowner = 'supabase_admin'::regrole; + +-- Reassign user objects in public schema from supabase_admin to postgres. +-- (Only affects public schema; Supabase-managed schemas stay as-is. +-- Extension-owned objects are skipped.) +DO $$ +DECLARE + rec record; + rel_count int := 0; + fn_count int := 0; + type_count int := 0; +BEGIN + -- Tables, views, sequences, materialized views, partitioned tables + FOR rec IN + SELECT c.relname, c.relkind + FROM pg_class c + WHERE c.relnamespace = 'public'::regnamespace + AND c.relowner = 'supabase_admin'::regrole + AND c.relkind IN ('r', 'v', 'S', 'm', 'p') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass + AND d.objid = c.oid + AND d.deptype = 'e' + ) + ORDER BY CASE c.relkind + WHEN 'p' THEN 0 -- partitioned parents first; cascades ownership to partitions + WHEN 'm' THEN 1 + WHEN 'r' THEN 2 + WHEN 'v' THEN 3 + WHEN 'S' THEN 4 + END + LOOP + EXECUTE format('ALTER TABLE public.%I OWNER TO postgres', rec.relname); + rel_count := rel_count + 1; + END LOOP; + + -- Functions and procedures + FOR rec IN + SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + WHERE p.pronamespace = 'public'::regnamespace + AND p.proowner = 'supabase_admin'::regrole + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_proc'::regclass + AND d.objid = p.oid + AND d.deptype = 'e' + ) + LOOP + EXECUTE format('ALTER ROUTINE public.%I(%s) OWNER TO postgres', rec.proname, rec.args); + fn_count := fn_count + 1; + END LOOP; + + -- Types (excluding array types and table-bound composites) + FOR rec IN + SELECT t.typname + FROM pg_type t + WHERE t.typnamespace = 'public'::regnamespace + AND t.typowner = 'supabase_admin'::regrole + AND t.typrelid = 0 + AND NOT EXISTS ( + SELECT 1 FROM pg_type el + WHERE el.oid = t.typelem + AND el.typarray = t.oid + ) + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_type'::regclass + AND d.objid = t.oid + AND d.deptype = 'e' + ) + LOOP + EXECUTE format('ALTER TYPE public.%I OWNER TO postgres', rec.typname); + type_count := type_count + 1; + END LOOP; + + RAISE NOTICE 'Reassigned % relation(s), % routine(s), % type(s) from supabase_admin to postgres.', + rel_count, fn_count, type_count; +END +$$; +EOF + +echo "" +echo "Done." diff --git a/utils/rotate-new-api-keys.sh b/utils/rotate-new-api-keys.sh new file mode 100644 index 0000000..48fd9f0 --- /dev/null +++ b/utils/rotate-new-api-keys.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# +# Rotate opaque API keys for a self-hosted Supabase installation. +# +# Regenerates SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY +# without touching the asymmetric key pair (JWKS) or JWT tokens. +# +# Usage: +# sh rotate-new-api-keys.sh # Interactive: prints keys, prompts to update .env +# sh rotate-new-api-keys.sh --update-env # Prints keys and writes them to .env +# sh rotate-new-api-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file (run generate-keys.sh and add-new-auth-keys.sh first) +# - node (>= 16) or docker +# + +set -e + +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null +} + +# Resolve how to run node: local install (>= 16) preferred, docker fallback. +if node_ok; then + node_runner="node" +else + if command -v node >/dev/null 2>&1; then + echo "Local node $(node -v) is too old (need >= 16), falling back to docker." + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "Error: requires either node (>= 16) or docker." + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "Error: docker is installed but the daemon is not running." + exit 1 + fi + + if ! docker image inspect node:22-alpine >/dev/null 2>&1; then + echo "Pulling node:22-alpine (first-run only)..." + docker pull node:22-alpine + fi + + node_runner="docker run --rm node:22-alpine node" +fi + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +$node_runner -e ' +const crypto = require("crypto"); + +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +' > "$tmpdir/output" + +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done diff --git a/utils/upgrade-pg17.sh b/utils/upgrade-pg17.sh new file mode 100644 index 0000000..6bdc97b --- /dev/null +++ b/utils/upgrade-pg17.sh @@ -0,0 +1,754 @@ +#!/usr/bin/env bash +# +# Requires bash (not sh) for pipefail, which ensures failures in piped +# commands are caught during the upgrade. +# +# Upgrade self-hosted Supabase Postgres from 15 to 17. +# +# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a +# temporary PG15 container, then swaps data directories and starts Postgres 17. +# +# Usage (must be run as root or with sudo): +# cd docker/ +# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation) +# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts) +# +# Requirements: +# - Docker with Docker Compose (docker compose, not docker-compose) +# - Running Supabase self-hosted setup with Postgres 15 +# - At least 2x current database size + 5 GB free disk space +# +# Backup: +# The original Postgres 15 data directory is preserved as +# ./volumes/db/data.bak.pg15 during the upgrade. +# DO NOT DELETE it until you have verified the upgrade was successful. +# +# Rollback (if the upgrade fails or you want to revert): +# 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down +# 2. rm -rf ./volumes/db/data +# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data +# 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/ +# 5. docker compose up -d +# + +# Ensure we're running under bash (not sh/zsh/dash). +# Check that $BASH ends with /bash (not /sh, /zsh, etc.). +case "${BASH:-}" in + */bash) ;; + *) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;; +esac + +set -euo pipefail + +AUTO_CONFIRM=false +for arg in "$@"; do + case "$arg" in + --yes|-y) AUTO_CONFIRM=true ;; + esac +done + +# --- Configuration ---------------------------------------------------------- + +# Image used for the upgrade tarball + complete.sh container. +# Must share glibc with PG15 (the extracted ELF binaries run inside PG15). +PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063" +# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts) +PG17_SCRIPTS_REF="17.6.1.063" +DB_CONTAINER="supabase-db" +UPGRADE_CONTAINER="supabase-pg-upgrade" +COMPLETE_CONTAINER="supabase-pg-complete" + +DATA_DIR="./volumes/db/data" +BACKUP_DIR="./volumes/db/data.bak.pg15" +# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it +PG17_TAG="${PG17_UPGRADE_IMAGE##*:}" +TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz" +# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/ +MIGRATION_DIR="./volumes/db/data_migration" + +# --- Helpers ---------------------------------------------------------------- + +die() { printf 'Error: %s\n' "$*" >&2; exit 1; } +info() { printf '\n==> %s\n' "$*"; } +warn() { printf 'Warning: %s\n' "$*" >&2; } + +# Temp dir on host for tarball + scripts (mounted into containers) +staging_dir="" +pg_password="" +current_image="" +drop_extensions="" +db_config_vol="" + +# Remove leftover containers and staging dir on exit. +# Uses an alpine container for rm because the tarball build runs as root +# inside Docker - the resulting files are root-owned and can't be deleted +# by the host user on macOS. +cleanup() { + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true + if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then + docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true + rm -rf "$staging_dir" 2>/dev/null || true + fi +} +trap cleanup EXIT + +on_interrupt() { + echo "" + warn "Interrupted. Cleaning up..." + # If db-config was chowned to PG17, restore for PG15 rollback + if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then + docker run --rm -v "${db_config_vol}:/vol" "$current_image" \ + chown -R postgres:postgres /vol/ 2>/dev/null || true + fi + die "Interrupted." +} +trap on_interrupt INT + +confirm() { + if [ "$AUTO_CONFIRM" = true ]; then return 0; fi + if ! test -t 0; then + die "This script must be run interactively, or use --yes to skip prompts." + fi + printf '%s (y/N) ' "$1" + read -r reply + case "$reply" in + [Yy]*) return 0 ;; + *) echo "Aborted."; exit 0 ;; + esac +} + +run_sql_on() { + local container=$1; shift + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$container" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@" +} + +wait_for_healthy() { + local container=$1 retries=30 + while [ $retries -gt 0 ]; do + if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then + return 0 + fi + retries=$((retries - 1)) + sleep 1 + done + die "Postgres in '$container' did not become ready in 30 seconds." +} + +# --- Pre-flight checks ----------------------------------------------------- + +preflight() { + info "Running pre-flight checks" + + if [ "$(id -u)" -ne 0 ]; then + die "This script must be run as root (e.g. sudo bash $0)." + fi + + docker compose version >/dev/null 2>&1 || die "Docker Compose not found." + command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)." + [ -f docker-compose.yml ] || die "Run this script from the docker/ directory." + [ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml." + [ -f .env ] || die "Missing .env file." + + # Resolve db-config volume (exact match on _db-config suffix or bare db-config) + db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \ + | grep -E '^db-config$|_db-config$' | head -n 1) + [ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?" + + # Read the target PG17 image from the compose override (what the user will run) + PG17_TARGET_IMAGE=$(grep 'image:.*postgres' docker-compose.pg17.yml | awk '{print $2}' | head -n 1) + [ -n "$PG17_TARGET_IMAGE" ] || die "Could not read image from docker-compose.pg17.yml." + + pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1) + [ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env." + + docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \ + || die "Container '$DB_CONTAINER' not found. Is Supabase running?" + + current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}') + case "$current_image" in + supabase/postgres:15.*|supabase.postgres:15.*) ;; + supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;; + *) die "Unexpected database image: $current_image" ;; + esac + + local status + status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}') + [ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)." + [ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR" + + if [ -d "$BACKUP_DIR" ]; then + warn "Backup directory already exists: $BACKUP_DIR" + warn "This is likely from a previous upgrade attempt." + warn "If you haven't verified that previous upgrade, roll back first:" + warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down" + warn " 2. rm -rf $DATA_DIR" + warn " 3. mv $BACKUP_DIR $DATA_DIR" + warn " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + warn " 5. docker compose up -d" + echo "" + warn "Continuing will DELETE the existing backup permanently." + confirm "Delete $BACKUP_DIR and start a fresh upgrade?" + rm -rf "$BACKUP_DIR" + fi + if [ -d "$MIGRATION_DIR" ]; then + rm -rf "$MIGRATION_DIR" + fi + + # Disk space + local data_size_kb data_size_mb avail_kb avail_mb needed_mb + data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1) + [ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR" + data_size_mb=$((data_size_kb / 1024)) + avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }') + [ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")" + avail_mb=$((avail_kb / 1024)) + needed_mb=$((data_size_mb * 2 + 5000)) + echo " Data size: ${data_size_mb} MB" + echo " Available space: ${avail_mb} MB" + echo " Estimated need: ${needed_mb} MB" + if [ "$avail_mb" -lt "$needed_mb" ]; then + warn "Disk space may be insufficient." + warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball." + confirm "Continue anyway?" + fi + + # Incompatible extensions + info "Checking for incompatible extensions" + local incompatible + incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c " + SELECT string_agg(extname, ', ') + FROM pg_extension + WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls'); + " 2>/dev/null | tr -d '[:space:]') || true + + if [ -n "$incompatible" ]; then + warn "Incompatible extensions found: $incompatible" + warn "These do not exist in Postgres 17 and must be dropped before upgrading." + warn "If you proceed, they will be dropped automatically." + warn "The original data is preserved as a backup so you can roll back." + confirm "Drop these extensions and continue with the upgrade?" + drop_extensions="$incompatible" + fi + + echo "" + echo "This script will:" + echo " 1. Pull the Postgres 17 image" + echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)" + echo " 3. Stop all Supabase services" + echo " 4. Run pg_upgrade (Postgres 15 -> 17)" + echo " 5. Apply post-upgrade patches" + echo " 6. Start Supabase with Postgres 17" + echo " 7. Apply additional migrations" + echo "" + echo " Current image: $current_image" + echo " Target image: $PG17_TARGET_IMAGE" + echo " Upgrade image: $PG17_UPGRADE_IMAGE" + echo " Data directory: $DATA_DIR" + echo " Backup location: $BACKUP_DIR" + echo "" + confirm "Proceed with the upgrade?" +} + +# --- Step 1: Pull Postgres 17 image ---------------------------------------- + +pull_image() { + info "Pulling Postgres 17 images" + docker pull "$PG17_UPGRADE_IMAGE" + if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then + docker pull "$PG17_TARGET_IMAGE" + fi +} + +# --- Step 2: Build upgrade tarball ----------------------------------------- +# +# Extracts PG17 binaries, libraries, share data, and upgrade scripts from +# the PG17 Docker image into a tarball that initiate.sh can consume. +# +# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no +# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find +# the bundled libraries. + +build_tarball() { + local tmpbase="${TMPDIR:-/tmp}" + staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX") + # World-writable so Docker containers can write to bind mounts on macOS, + # where the VM's root user has no special access to host directories. + chmod 777 "$staging_dir" + echo " Staging directory: $staging_dir" + + # Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF). + # These are no longer bundled in the latest PG17 Docker images. + info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)" + local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts" + mkdir -p "$staging_dir/scripts" + for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do + curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \ + || die "Failed to download $script from GitHub" + done + + if [ -f "$TARBALL_CACHE" ]; then + info "Using cached upgrade tarball: $TARBALL_CACHE" + cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz" + return + fi + + info "Building upgrade tarball from Postgres 17 image (first run)" + docker run --rm --user root --entrypoint bash \ + -v "$staging_dir:/export" \ + "$PG17_UPGRADE_IMAGE" \ + -c ' + set -euo pipefail + mkdir -p /export/17/bin /export/17/lib /export/17/share + + echo " Copying binaries..." + # Binaries in the nix profile are either ELF binaries or shell + # wrappers that exec a .xxx-wrapped ELF from the nix store. + # Extract the actual ELF binaries so they work outside nix. + BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres)) + for f in "$BIN_DIR"/*; do + name=$(basename "$f") + + # Skip nix wrapper-internal files + case "$name" in .*-wrapped) continue ;; esac + + # Check for ELF + if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then + cp "$f" /export/17/bin/"$name" + else + # Shell wrapper - extract the real .xxx-wrapped ELF path + wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true) + if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then + cp "$wrapped" /export/17/bin/"$name" + else + cp "$f" /export/17/bin/"$name" + fi + fi + done + + echo " Copying libraries..." + PKGLIBDIR=$(pg_config --pkglibdir) + LIBDIR=$(pg_config --libdir) + + # These paths may overlap (PKGLIBDIR and LIBDIR often point to the + # same nix store path). Use cp -Lf to handle overwrites from + # read-only nix store source files. + cp -Lf "$PKGLIBDIR"/*.so /export/17/lib/ || echo " Warning: cp from $PKGLIBDIR failed" >&2 + cp -Lf "$LIBDIR"/*.so* /export/17/lib/ || echo " Warning: cp from $LIBDIR failed" >&2 + cp -Lf /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ || echo " Warning: cp from nix profile lib failed" >&2 + + echo " Copying share data..." + + # Nix-built binaries resolve share dir relative to their location: + # /../share/postgresql/ + # so we need share/postgresql/ not just share/ + mkdir -p /export/17/share/postgresql + + # Remove cyclic symlink (timezonesets/timezonesets -> timezonesets). + rm -f /usr/share/postgresql/timezonesets/timezonesets 2>/dev/null || true + + # Pre-create subdirectories so cp -rL does not need to mkdir them. + # On macOS with Docker Desktop bind mount rejects mkdir with the nix store + # read-only (dr-xr-xr-x) permissions; pre-creating with default + # writable permissions fixed this + mkdir -p /export/17/share/postgresql/{extension,timezonesets,tsearch_data} + mkdir -p /export/17/share/postgresql/extension/{functions,procedures,tables,types} + + cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ || echo " Warning: cp share data had errors" >&2 + + # initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/ + echo " Copying extension definitions to lib..." + SHAREDIR=$(pg_config --sharedir) + cp "$SHAREDIR"/extension/*.control /export/17/lib/ || echo " Warning: cp .control from $SHAREDIR/extension failed" >&2 + cp "$SHAREDIR"/extension/*.sql /export/17/lib/ || echo " Warning: cp .sql from $SHAREDIR/extension failed" >&2 + + # Verify critical files before creating tarball + echo " Checking for key files..." + [ -f /export/17/bin/postgres ] || { echo "Error: bin/postgres missing"; exit 1; } + [ -f /export/17/share/postgresql/timezonesets/Default ] || { echo "Error: timezonesets/Default missing"; exit 1; } + ls /export/17/share/postgresql/extension/*.control >/dev/null 2>&1 || { echo "Error: no .control files in extension/"; exit 1; } + ls /export/17/lib/*.so >/dev/null 2>&1 || { echo "Error: no .so files in lib/"; exit 1; } + + echo " Creating tarball (this may take several minutes)..." + cd /export && tar czf pg_upgrade_bin.tar.gz 17/ + + echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)" + ' + + # Cache for next run + cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE" + info "Tarball cached at $TARBALL_CACHE" +} + +# --- Step 3: Drop incompatible extensions ---------------------------------- + +drop_incompatible_extensions() { + if [ -z "$drop_extensions" ]; then + return + fi + info "Dropping incompatible extensions" + + local ext + echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do + ext=$(echo "$ext" | tr -d '[:space:]') + [ -z "$ext" ] && continue + echo " DROP EXTENSION $ext CASCADE" + run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;" + done +} + +# --- Step 4: Stop services and back up ------------------------------------- + +stop_and_backup() { + info "Backing up pgsodium root key" + local key_backup="./volumes/db/pgsodium_root.key.bak.pg15" + docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \ + alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \ + || die "Failed to back up pgsodium root key from db-config volume." + echo " Saved to: $key_backup" + + info "Stopping all Supabase services" + docker compose down + + echo " Original data will be preserved as: $BACKUP_DIR" +} + +# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------ +# +# Host directories are mounted at non-standard paths (/mnt/host-*) with +# symlinks at the paths the upgrade scripts expect. This lets complete.sh's +# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and +# /data_migration) operate on symlinks rather than bind mounts. + +run_upgrade() { + local abs_data_dir abs_migration_dir + + mkdir -p "$MIGRATION_DIR" + # World-writable for macOS Docker bind mount compatibility (see build_tarball) + chmod 777 "$MIGRATION_DIR" + abs_data_dir=$(cd "$DATA_DIR" && pwd) + abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd) + + info "Starting upgrade container" + docker run -d --name "$UPGRADE_CONTAINER" \ + --entrypoint sleep \ + -v "${abs_data_dir}:/mnt/host-pgdata" \ + -v "${abs_migration_dir}:/mnt/host-migration" \ + -v "${db_config_vol}:/etc/postgresql-custom" \ + -v "${staging_dir}:/tmp/staging:ro" \ + -e PGPASSWORD="$pg_password" \ + "$current_image" \ + infinity + + info "Preparing upgrade environment" + docker exec "$UPGRADE_CONTAINER" bash -c ' + # Symlink bind mounts to the paths the upgrade scripts expect + rm -rf /var/lib/postgresql/data + ln -s /mnt/host-pgdata /var/lib/postgresql/data + ln -s /mnt/host-migration /data_migration + + mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade + cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/ + cp /tmp/staging/scripts/*.sh /tmp/upgrade/ + chmod +x /tmp/upgrade/*.sh + + # Patch CI_start_postgres to use "restart" instead of "start" so it + # is idempotent (initiate.sh starts postgres for top-level queries, + # then handle_extensions calls CI_start_postgres again) + sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh + + # Patch PGSHARENEW to match nix binary expectations (share/postgresql/) + sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh + ' + + info "Starting Postgres 15 in upgrade container" + docker exec "$UPGRADE_CONTAINER" bash -c ' + su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log" + ' + wait_for_healthy "$UPGRADE_CONTAINER" + + # initiate.sh expects the PG17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz + # (hardcoded path - copied there during container setup above). + # + # Env vars for the unwrapped nix ELF binaries in the tarball: + # LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths) + # NIX_PGLIBDIR - postgres uses this to find extension .so files + + info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)" + echo " This may take several minutes depending on database size..." + echo "" + if ! docker exec \ + -e IS_CI=true \ + -e PG_MAJOR_VERSION=17 \ + -e PGPASSWORD="$pg_password" \ + -e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \ + -e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \ + "$UPGRADE_CONTAINER" \ + /tmp/upgrade/initiate.sh 17; then + echo "" + warn "initiate.sh failed. Its cleanup may have restored the original state" + warn "(re-enabled extensions, revoked superuser). Your data directory is" + warn "unchanged - no data was moved or deleted." + warn "" + warn "Check the output above for the root cause, fix it, and re-run." + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true + die "initiate.sh failed" + fi + + info "initiate.sh completed successfully" + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true +} + +# --- Step 6: Run complete.sh in a native PG17 container ------------------- +# +# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption, +# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container +# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed. + +run_complete() { + local abs_migration_dir + + abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd) + + info "Starting PG17 container for complete.sh" + docker run -d --name "$COMPLETE_CONTAINER" \ + --entrypoint sleep \ + -v "${abs_migration_dir}:/mnt/host-migration" \ + -v "${db_config_vol}:/etc/postgresql-custom" \ + -v "${staging_dir}:/tmp/staging:ro" \ + -e PGPASSWORD="$pg_password" \ + "$PG17_UPGRADE_IMAGE" \ + infinity + + info "Preparing complete.sh environment" + # Save original db-config ownership so we can restore it if complete.sh fails. + # complete.sh needs PG17 ownership to start postgres, but if it fails the + # user needs to fall back to PG15 which uses a different uid. + docker exec "$COMPLETE_CONTAINER" bash -c ' + stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true + ' + + docker exec "$COMPLETE_CONTAINER" bash -c ' + # Symlink bind mount so complete.sh CI wrapper can mv/rm/ln + ln -s /mnt/host-migration /data_migration + + # Remove the image default data dir (complete.sh creates a symlink here) + rm -rf /var/lib/postgresql/data + + # Fix ownership on db-config volume (PG15 uid differs from PG17) + chown -R postgres:postgres /etc/postgresql-custom/ + + # PG17 config includes this directory; may not exist from PG15 + mkdir -p /etc/postgresql-custom/conf.d + + mkdir -p /tmp/upgrade + + # Copy upgrade scripts + cp /tmp/staging/scripts/*.sh /tmp/upgrade/ + chmod +x /tmp/upgrade/*.sh + + # Patch --new-bin to use native bindir (we are in a PG17 container, + # no need for /tmp/pg_upgrade_bin/ paths) + sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh + ' + + info "Running complete.sh (post-upgrade patches, vacuum analyze)" + docker exec \ + -e IS_CI=true \ + -e PG_MAJOR_VERSION=17 \ + -e PGPASSWORD="$pg_password" \ + "$COMPLETE_CONTAINER" \ + /tmp/upgrade/complete.sh || true + + # complete.sh's ERR trap exits with 0 in some cases; check status file + local status + status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown") + if [ "$status" != "complete" ]; then + warn "complete.sh failed. Postgres log:" + docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true + echo "" + # Restore db-config ownership so PG15 can start for rollback + warn "Restoring db-config ownership for PG15..." + local orig_owner + orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true) + if [ -n "$orig_owner" ]; then + docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true + fi + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true + echo "" + echo " Your Postgres 15 data is unchanged (data swap has not happened yet)." + echo " To restart Postgres 15:" + echo " rm -rf $MIGRATION_DIR" + echo " docker compose up -d" + echo "" + die "complete.sh failed (status: $status)" + fi + + info "complete.sh finished successfully" + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true +} + +# --- Step 7: Swap data directories ----------------------------------------- + +swap_data() { + info "Swapping data directories" + + echo " $DATA_DIR -> $BACKUP_DIR" + mv "$DATA_DIR" "$BACKUP_DIR" + + echo " $MIGRATION_DIR/pgdata -> $DATA_DIR" + mv "$MIGRATION_DIR/pgdata" "$DATA_DIR" + rm -rf "$MIGRATION_DIR" +} + +# --- Step 8: Start Postgres 17 --------------------------------------------- + +start_pg17() { + info "Starting Supabase with Postgres 17" + + # Ensure db-config volume has correct ownership and structure for PG17. + # complete.sh does this too, but just in case of partial + # failures from previous runs. + docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c ' + mkdir -p /vol/conf.d + chown -R postgres:postgres /vol/ + ' + + docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d + + echo " Waiting for Postgres 17 to be ready..." + local retries=60 + while [ $retries -gt 0 ]; do + if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then + break + fi + retries=$((retries - 1)) + sleep 2 + done + [ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds." + + local new_version + new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1) + echo " Postgres version: $new_version" + case "$new_version" in + 17.*) ;; + *) die "Expected Postgres 17.x, got: $new_version" ;; + esac +} + +# --- Step 9: Apply migrations not covered by complete.sh ------------------- +# +# These PG17 migrations run on fresh installs via initdb but not after +# pg_upgrade (init scripts don't rerun when PG_VERSION already exists). +# complete.sh doesn't cover them either. +# +# Source: postgres/migrations/db/migrations/ +# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql +# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor) +# - 20251105172723_grant_pg_reload_conf_to_postgres.sql +# - 20251121132723_correct_search_path_pgbouncer.sql + +apply_role_migrations() { + info "Applying Postgres 17 migrations" + + # Fix collation version mismatch first (upgrade used glibc 2.39, target + # image may use glibc 2.40). Do this before any other SQL to suppress + # the noisy warnings on every subsequent command. + for db in postgres template1 _supabase; do + docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d "$db" \ + -c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true + done + + # Create supabase_etl_admin role (doesn't exist in PG15 images). + # Must be created before running predefined_role_grants.sql which + # assumes it exists. + run_sql_on "$DB_CONTAINER" -c " + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN + CREATE USER supabase_etl_admin WITH LOGIN REPLICATION; + GRANT pg_read_all_data TO supabase_etl_admin; + GRANT CREATE ON DATABASE postgres TO supabase_etl_admin; + END IF; + END + \$\$;" || true + + # Run the migration files directly from the PG17 container image. + # They're idempotent (IF EXISTS / IF NOT EXISTS guards). + local migration_dir="/docker-entrypoint-initdb.d/migrations" + local migrations=" + 20250710151649_supabase_read_only_user_default_transaction_read_only.sql + 20251001204436_predefined_role_grants.sql + 20251105172723_grant_pg_reload_conf_to_postgres.sql + 20251121132723_correct_search_path_pgbouncer.sql + " + + for m in $migrations; do + echo " Running: $m" + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \ + -f "${migration_dir}/${m}" || warn " $m failed (non-fatal)" + done +} + +# --- Step 10: Verify ------------------------------------------------------ + +verify() { + info "Verification" + + local version + version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1) + echo " $version" + + echo "" + echo " Extensions:" + run_sql_on "$DB_CONTAINER" -c \ + "SELECT extname, extversion FROM pg_extension ORDER BY extname;" + + echo "" + info "Upgrade complete!" + echo "" + echo " To use Postgres 17 going forward, always include the override:" + echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d" + echo "" + echo " Postgres 15 backup: $BACKUP_DIR" + echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15" + echo " Once satisfied, you can reclaim space:" + echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz" + echo "" + echo " Rollback (if needed):" + echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down" + echo " 2. rm -rf $DATA_DIR" + echo " 3. mv $BACKUP_DIR $DATA_DIR" + echo " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + echo " 5. docker compose up -d" + echo "" +} + +# --- Main ------------------------------------------------------------------- + +main() { + echo "" + echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade" + echo "================================================" + + preflight + pull_image + build_tarball + drop_incompatible_extensions + stop_and_backup + run_upgrade + run_complete + swap_data + start_pg17 + apply_role_migrations + verify +} + +main "$@" diff --git a/versions.md b/versions.md new file mode 100644 index 0000000..ee52012 --- /dev/null +++ b/versions.md @@ -0,0 +1,135 @@ +# Docker Image Versions + +## 2026-06-03 +- supabase/studio:2026.06.03-sha-0bca601 (prev supabase/studio:2026.04.27-sha-5f60601) +- supabase/gotrue:v2.189.0 (prev supabase/gotrue:v2.186.0) +- postgrest/postgrest:v14.12 (prev postgrest/postgrest:v14.8) +- supabase/realtime:v2.102.3 (prev supabase/realtime:v2.76.5) +- supabase/storage-api:v1.60.4 (prev supabase/storage-api:v1.48.26) +- supabase/postgres-meta:v0.96.6 (prev supabase/postgres-meta:v0.96.3) +- supabase/edge-runtime:v1.74.0 (prev supabase/edge-runtime:v1.71.2) +- supabase/supavisor:2.9.5 (prev supabase/supavisor:2.7.4) +- supabase/logflare:1.43.1 (prev supabase/logflare:1.36.1) + +## 2026-04-27 +- supabase/studio:2026.04.27-sha-5f60601 (prev supabase/studio:2026.04.08-sha-205cbe7) + +## 2026-04-08 +- supabase/studio:2026.04.08-sha-205cbe7 (prev supabase/studio:2026.03.16-sha-5528817) +- postgrest/postgrest:v14.8 (prev postgrest/postgrest:v14.6) +- supabase/storage-api:v1.48.26 (prev supabase/storage-api:v1.44.2) +- supabase/postgres-meta:v0.96.3 (prev supabase/postgres-meta:v0.95.2) +- supabase/logflare:1.36.1 (prev supabase/logflare:1.31.2) + +## 2026-03-16 +- supabase/studio:2026.03.16-sha-5528817 (prev supabase/studio:2026.02.16-sha-26c615c) +- kong/kong:3.9.1 (prev kong:2.8.1) +- postgrest/postgrest:v14.6 (prev postgrest/postgrest:v14.5) +- supabase/storage-api:v1.44.2 (prev supabase/storage-api:v1.37.8) +- supabase/edge-runtime:v1.71.2 (prev supabase/edge-runtime:v1.70.3) + +## 2026-02-16 +- supabase/studio:2026.02.16-sha-26c615c (prev supabase/studio:2026.01.27-sha-6aa59ff) +- supabase/gotrue:v2.186.0 (prev supabase/gotrue:v2.185.0) +- postgrest/postgrest:v14.5 (prev postgrest/postgrest:v14.3) +- supabase/realtime:v2.76.5 (prev supabase/realtime:v2.72.0) +- supabase/storage-api:v1.37.8 (prev supabase/storage-api:v1.37.1) +- supabase/edge-runtime:v1.70.3 (prev supabase/edge-runtime:v1.70.0) +- supabase/logflare:1.31.2 (prev supabase/logflare:1.30.3) +- timberio/vector:0.53.0-alpine (prev timberio/vector:0.28.1-alpine) + +## 2026-02-05 +- supabase/storage-api:v1.37.1 (prev supabase/storage-api:v1.33.5) + +## 2026-01-27 +- supabase/studio:2026.01.27-sha-6aa59ff (prev supabase/studio:2025.12.17-sha-43f4f7f) +- supabase/gotrue:v2.185.0 (prev supabase/gotrue:v2.184.0) +- postgrest/postgrest:v14.3 (prev postgrest/postgrest:v14.1) +- supabase/realtime:v2.72.0 (prev supabase/realtime:v2.68.0) +- supabase/storage-api:v1.33.5 (prev supabase/storage-api:v1.33.0) +- darthsim/imgproxy:v3.30.1 (prev darthsim/imgproxy:v3.8.0) +- supabase/postgres-meta:v0.95.2 (prev supabase/postgres-meta:v0.95.1) +- supabase/edge-runtime:v1.70.0 (prev supabase/edge-runtime:v1.69.28) +- supabase/logflare:1.30.3 (prev supabase/logflare:1.27.0) + +## 2025-12-18 +- supabase/studio:2025.12.17-sha-43f4f7f (prev supabase/studio:2025.12.09-sha-434634f) +- supabase/gotrue:v2.184.0 (prev supabase/gotrue:v2.183.0) +- supabase/postgres-meta:v0.95.1 (prev supabase/postgres-meta:v0.93.1) +- supabase/logflare:1.27.0 (prev supabase/logflare:1.26.25) + +## 2025-12-10 +- supabase/studio:2025.12.09-sha-434634f (prev supabase/studio:2025.11.26-sha-8f096b5) +- postgrest/postgrest:v14.1 (prev postgrest/postgrest:v13.0.7) +- supabase/realtime:v2.68.0 (prev supabase/realtime:v2.65.3) +- supabase/storage-api:v1.33.0 (prev supabase/storage-api:v1.32.0) +- supabase/edge-runtime:v1.69.28 (prev supabase/edge-runtime:v1.69.25) +- supabase/logflare:1.26.25 (prev supabase/logflare:1.26.13) + +## 2025-11-26 +- supabase/studio:2025.11.26-sha-8f096b5 (prev supabase/studio:2025.11.24-sha-d990ae8) +- supabase/realtime:v2.65.3 (prev supabase/realtime:v2.65.2) +- supabase/logflare:1.26.13 (prev supabase/logflare:1.26.12) + +## 2025-11-25 +- supabase/studio:2025.11.24-sha-d990ae8 (prev supabase/studio:2025.11.10-sha-5291fe3) +- supabase/gotrue:v2.183.0 (prev supabase/gotrue:v2.182.1) +- supabase/realtime:v2.65.2 (prev supabase/realtime:v2.63.0) +- supabase/storage-api:v1.32.0 (prev supabase/storage-api:v1.29.0) +- supabase/edge-runtime:v1.69.25 (prev supabase/edge-runtime:v1.69.23) +- supabase/logflare:1.26.12 (prev supabase/logflare:1.22.6) + +## 2025-11-12 +- supabase/studio:2025.11.10-sha-5291fe3 (prev supabase/studio:2025.10.27-sha-85b84e0) +- supabase/gotrue:v2.182.1 (prev supabase/gotrue:v2.180.0) +- supabase/realtime:v2.63.0 (prev supabase/realtime:v2.57.2) +- supabase/storage-api:v1.29.0 (prev supabase/storage-api:v1.28.2) +- supabase/edge-runtime:v1.69.23 (prev supabase/edge-runtime:v1.69.15) +- supabase/supavisor:2.7.4 (prev supabase/supavisor:2.7.3) + +## 2025-10-28 +- supabase/studio:2025.10.27-sha-85b84e0 (prev supabase/studio:2025.10.20-sha-5005fc6) +- supabase/realtime:v2.57.2 (prev supabase/realtime:v2.56.0) +- supabase/storage-api:v1.28.2 (prev supabase/storage-api:v1.28.1) +- supabase/postgres-meta:v0.93.1 (prev supabase/postgres-meta:v0.93.0) +- supabase/edge-runtime:v1.69.15 (prev supabase/edge-runtime:v1.69.14) + +## 2025-10-21 +- supabase/studio:2025.10.20-sha-5005fc6 (prev supabase/studio:2025.10.01-sha-8460121) +- supabase/realtime:v2.56.0 (prev supabase/realtime:v2.51.11) +- supabase/storage-api:v1.28.1 (prev supabase/storage-api:v1.28.0) +- supabase/postgres-meta:v0.93.0 (prev supabase/postgres-meta:v0.91.6) +- supabase/edge-runtime:v1.69.14 (prev supabase/edge-runtime:v1.69.6) +- supabase/supavisor:2.7.3 (prev supabase/supavisor:2.7.0) + +## 2025-10-13 +- supabase/logflare:1.22.6 (prev supabase/logflare:1.22.4) + +## 2025-10-08 +- supabase/studio:2025.10.01-sha-8460121 (prev supabase/studio:2025.06.30-sha-6f5982d) +- supabase/gotrue:v2.180.0 (prev supabase/gotrue:v2.177.0) +- postgrest/postgrest:v13.0.7 (prev postgrest/postgrest:v12.2.12) +- supabase/realtime:v2.51.11 (prev supabase/realtime:v2.34.47) +- supabase/storage-api:v1.28.0 (prev supabase/storage-api:v1.25.7) +- supabase/postgres-meta:v0.91.6 (prev supabase/postgres-meta:v0.91.0) +- supabase/logflare:1.22.4 (prev supabase/logflare:1.14.2) +- supabase/postgres:15.8.1.085 (prev supabase/postgres:15.8.1.060) +- supabase/supavisor:2.7.0 (prev supabase/supavisor:2.5.7) + +## 2025-07-15 +- supabase/gotrue:v2.177.0 (prev supabase/gotrue:v2.176.1) +- supabase/storage-api:v1.25.7 (prev supabase/storage-api:v1.24.7) +- supabase/postgres-meta:v0.91.0 (prev supabase/postgres-meta:v0.89.3) +- supabase/supavisor:2.5.7 (prev supabase/supavisor:2.5.6) + +## 2025-07-02 +- supabase/studio:2025.06.30-sha-6f5982d (prev supabase/studio:2025.06.02-sha-8f2993d) +- supabase/gotrue:v2.176.1 (prev supabase/gotrue:v2.174.0) +- supabase/storage-api:v1.24.7 (prev supabase/storage-api:v1.23.0) +- supabase/supavisor:2.5.6 (prev supabase/supavisor:2.5.1) + +## 2025-06-03 +- supabase/studio:2025.06.02-sha-8f2993d (prev supabase/studio:2025.05.19-sha-3487831) +- supabase/gotrue:v2.174.0 (prev supabase/gotrue:v2.172.1) +- supabase/storage-api:v1.23.0 (prev supabase/storage-api:v1.22.17) +- supabase/postgres-meta:v0.89.3 (prev supabase/postgres-meta:v0.89.0) diff --git a/volumes/api/envoy/cds.yaml b/volumes/api/envoy/cds.yaml new file mode 100644 index 0000000..2d47842 --- /dev/null +++ b/volumes/api/envoy/cds.yaml @@ -0,0 +1,223 @@ +resources: + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: auth + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: auth + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: auth + port_value: 9999 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /health + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: rest + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: rest + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: rest + port_value: 3000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: / + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: realtime + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: realtime + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: realtime-dev.supabase-realtime + port_value: 4000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: / + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: storage + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: storage + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: storage + port_value: 5000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /status + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: functions + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: functions + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: functions + port_value: 9000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + tcp_health_check: {} + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: meta + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: meta + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: meta + port_value: 8080 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /health + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: studio + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: studio + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: studio + port_value: 3000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /project/default + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 diff --git a/volumes/api/envoy/docker-entrypoint.sh b/volumes/api/envoy/docker-entrypoint.sh new file mode 100755 index 0000000..7836038 --- /dev/null +++ b/volumes/api/envoy/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +# Generate SHA1 base64 hash for Envoy basic auth user list +PASSWORD_HASH=$(printf '%s' "${DASHBOARD_PASSWORD}" | openssl sha1 -binary | openssl base64) +DASHBOARD_BASIC_AUTH="${DASHBOARD_USERNAME}:{SHA}${PASSWORD_HASH}" + +echo "Generating Envoy configuration..." + +# Process the lds.yaml template with environment variables using sed +# Using | as delimiter since JWT tokens contain / +sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \ + -e "s|\${ANON_KEY_ASYMMETRIC}|${ANON_KEY_ASYMMETRIC}|g" \ + -e "s|\${SERVICE_ROLE_KEY}|${SERVICE_ROLE_KEY}|g" \ + -e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \ + -e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \ + -e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \ + -e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \ + /etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml + +if [ -n "$SUPABASE_SECRET_KEY" ] && \ + [ -n "$SUPABASE_PUBLISHABLE_KEY" ] && \ + [ -n "$SERVICE_ROLE_KEY_ASYMMETRIC" ] && \ + [ -n "$ANON_KEY_ASYMMETRIC" ]; then + echo "Envoy sb_ key translation enabled" +else + echo "Envoy running in legacy API key mode (sb_ keys disabled)" +fi + +echo "Envoy configuration generated successfully" +echo "Starting Envoy..." + +# Start Envoy +exec envoy -c /etc/envoy/envoy.yaml "$@" diff --git a/volumes/api/envoy/envoy.yaml b/volumes/api/envoy/envoy.yaml new file mode 100644 index 0000000..bf3dd4e --- /dev/null +++ b/volumes/api/envoy/envoy.yaml @@ -0,0 +1,27 @@ +dynamic_resources: + cds_config: + path_config_source: + path: /etc/envoy/cds.yaml + resource_api_version: V3 + lds_config: + path_config_source: + path: /etc/envoy/lds.yaml + resource_api_version: V3 + +node: + cluster: supabase_cluster + id: supabase_node + +overload_manager: + resource_monitors: + - name: envoy.resource_monitors.global_downstream_max_connections + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig + max_active_downstream_connections: 30000 + +admin: + address: + socket_address: + address: 127.0.0.1 + port_value: 9901 diff --git a/volumes/api/envoy/lds.template.yaml b/volumes/api/envoy/lds.template.yaml new file mode 100644 index 0000000..62580b1 --- /dev/null +++ b/volumes/api/envoy/lds.template.yaml @@ -0,0 +1,994 @@ +resources: + - '@type': type.googleapis.com/envoy.config.listener.v3.Listener + name: supabase + per_connection_buffer_limit_bytes: 32768 # 32 KiB + + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + normalize_path: true + merge_slashes: true + path_with_escaped_slashes_action: REJECT_REQUEST + use_remote_address: true + common_http_protocol_options: + headers_with_underscores_action: REJECT_REQUEST + upgrade_configs: + - upgrade_type: websocket + access_log: + - name: envoy.access_loggers.stdout + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + log_format: + text_format_source: + inline_string: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT% - - [%START_TIME(%d/%b/%Y:%H:%M:%S %z)%] \"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%\" %RESPONSE_CODE% %BYTES_SENT% \"%REQ(REFERER)%\" \"%REQ(USER-AGENT)%\"\n" + + route_config: + name: supabase_route + virtual_hosts: + - name: supabase_host + domains: + - '*' + cors: + allow_origin_string_match: + - safe_regex: + regex: ".*" + allow_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD,CONNECT,TRACE" + allow_headers: "*" + expose_headers: "*" + max_age: "3600" + request_headers_to_add: + - header: + key: X-Forwarded-Host + value: "%REQ(:AUTHORITY)%" + append_action: ADD_IF_ABSENT + - header: + key: X-Forwarded-Port + value: "%DOWNSTREAM_LOCAL_PORT%" + append_action: ADD_IF_ABSENT + routes: + - match: + prefix: /auth/v1/verify + route: + cluster: auth + prefix_rewrite: /verify + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/verify + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/callback + route: + cluster: auth + prefix_rewrite: /callback + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/callback + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/authorize + route: + cluster: auth + prefix_rewrite: /authorize + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/authorize + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/.well-known/jwks.json + route: + cluster: auth + prefix_rewrite: /.well-known/jwks.json + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/.well-known/jwks.json + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /.well-known/oauth-authorization-server + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /.well-known/oauth-authorization-server + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /sso/saml/acs + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /sso/saml/acs + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /sso/saml/metadata + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /sso/saml/metadata + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - name: functions-v1-all + match: + prefix: /functions/v1/ + route: + cluster: functions + prefix_rewrite: / + timeout: 150s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /functions/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /storage/v1/ + route: + cluster: storage + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /storage/v1 + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - name: auth-v1-protected + match: + prefix: /auth/v1/ + route: + cluster: auth + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: rest-v1-protected + match: + prefix: /rest/v1/ + route: + cluster: rest + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /rest/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: graphql-v1-protected + match: + prefix: /graphql/v1 + route: + cluster: rest + prefix_rewrite: /rpc/graphql + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /graphql/v1 + append_action: ADD_IF_ABSENT + - header: + key: Content-Profile + value: graphql_public + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: realtime-v1-api-protected + match: + prefix: /realtime/v1/api + route: + cluster: realtime + prefix_rewrite: /api + timeout: 30s + host_rewrite_literal: realtime-dev.supabase-realtime + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/api + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: realtime-v1-ws-protected + match: + prefix: /realtime/v1/ + route: + cluster: realtime + prefix_rewrite: /socket/ + timeout: 30s + host_rewrite_literal: realtime-dev.supabase-realtime + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: pg-protected + match: + prefix: /pg/ + route: + cluster: meta + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /pg/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - match: + prefix: /api/mcp + route: + cluster: studio + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /api/mcp + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: DENY + policies: + deny_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /mcp + route: + cluster: studio + prefix_rewrite: /api/mcp + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /mcp + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + # Block access to /mcp by default + rbac: + rules: + action: DENY + policies: + deny_all: + permissions: + - any: true + principals: + - any: true + # Enable local access (danger zone!) + # 1. Comment out the 'rbac' block above. + # 2. Uncomment and adjust the 'rbac' block below. + # 3. Add or adjust your local IPs in 'principals'. + #rbac: + # rules: + # action: ALLOW + # policies: + # allow_local: + # permissions: + # - any: true + # principals: + # - direct_remote_ip: + # address_prefix: 127.0.0.1 + # prefix_len: 32 + # - direct_remote_ip: + # address_prefix: ::1 + # prefix_len: 128 + + - match: + prefix: / + route: + cluster: studio + timeout: 30s + request_headers_to_remove: + - authorization + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: / + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + http_filters: + - name: envoy.filters.http.cors + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + + - name: envoy.filters.http.basic_auth + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.basic_auth.v3.BasicAuth + users: + inline_string: '${DASHBOARD_BASIC_AUTH}' + + # Copies ?apikey=... from the URL into the apikey header when clients omit the header. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + if headers:get("apikey") ~= nil then + return + end + + local path = headers:get(":path") + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return + end + + local query = string.sub(path, query_start + 1) + for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do + if key == "apikey" and value ~= "" then + headers:add("apikey", value) + return + end + end + end + + # Returns 401 for missing/invalid API keys on protected API routes. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local ANON_KEY = "${ANON_KEY}" + local SERVICE_ROLE_KEY = "${SERVICE_ROLE_KEY}" + local SUPABASE_PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SUPABASE_SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local ANON_KEY_ASYMMETRIC = "${ANON_KEY_ASYMMETRIC}" + local SERVICE_ROLE_KEY_ASYMMETRIC = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SUPABASE_SECRET_KEY ~= "" and SUPABASE_PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_KEY_ASYMMETRIC ~= "" and ANON_KEY_ASYMMETRIC ~= "" + + local PROTECTED_ROUTES = { + ["auth-v1-protected"] = true, + ["rest-v1-protected"] = true, + ["graphql-v1-protected"] = true, + ["realtime-v1-api-protected"] = true, + ["realtime-v1-ws-protected"] = true, + ["pg-protected"] = true, + } + + local function is_protected_route(route_name) + if route_name == nil or route_name == "" then + return false + end + + return PROTECTED_ROUTES[route_name] == true + end + + local function is_valid_apikey(apikey) + if apikey == nil or apikey == "" then + return false + end + + if SERVICE_ROLE_KEY ~= "" and apikey == SERVICE_ROLE_KEY then + return true + end + + if ANON_KEY ~= "" and apikey == ANON_KEY then + return true + end + + if TRANSLATION_ENABLED and apikey == SUPABASE_SECRET_KEY then + return true + end + + if TRANSLATION_ENABLED and apikey == SUPABASE_PUBLISHABLE_KEY then + return true + end + + return false + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + local route_name = request_handle:streamInfo():routeName() + if not is_protected_route(route_name) then + return + end + + if is_valid_apikey(headers:get("apikey")) then + return + end + + request_handle:respond({ + [":status"] = "401", + ["content-type"] = "text/plain", + }, "Unauthorized") + end + + # Translates the query parameter apikey into the matching internal JWT and rewrites the URL so only JWTs propagate downstream. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local ANON_JWT = "${ANON_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= "" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function translate_apikey(apikey) + if apikey == nil or apikey == "" then + return nil + end + + if not TRANSLATION_ENABLED then + return nil + end + + if apikey == SECRET_KEY then + return SERVICE_ROLE_JWT + end + + if apikey == PUBLISHABLE_KEY then + return ANON_JWT + end + + return nil + end + + local function extract_query_apikey(path) + if path == nil or path == "" then + return nil + end + + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return nil + end + + local query = string.sub(path, query_start + 1) + for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do + if key == "apikey" and value ~= "" then + return value + end + end + + return nil + end + + local function replace_query_apikey(path, new_value) + if path == nil or path == "" or new_value == nil or new_value == "" then + return nil + end + + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return nil + end + + local base = string.sub(path, 1, query_start) + local query = string.sub(path, query_start + 1) + local updated = {} + local replaced = false + + for part in string.gmatch(query, "([^&]+)") do + local key, value = string.match(part, "([^=]+)=(.*)") + if key == "apikey" then + part = key .. "=" .. new_value + replaced = true + end + table.insert(updated, part) + end + + if not replaced then + return nil + end + + return base .. table.concat(updated, "&") + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + local path = headers:get(":path") + local apikey = extract_query_apikey(path) + local translated = translate_apikey(apikey) + + if translated == nil then + return + end + + headers:replace("apikey", translated) + + local rewritten_path = replace_query_apikey(path, translated) + if rewritten_path ~= nil then + headers:replace(":path", rewritten_path) + end + end + + # Translates an apikey header into the appropriate internal JWT for downstream RBAC checks. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local ANON_JWT = "${ANON_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= "" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function translate_apikey(apikey) + if apikey == nil or apikey == "" then + return nil + end + + if not TRANSLATION_ENABLED then + return nil + end + + if apikey == SECRET_KEY then + return SERVICE_ROLE_JWT + end + + if apikey == PUBLISHABLE_KEY then + return ANON_JWT + end + + return nil + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + local translated = translate_apikey(headers:get("apikey")) + if translated ~= nil and translated ~= "" then + headers:replace("apikey", translated) + end + end + + # Mirrors apikey into x-api-key for realtime WS compatibility. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local REALTIME_WS_ROUTE = "realtime-v1-ws-protected" + + function envoy_on_request(request_handle) + local route_name = request_handle:streamInfo():routeName() + if route_name ~= REALTIME_WS_ROUTE then + return + end + + local headers = request_handle:headers() + local apikey = headers:get("apikey") + if apikey == nil or apikey == "" then + return + end + + headers:replace("x-api-key", apikey) + end + + # Synthesizes an Authorization header (Bearer …) from apikey when callers don’t provide a real JWT header. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local REALTIME_WS_ROUTE = "realtime-v1-ws-protected" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function has_real_jwt(auth_header) + if auth_header == nil or auth_header == "" then + return false + end + + if string.sub(auth_header, 1, 7) ~= "Bearer " then + return false + end + + return string.sub(auth_header, 1, 10) ~= "Bearer sb_" + end + + local function format_authorization(value) + if value == nil or value == "" then + return nil + end + + if string.sub(value, 1, 7) == "Bearer " then + return value + end + + return "Bearer " .. value + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if request_handle:streamInfo():routeName() == REALTIME_WS_ROUTE then + return + end + + if is_functions_request(request_handle, headers) then + return + end + + if has_real_jwt(headers:get("authorization")) then + return + end + + local apikey = headers:get("apikey") + local authorization_value = format_authorization(apikey) + if authorization_value ~= nil then + headers:replace("authorization", authorization_value) + end + end + + - name: envoy.filters.http.rbac + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC + rules: + action: ALLOW + policies: + admin: + permissions: + - url_path: + path: + prefix: /pg/ + principals: + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY}' + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}' + apikey: + permissions: + - url_path: + path: + prefix: /auth/v1/ + - url_path: + path: + prefix: /rest/v1/ + - url_path: + path: + prefix: /realtime/v1/api + - url_path: + path: + prefix: /realtime/v1/ + - url_path: + path: + prefix: /graphql/v1 + principals: + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY}' + - header: + name: apikey + string_match: + exact: '${ANON_KEY}' + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}' + - header: + name: apikey + string_match: + exact: '${ANON_KEY_ASYMMETRIC}' + - name: envoy.filters.http.router + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/volumes/api/kong-entrypoint.sh b/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..d5eee93 --- /dev/null +++ b/volumes/api/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Custom entrypoint for Kong that builds Lua expressions for request-transformer +# and performs environment variable substitution in the declarative config. + +# Build Lua expressions for translating opaque API keys to asymmetric JWTs. +# When opaque keys are not configured (empty env vars), expressions fall through +# to legacy-only behavior - just passing apikey as-is. +# +# Full expression logic (when opaque keys are configured): +# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT) +# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key" +# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key" +# 4. Fallback: pass apikey as-is (legacy HS256 JWT) + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # Opaque keys configured -> full translation expressions + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" + + # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey + # via query string), outputs to x-api-key header which Realtime checks first. + export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" +else + # Legacy API keys, not sb_ API keys -> pass apikey through unchanged + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" + export LUA_RT_WS_EXPR="\$(query_params.apikey)" +fi + +# Substitute environment variables in the Kong declarative config. +# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double +# quotes, breaking "Header: value" patterns that YAML parses as mappings). +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +# Remove empty key-auth credentials (unconfigured opaque keys) +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/volumes/api/kong.yml b/volumes/api/kong.yml new file mode 100644 index 0000000..b89e868 --- /dev/null +++ b/volumes/api/kong.yml @@ -0,0 +1,408 @@ +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - key: $SUPABASE_PUBLISHABLE_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + - key: $SUPABASE_SECRET_KEY + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: '$DASHBOARD_USERNAME' + password: '$DASHBOARD_PASSWORD' + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + _comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*' + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + _comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*' + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + _comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*' + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + - name: auth-v1-open-jwks + _comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json' + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + - name: auth-v1-open-sso-acs + url: "http://auth:9999/sso/saml/acs" + routes: + - name: auth-v1-open-sso-acs + strip_path: true + paths: + - /sso/saml/acs + plugins: + - name: cors + + - name: auth-v1-open-sso-metadata + url: "http://auth:9999/sso/saml/metadata" + routes: + - name: auth-v1-open-sso-metadata + strip_path: true + paths: + - /sso/saml/metadata + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'Auth: /auth/v1/* -> http://auth:9999/*' + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/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 + - anon + + ## Secure PostgREST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /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 + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + 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: false + - name: request-transformer + config: + add: + headers: + - "Content-Profile: graphql_public" + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "x-api-key:$LUA_RT_WS_EXPR" + replace: + querystring: + - "apikey:$LUA_RT_WS_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + 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 + - anon + + ## Storage API endpoint (with Authorization header transformation). + ## No key-auth — S3 protocol requests don't carry an apikey header. + ## + ## The request-transformer translates opaque API keys to asymmetric JWTs + ## and passes through existing Authorization headers (user JWTs, AWS SigV4). + ## When no Authorization or apikey header is present (S3 presigned URLs), + ## the Lua expression evaluates to nil which Kong renders as empty string. + ## The post-function strips this empty header so Storage's S3 signature + ## verification falls through to query-parameter parsing. + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://functions:9000/ + read_timeout: 150000 + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## OAuth 2.0 Authorization Server Metadata (RFC 8414) + - name: well-known-oauth + _comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server' + url: http://auth:9999/.well-known/oauth-authorization-server + routes: + - name: well-known-oauth + strip_path: true + paths: + - /.well-known/oauth-authorization-server + plugins: + - name: cors + + ## Analytics routes + ## Not used - Studio and Vector talk directly to analytics via Docker networking. + ## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth. + # - name: analytics-v1-api + # _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*' + # url: http://analytics:4000/api/endpoints + # routes: + # - name: analytics-v1-api + # strip_path: true + # paths: + # - /analytics/v1/api/endpoints/ + # - name: analytics-v1 + # _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + # url: http://analytics:4000/ + # routes: + # - name: dashboard-v1-all + # strip_path: true + # paths: + # - /analytics/v1 + # plugins: + # - name: cors + # - name: basic-auth + # config: + # hide_credentials: true + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Block access to /api/mcp + - name: mcp-blocker + _comment: 'Block direct access to /api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp-blocker-route + strip_path: true + paths: + - /api/mcp + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + + ## MCP endpoint - local access + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + # Block access to /mcp by default + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + # Enable local access (danger zone!) + # 1. Comment out the 'request-termination' section above + # 2. Uncomment the entire section below, including 'deny' + # 3. Add your local IPs to the 'allow' list + #- name: cors + #- name: ip-restriction + # config: + # allow: + # - 127.0.0.1 + # - ::1 + # deny: [] + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/volumes/db/_supabase.sql b/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/volumes/db/init/data.sql b/volumes/db/init/data.sql new file mode 100755 index 0000000..e69de29 diff --git a/volumes/db/jwt.sql b/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/volumes/db/logs.sql b/volumes/db/logs.sql new file mode 100644 index 0000000..255c0f4 --- /dev/null +++ b/volumes/db/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/volumes/db/pooler.sql b/volumes/db/pooler.sql new file mode 100644 index 0000000..162c5b9 --- /dev/null +++ b/volumes/db/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/volumes/db/realtime.sql b/volumes/db/realtime.sql new file mode 100644 index 0000000..4d4b9ff --- /dev/null +++ b/volumes/db/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/volumes/db/roles.sql b/volumes/db/roles.sql new file mode 100644 index 0000000..8f7161a --- /dev/null +++ b/volumes/db/roles.sql @@ -0,0 +1,8 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/volumes/db/webhooks.sql b/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/volumes/functions/hello/index.ts b/volumes/functions/hello/index.ts new file mode 100644 index 0000000..e3f138b --- /dev/null +++ b/volumes/functions/hello/index.ts @@ -0,0 +1,14 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +Deno.serve(async () => { + return new Response( + `"Hello from Edge Functions!"`, + { headers: { "Content-Type": "application/json" } }, + ) +}) + +// To invoke: +// curl 'http://localhost:/functions/v1/hello' \ +// --header 'Authorization: Bearer ' diff --git a/volumes/functions/main/index.ts b/volumes/functions/main/index.ts new file mode 100644 index 0000000..ebe2061 --- /dev/null +++ b/volumes/functions/main/index.ts @@ -0,0 +1,168 @@ +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// Create JWKS for ES256/RS256 tokens (newer tokens) +let SUPABASE_JWT_KEYS: ReturnType | null = null +if (SUPABASE_URL) { + 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) + } +} + +/** + * Extract JWT token from Authorization header + * + * Parses the Authorization header to extract the Bearer token. + * Expects format: "Bearer " + * + * @param req - The HTTP request object + * @returns The JWT token string + * @throws Error if Authorization header is missing or malformed + */ +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function isValidLegacyJWT(jwt: string): Promise { + if (!JWT_SECRET) { + console.error('JWT_SECRET not available for HS256 token verification') + return false + } + + const encoder = new TextEncoder(); + const secretKey = encoder.encode(JWT_SECRET) + + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error('Symmetric Legacy JWT verification error', e); + return false; + } + return true; +} + +async function isValidJWT(jwt: string): Promise { + if (!SUPABASE_JWT_KEYS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + } catch (e) { + console.error('Asymmetric JWT verification error', e); + return false + } + + return true; +} + +/** + * Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms + * + * This function automatically detects the algorithm used in the token and applies + * the appropriate verification method: + * - HS256: Uses JWT_SECRET (symmetric key) + * - ES256/RS256: Uses JWKS endpoint (asymmetric public keys) + * + * This fix ensures compatibility with both legacy tokens and newer asymmetric tokens, + * resolving the "Key for the ES256 algorithm must be of type CryptoKey" error. + * + * @param jwt - The JWT token string to verify + * @returns Promise resolving to true if verification succeeds, false otherwise + */ +async function isValidHybridJWT(jwt: string): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) + + if (jwtAlgorithm === 'HS256') { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) + + return await isValidLegacyJWT(jwt) + } + + if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { + return await isValidJWT(jwt) + } + + return false; +} + +Deno.serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await isValidHybridJWT(token); + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/volumes/logs/vector.yml b/volumes/logs/vector.yml new file mode 100644 index 0000000..f63bfd8 --- /dev/null +++ b/volumes/logs/vector.yml @@ -0,0 +1,268 @@ +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + exclude_containers: + - supabase-vector + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong" || .appname == "supabase-envoy"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.response.status_code = req.status + url, split_err = split(req.request, " ") + if split_err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P