diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..4d6472e --- /dev/null +++ b/.env.production.example @@ -0,0 +1,15 @@ +# TEMPLATE DI PRODUZIONE per l'app CrAPP — copia in .env sul server e compila. +# I valori Supabase vengono dal .env dello stack self-hosted in infra/supabase/docker/ +# (ANON_KEY -> VITE_SUPABASE_PUBLISHABLE_KEY, SERVICE_ROLE_KEY -> SUPABASE_SERVICE_ROLE_KEY). + +VITE_SUPABASE_URL=https://tuodominio.it/api +VITE_SUPABASE_PUBLISHABLE_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Notifiche push (genera con: npx web-push generate-vapid-keys) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:tuamail@esempio.it + +# Porta su cui ascolta il server Node generato da Nitro (preset node-server) +PORT=3000 diff --git a/.gitignore b/.gitignore index 702c652..44b3a03 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ dist-ssr # Optional .vercel + +# Supabase self-host: dati runtime, mai versionati +infra/supabase/docker/volumes/db/data/ +infra/supabase/docker/volumes/storage/ diff --git a/infra/supabase/docker/.env.example b/infra/supabase/docker/.env.example new file mode 100644 index 0000000..eb130e1 --- /dev/null +++ b/infra/supabase/docker/.env.example @@ -0,0 +1,389 @@ +############ +# 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 + +# Encryption key for securing Realtime and Supavisor communications. +# (Must be at least 64 characters; generate with: openssl rand -base64 48) +SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + +# Encryption key used by Realtime for sensitive fields in the `_realtime` schema. +# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`) +REALTIME_DB_ENC_KEY=supabaserealtime + +# Encryption key used by Supavisor for storing encrypted configuration. +# (Must be exactly 32 characters; generate with: openssl rand -hex 16) +VAULT_ENC_KEY=your-32-character-encryption-key + +# Encryption key for securing connection strings used by Studio against postgres-meta. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min + +# API token for log ingestion used by Logflare and Vector. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public +# API token used for Logflare management operations. Never expose client-side. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private + +# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage. +# (Generate with: openssl rand -hex 16) +S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c +# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. +# (Generate with: openssl rand -hex 32) +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/auth/v1 + +# 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/accessing-postgres +POSTGRES_PORT=5432 + + +############ +# Database pooler +############ + +# Self-hosted Supabase uses Supavisor as the default database pooler. +# If you use the PgBouncer docker-compose override, Supavisor is disabled +# and the pooler settings below are used to configure PgBouncer instead. +# +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION. +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor or PgBouncer accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/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/auth/v1 + +# 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 +# Root administrator password for the RustFS or MinIO server. +# (Must be 8+ characters; generate with: openssl rand -hex 16) +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,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 +############ + +# Host port the API gateway (Envoy by default) listens on. +API_GW_HTTP_PORT=8000 + +# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is +# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for +# API_GW_HTTP_PORT so existing .env files continue to work. +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 placeholder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/infra/supabase/docker/.env.production.example b/infra/supabase/docker/.env.production.example new file mode 100644 index 0000000..26460c4 --- /dev/null +++ b/infra/supabase/docker/.env.production.example @@ -0,0 +1,402 @@ +# TEMPLATE DI PRODUZIONE — non usare i valori cosi' come sono. +# +# 1. Copia questo file in .env: cp .env.production.example .env +# 2. Rigenera TUTTI i secret (non riusare quelli di sviluppo): +# sh utils/generate-keys.sh --update-env +# 3. Sostituisci i placeholder tuodominio.it con il dominio reale (un solo hostname: le API +# Supabase sono servite sotto /api, instradate per path da Caddy — vedi Caddyfile.example). +# Con No-IP gratuito, es. crapp.ddns.net per tutto. +# 4. Imposta una DASHBOARD_PASSWORD robusta e, se servono email +# (reset password, inviti), un SMTP reale al posto di quello finto. +# 5. Avvia con l'override che non espone porte pubblicamente: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +############ +# 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=CAMBIAMI-password-robusta + +# Encryption key for securing Realtime and Supavisor communications. +# (Must be at least 64 characters; generate with: openssl rand -base64 48) +SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + +# Encryption key used by Realtime for sensitive fields in the `_realtime` schema. +# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`) +REALTIME_DB_ENC_KEY=supabaserealtime + +# Encryption key used by Supavisor for storing encrypted configuration. +# (Must be exactly 32 characters; generate with: openssl rand -hex 16) +VAULT_ENC_KEY=your-32-character-encryption-key + +# Encryption key for securing connection strings used by Studio against postgres-meta. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min + +# API token for log ingestion used by Logflare and Vector. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public +# API token used for Logflare management operations. Never expose client-side. +# (Must be at least 32 characters; generate with openssl rand -base64 24) +LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private + +# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage. +# (Generate with: openssl rand -hex 16) +S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c +# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. +# (Generate with: openssl rand -hex 32) +S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 + + +############ +# URLs - Configure hostnames below to reflect your actual domain name +############ + +# Access to Dashboard and REST API (dietro Caddy: /api/* -> questo gateway, path stripped) +SUPABASE_PUBLIC_URL=https://tuodominio.it/api + +# Full external URL of the Auth service, used to construct OAuth callbacks, +# SAML endpoints, and email links +API_EXTERNAL_URL=https://tuodominio.it/api/auth/v1 + +# 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/accessing-postgres +POSTGRES_PORT=5432 # non esporre su internet, vedi docker-compose.prod.yml + + +############ +# Database pooler +############ + +# Self-hosted Supabase uses Supavisor as the default database pooler. +# If you use the PgBouncer docker-compose override, Supavisor is disabled +# and the pooler settings below are used to configure PgBouncer instead. +# +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION. +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor or PgBouncer accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/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=https://tuodominio.it +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/auth/v1 + +# 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 +# Root administrator password for the RustFS or MinIO server. +# (Must be 8+ characters; generate with: openssl rand -hex 16) +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,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 +############ + +# Host port the API gateway (Envoy by default) listens on. +API_GW_HTTP_PORT=8000 + +# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is +# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for +# API_GW_HTTP_PORT so existing .env files continue to work. +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 placeholder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/infra/supabase/docker/.gitattributes b/infra/supabase/docker/.gitattributes new file mode 100644 index 0000000..95aa9f9 --- /dev/null +++ b/infra/supabase/docker/.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/infra/supabase/docker/.gitignore b/infra/supabase/docker/.gitignore new file mode 100644 index 0000000..1f9c343 --- /dev/null +++ b/infra/supabase/docker/.gitignore @@ -0,0 +1,16 @@ +volumes/db/data +volumes/storage +volumes/snippets +volumes/functions/** +!volumes/functions/deno.json* +!volumes/functions/main/ +volumes/functions/main/** +!volumes/functions/main/index.ts +!volumes/functions/hello/ +volumes/functions/hello/** +!volumes/functions/hello/index.ts +.env +test.http +docker-compose.override.yml +.supabase-version +backups diff --git a/infra/supabase/docker/CHANGELOG.md b/infra/supabase/docker/CHANGELOG.md new file mode 100644 index 0000000..cefc055 --- /dev/null +++ b/infra/supabase/docker/CHANGELOG.md @@ -0,0 +1,676 @@ +# 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`. + +--- + +## [0.8.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.8.0) - 2026-08-11 + +⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below: +- Envoy is now the default API gateway, replacing Kong. Kong stays available as an opt-in override with `sh run.sh config add kong`. See the [heads-up discussion](https://github.com/orgs/supabase/discussions/48048) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#48153](https://github.com/supabase/supabase/pull/48153) + +### Configuration +- ⚠️ Added `API_GW_HTTP_PORT` (falls back to `KONG_HTTP_PORT`; requires `.env` and `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153) + +### Documentation +- Updated several self-hosting how-to guides to reflect the current configuration - PR [#48153](https://github.com/supabase/supabase/pull/48153) +- Updated architecture diagram for self-hosted Supabase - PR [#48763](https://github.com/supabase/supabase/pull/48763) + +### Utils and tests +- Updated `tests/` to match the API gateway switch to Envoy - PR [#48153](https://github.com/supabase/supabase/pull/48153) + +### API gateway +- ⚠️ Changed the default API gateway from Kong to Envoy; the `kong` service is now `api-gw` (requires `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153) +- Changed `docker-compose.envoy.yml` to a no-op shim now that Envoy is the default (requires `docker-compose.envoy.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153) +- Added an optional override for Kong (requires new `docker-compose.kong.yml`) - PR [#48153](https://github.com/supabase/supabase/pull/48153) +- Updated the Caddy and nginx reverse proxies to forward to `api-gw` (requires `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/proxy/caddy/Caddyfile`, and `volumes/proxy/nginx/supabase-nginx.conf.tpl` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153) + +--- + +## [0.7.2](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.2) - 2026-08-04 + +### Utils and tests +- Fixed `update.sh` overwriting itself during an update; the new `update.sh` is now staged as `update.sh.new` for review instead of replacing the running script - PR [#48690](https://github.com/supabase/supabase/pull/48690) +- `update.sh` now fetches only the `docker/` directory (partial clone), making updates substantially faster and lighter - PR [#48690](https://github.com/supabase/supabase/pull/48690) + +--- + +## [0.7.1](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.1) - 2026-08-03 + +### Configuration +- Added `SUPABASE_JWKS` configuration for Edge Functions to `docker-compose.yml` - PR [#45635](https://github.com/supabase/supabase/pull/45635) +- Added `upgrades.json` - a version-keyed manifest to gate breaking changes (required for `update.sh`) - PR [#47851](https://github.com/supabase/supabase/pull/47851) +- Updated `.gitignore` (required for `update.sh`) + +### Documentation +- Added new how-to guides ([Custom Postgres Extensions](https://supabase.com/docs/guides/self-hosting/custom-postgres-extensions) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating)) - PR [#48203](https://github.com/supabase/supabase/pull/48203), PR [#48535](https://github.com/supabase/supabase/pull/48535) + +### Utils and tests +- Added base version stamp to `setup.sh` (saved in `.supabase-version`, required for `update.sh`) - PR [#47848](https://github.com/supabase/supabase/pull/47848) +- Added `update.sh`. Refer to [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#47851](https://github.com/supabase/supabase/pull/47851) +- Added `SUPABASE_JWKS` configuration for Edge Functions to `utils/add-new-auth-keys.sh` - PR [#45635](https://github.com/supabase/supabase/pull/45635) +- Updated `tests/test-s3.sh` and `test-s3-backend.sh` - PR [#48500](https://github.com/supabase/supabase/pull/48500) + +### API gateway +- Updated Kong to `3.9.3` +- Added `KONG_DNS_VALID_TTL` configuration environment variable (requires `docker-compose.yml` update) - PR [#47846](https://github.com/supabase/supabase/pull/47846) +- Updated Envoy to `1.39.0` (requires `docker-compose.envoy.yml` update) +- Updated [nginx-certbot](https://github.com/JonasAlfredsson/docker-nginx-certbot) to `6.2.0-nginx1.31.3` (requires `docker-compose.nginx.yml` update) + +### Studio +- Updated to `2026.08.03-sha-022b374` +- Fixed URL generation for Edge Functions - PR [#47861](https://github.com/supabase/supabase/pull/47861) (via [@7ttp](https://github.com/7ttp)) +- Fixed the Logs tab visibility in **Auth > Users** - PR [#48122](https://github.com/supabase/supabase/pull/48122) (via [@luizfelmach](https://github.com/luizfelmach/)) + +### Storage +- Changed RustFS image to `1.0.0-beta.11` temporarily (requires `docker-compose.rustfs.yml` update) - PR [#48500](https://github.com/supabase/supabase/pull/48500) + +### Edge Runtime +- Changed JWKS configuration mechanism for main worker (requires `docker-compose.yml` and `volumes/functions/main/index.ts` update) - PR [#45635](https://github.com/supabase/supabase/pull/45635) + +--- + +## [0.7.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.0) - 2026-07-07 + +⚠️ **Note:** This update contains **breaking changes**: +- Access to the OpenAPI spec at `/rest/v1/` via the anon (publishable) key has been removed. Requests using the service role or new secret API key are unaffected, and data access via `/rest/v1/your_table` or any client library continues to work as-is. See discussion [#42949](https://github.com/orgs/supabase/discussions/42949) +- `API_EXTERNAL_URL` has been updated to include the `/auth/v1` path prefix (e.g. `http://localhost:8000/auth/v1`), aligning self-hosted with the platform and CLI. This makes custom OAuth providers work out of the box and moves SAML SSO endpoints to `/auth/v1/sso/saml/*`. See discussion [#47093](https://github.com/orgs/supabase/discussions/47093) and PR [#47640](https://github.com/supabase/supabase/pull/47640) + +### Configuration +- ⚠️ Added `KONG_ROUTER_FLAVOR` to the compose configuration for Kong (requires `docker-compose.yml` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462) +- ⚠️ Changed the default `API_EXTERNAL_URL` in `.env.example` to contain `/auth/v1` - PR [#47640](https://github.com/supabase/supabase/pull/47640) +- ⚠️ Changed the default `PGRST_DB_SCHEMAS` to `public,graphql_public` in `.env.example` to avoid exposing `storage` (a protected schema) + +### Documentation +- Minor updates to the how-to guides following the configuration changes + +### Utils and tests +- Updated `setup.sh` to match the new `API_EXTERNAL_URL` configuration +- Updated `utils/generate-keys.sh` to also generate a unique `REALTIME_DB_ENC_KEY` +- Updated `tests/test-self-hosted.sh` and `tests/test-auth-keys.sh` to reflect the changes in the API gateway configuration + +### API gateway +- ⚠️ Updated Kong and Envoy configuration to restrict access to PostgREST `/rest/v1/` (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462) (via [@luizfelmach](https://github.com/luizfelmach/)) +- ⚠️ Updated Kong and Envoy configuration to match the new `/auth/v1/sso` routing for SAML SSO (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640) + +### Studio +- Updated to `2026.07.07-sha-a6a04f2` +- Fixed the local SQL snippets not being shown in the SQL Editor - PR [#47403](https://github.com/supabase/supabase/pull/47403), PR [#47409](https://github.com/supabase/supabase/pull/47409) +- Fixed the exposed schemas and tables UI to properly reflect non-platform configuration (**Data API > Settings**) - PR [#47511](https://github.com/supabase/supabase/pull/47511) +- Fixed the behavior of the type generator (**Data API > Docs**) - PR [#47577](https://github.com/supabase/supabase/pull/47577) + +### Auth +- ⚠️ Changed Auth configuration placeholders to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640) +- ⚠️ Changed `GOTRUE_JWT_ISSUER` to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640) + +### Realtime +- ⚠️ Added a new configuration variable `REALTIME_DB_ENC_KEY` for Realtime with a fallback to the default value (requires `docker-compose.yml` update) - PR [#46021](https://github.com/supabase/supabase/pull/46021) + +--- + +## [0.6.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.6.0) - 2026-06-17 + +⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below: +- **Postgres 17 is now the default**. Do not start Postgres 17 on an existing Postgres 15 data directory. See the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) guide. Check the **Configuration** and **Postgres** sections for additional information +- API gateway configuration includes a **security fix** for Realtime routes - it is **strongly recommended** to add this update to any self-hosted Supabase instance running Realtime +- Studio and Postgres Meta configuration now use `postgres` and not `supabase_admin` to connect to Postgres + +### Configuration +- ⚠️ Changed the default Postgres image to `supabase/postgres:17.6.1.136` - PR [#46981](https://github.com/supabase/supabase/pull/46981) +- ⚠️ Added `docker-compose.pg15.yml` - for deployments not yet upgraded, and as the rollback target for `utils/upgrade-pg17.sh` - PR [#46981](https://github.com/supabase/supabase/pull/46981) +- Updated `docker-compose.pg17.yml` to match the new default - PR [#46981](https://github.com/supabase/supabase/pull/46981) + +### Documentation +- Updated the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) how-to - PR [#46989](https://github.com/supabase/supabase/pull/46989) +- Updated the [New API Keys](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) and [Envoy API Gateway](https://supabase.com/docs/guides/self-hosting/self-hosted-envoy) how-to guides - PR [#46856](https://github.com/supabase/supabase/pull/46856) +- Updated [CONFIG.md](CONFIG.md) - PR [#47022](https://github.com/supabase/supabase/pull/47022) + +### Utils and tests +- Updated `utils/upgrade-pg17.sh` (bumped Postgres image, added additional migrations), and `tests/test-pg17-upgrade.sh` (added tests for pg_cron) - PR [#46981](https://github.com/supabase/supabase/pull/46981) +- Updated `tests/test-self-hosted.sh` (added tests for resumable upload, modified tests for Realtime and GraphQL) - PR [#46731](https://github.com/supabase/supabase/pull/46731), PR [#46856](https://github.com/supabase/supabase/pull/46856), PR [#46981](https://github.com/supabase/supabase/pull/46981) +- Updated `tests/test-auth-keys.sh` (modified tests for Realtime) - PR [#46856](https://github.com/supabase/supabase/pull/46856) + +### API gateway +- ⚠️ Updated Kong and Envoy configuration to block access to Realtime `/api/tenants` and `/api/openapi` endpoints. This is a **security fix** (requires `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#46856](https://github.com/supabase/supabase/pull/46856) +- Updated entrypoint for Kong to use `/bin/sh` (requires `docker-compose.yml` update) - PR [#46873](https://github.com/supabase/supabase/pull/46873) + +### Studio +- ⚠️ Updated `studio` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update). See discussion [#46081](https://github.com/orgs/supabase/discussions/46081) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) for important information - PR [#47022](https://github.com/supabase/supabase/pull/47022) + +### PostgREST +- Added healthcheck for `rest` (requires `docker-compose.yml` update) - PR [#46658](https://github.com/supabase/supabase/pull/46658) + +### Postgres Meta +- ⚠️ Updated `meta` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update) - PR [#47022](https://github.com/supabase/supabase/pull/47022) + +### Edge Runtime +- Added healthcheck for `functions` (requires `docker-compose.yml` update) - PR [#46655](https://github.com/supabase/supabase/pull/46655) + +### Postgres +- ⚠️ Updated the default image to `17.6.1.136` (from `15.8.1.085`). `pg_graphql` is now **disabled by default** on fresh installs. Databases that already use GraphQL keep it after an upgrade. See discussion [#46080](https://github.com/orgs/supabase/discussions/46080) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) for more information - PR [#46981](https://github.com/supabase/supabase/pull/46981) + +--- + +## [0.5.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.5.0) - 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/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/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/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/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/infra/supabase/docker/CONFIG.md b/infra/supabase/docker/CONFIG.md new file mode 100644 index 0000000..7570528 --- /dev/null +++ b/infra/supabase/docker/CONFIG.md @@ -0,0 +1,1453 @@ +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 by the local MCP server when running in read-only mode. | Default: `supabase_read_only_user`. This role has no password by default, so read-only MCP will fail to connect. To enable, assign a password matching `POSTGRES_PASSWORD`. | +| `POSTGRES_USER_READ_WRITE` | string | Both | Postgres role used for read/write queries from the SQL editor. | Default: `postgres`. | +| `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,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_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 | Self-hosted | 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/infra/supabase/docker/Caddyfile.example b/infra/supabase/docker/Caddyfile.example new file mode 100644 index 0000000..339a376 --- /dev/null +++ b/infra/supabase/docker/Caddyfile.example @@ -0,0 +1,18 @@ +# Reverse proxy di produzione, TLS automatico via Let's Encrypt. +# Copia in Caddyfile, sostituisci il dominio, poi: +# caddy run --config Caddyfile +# (o come container: docker run -p 80:80 -p 443:443 -v ./Caddyfile:/etc/caddy/Caddyfile caddy) +# +# Un solo hostname: /api/* va al gateway Supabase (envoy accetta qualsiasi Host, instrada per +# path), tutto il resto va all'app. handle_path toglie il prefisso /api prima di inoltrare, quindi +# VITE_SUPABASE_URL nell'app deve essere https:///api (supabase-js aggiunge da solo +# /rest/v1, /auth/v1, ecc.). + +crapp.ddns.net { + handle_path /api/* { + reverse_proxy 127.0.0.1:8000 + } + handle { + reverse_proxy 127.0.0.1:3000 + } +} diff --git a/infra/supabase/docker/README.md b/infra/supabase/docker/README.md new file mode 100644 index 0000000..48c8220 --- /dev/null +++ b/infra/supabase/docker/README.md @@ -0,0 +1,96 @@ +
+ +[![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 +- **[Envoy](https://www.envoyproxy.io/)** - API gateway (default; Kong is available as an optional override via `sh run.sh config add kong`) +- **[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 +- **[Update your deployment](https://supabase.com/docs/guides/self-hosting/updating)** - Update an existing deployment with `update.sh` + +## Updates + +Back up your database, then: + +```sh +sh update.sh --dry-run # optional preview +sh update.sh +sh run.sh pull && sh run.sh recreate +``` + +See the **[update guide](https://supabase.com/docs/guides/self-hosting/updating)** for conflicts, +breaking changes, pinning a release, and older installs without `.supabase-version`. + +## 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/infra/supabase/docker/dev/data.sql b/infra/supabase/docker/dev/data.sql new file mode 100644 index 0000000..2328004 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/dev/docker-compose.dev.yml b/infra/supabase/docker/dev/docker-compose.dev.yml new file mode 100644 index 0000000..e6b2e76 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/docker-compose.caddy.yml b/infra/supabase/docker/docker-compose.caddy.yml new file mode 100644 index 0000000..9f99ff3 --- /dev/null +++ b/infra/supabase/docker/docker-compose.caddy.yml @@ -0,0 +1,42 @@ +services: + + # Caddy terminates TLS and forwards to the API gateway (api-gw) on port 8000, + # so the gateway's own host port binding is removed here. This works for + # either gateway, since the service is named api-gw in both cases. + api-gw: + ports: !reset [] + # When using the Kong override uncomment the following: + #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 + 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/infra/supabase/docker/docker-compose.envoy.yml b/infra/supabase/docker/docker-compose.envoy.yml new file mode 100644 index 0000000..93ff2b0 --- /dev/null +++ b/infra/supabase/docker/docker-compose.envoy.yml @@ -0,0 +1,15 @@ +# DEPRECATED: Envoy is now the default API gateway defined directly in +# docker-compose.yml, so this override is no longer needed and does nothing. +# +# This no-op shim is kept for one release cycle so existing COMPOSE_FILE +# entries referencing it do not break. Remove it from your configuration: +# +# sh run.sh config remove envoy +# +# To run Kong instead of Envoy, use the Kong override: +# +# sh run.sh config add kong +# +# This file will be removed in a future release. + +services: {} diff --git a/infra/supabase/docker/docker-compose.kong.yml b/infra/supabase/docker/docker-compose.kong.yml new file mode 100644 index 0000000..74793f6 --- /dev/null +++ b/infra/supabase/docker/docker-compose.kong.yml @@ -0,0 +1,49 @@ +# Kong API gateway override. +# +# Replaces the default Envoy gateway with Kong. Enable with: +# sh run.sh config add kong +# sh run.sh start +# +# This overrides the `api-gw` service in place, so its dependents (functions), +# the reverse-proxy overrides (caddy/nginx), and the `kong`/`envoy` network +# aliases keep working unchanged. Kong re-adds an HTTPS listener on 8443. + +services: + api-gw: + container_name: supabase-kong + image: kong/kong:3.9.3 + healthcheck: + test: !override ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + ports: !override + - ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp + - ${KONG_HTTPS_PORT:-8443}:8443/tcp + volumes: !override + - ./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: !override + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + KONG_ROUTER_FLAVOR: expressions + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_DNS_VALID_TTL: 5 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + 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: !override ["/bin/sh", "/home/kong/kong-entrypoint.sh"] diff --git a/infra/supabase/docker/docker-compose.logs.yml b/infra/supabase/docker/docker-compose.logs.yml new file mode 100644 index 0000000..2249a72 --- /dev/null +++ b/infra/supabase/docker/docker-compose.logs.yml @@ -0,0 +1,99 @@ +# This override adds the following to the self-hosted Supabase configuration: +# - Logflare: Log management and event analytics platform +# - Vector: High-performance observability data pipeline for logs +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.logs.yml up -d + +services: + + studio: + depends_on: + analytics: + condition: service_healthy + environment: + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN} + LOGFLARE_URL: http://analytics:4000 + ENABLED_FEATURES_LOGS_ALL: "true" + + analytics: + container_name: supabase-analytics + image: supabase/logflare:1.43.1 + restart: unless-stopped + #ports: + # - 4000:4000 + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL -o /dev/null http://localhost:4000/health" + ] + timeout: 5s + interval: 5s + retries: 10 + start_period: 30s + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_SCHEMA: _analytics + + # Enable single-tenant mode for Logflare + LOGFLARE_SINGLE_TENANT: "true" + # Seed Supabase-related metadata + LOGFLARE_SUPABASE_MODE: "true" + + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN} + + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + + # Comment out the following two variables when switching to + # the BigQuery backend for logs + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + + # Uncomment to use the BigQuery backend for logs + #GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} + #GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} + # Uncomment to use the BigQuery backend for logs (requires gcloud.json + # service account key from Google Cloud Console) + #volumes: + # - ./gcloud.json:/opt/app/rel/logflare/bin/gcloud.json:ro,z + + vector: + container_name: supabase-vector + image: timberio/vector:0.53.0-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://vector:9001/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + command: + [ + "--config", + "/etc/vector/vector.yml" + ] + security_opt: + - "label=disable" diff --git a/infra/supabase/docker/docker-compose.nginx.yml b/infra/supabase/docker/docker-compose.nginx.yml new file mode 100644 index 0000000..0414b3a --- /dev/null +++ b/infra/supabase/docker/docker-compose.nginx.yml @@ -0,0 +1,41 @@ +services: + + # nginx terminates TLS and forwards to the API gateway (api-gw) on port 8000, + # so the gateway's own host port binding is removed here. This works for + # either gateway, since the service is named api-gw in both cases. + api-gw: + ports: !reset [] + # When using the Kong override, uncomment the following: + #environment: + # KONG_PORT_MAPS: "443:8000,443:8443" + + nginx: + container_name: supabase-nginx + image: jonasal/nginx-certbot:6.2.0-nginx1.31.3 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + depends_on: + api-gw: + condition: service_healthy + studio: + condition: service_healthy + environment: + PROXY_DOMAIN: ${PROXY_DOMAIN} + CERTBOT_EMAIL: ${CERTBOT_EMAIL} + PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME} + PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD} + command: + - /bin/bash + - -c + - | + printf '%s:%s\n' "$${PROXY_AUTH_USERNAME}" "$$(openssl passwd -apr1 "$${PROXY_AUTH_PASSWORD}")" > /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/infra/supabase/docker/docker-compose.pg15.yml b/infra/supabase/docker/docker-compose.pg15.yml new file mode 100644 index 0000000..f87fe8c --- /dev/null +++ b/infra/supabase/docker/docker-compose.pg15.yml @@ -0,0 +1,18 @@ +# Postgres 15 override for self-hosted Supabase. +# +# Postgres 17 is now the default in docker-compose.yml. Use this override to pin +# Postgres 15, for two cases: +# 1. An existing Postgres 15 deployment that has not been upgraded yet. +# 2. As the rollback target for utils/upgrade-pg17.sh (PG 15 and PG 17 use +# different postgres UIDs, so rollback must start with the PG 15 image). +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d +# +# To upgrade an existing Postgres 15 database to Postgres 17 in place, see: +# - https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17 +# - utils/upgrade-pg17.sh + +services: + db: + image: supabase/postgres:15.8.1.085 diff --git a/infra/supabase/docker/docker-compose.pg17.yml b/infra/supabase/docker/docker-compose.pg17.yml new file mode 100644 index 0000000..a8e3711 --- /dev/null +++ b/infra/supabase/docker/docker-compose.pg17.yml @@ -0,0 +1,19 @@ +# Postgres 17 override for self-hosted Supabase. +# +# NOTE: Postgres 17 is now the default in docker-compose.yml, so this override +# is redundant for new installs (kept for explicitness / backwards compatibility). +# Keep the image tag here in sync with the db image in docker-compose.yml. +# +# 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.136 diff --git a/infra/supabase/docker/docker-compose.pgbouncer.yml b/infra/supabase/docker/docker-compose.pgbouncer.yml new file mode 100644 index 0000000..d745ed4 --- /dev/null +++ b/infra/supabase/docker/docker-compose.pgbouncer.yml @@ -0,0 +1,56 @@ +# PgBouncer override for self-hosted Supabase +# +# Replaces the default Supavisor pooler with PgBouncer in transaction mode. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up -d +# +# Or use run.sh to add PgBouncer to the stack: +# sh run.sh config add pgbouncer +# sh run.sh start +# + +services: + supavisor: !reset null + + # To expose Postgres directly on POSTGRES_PORT (5432), uncomment the "db" + # section below. + # WARNING: this opens Postgres to the external traffic. Restrict access at the + # network level if necessary. + #db: + # ports: + # - ${POSTGRES_PORT}:${POSTGRES_PORT} + + pgbouncer: + container_name: supabase-pgbouncer + image: edoburu/pgbouncer:v1.25.2-p0 + restart: unless-stopped + ports: + - ${POOLER_PROXY_PORT_TRANSACTION}:6432 + healthcheck: + test: ['CMD', 'pg_isready', '-h', 'localhost', '-p', '6432'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + depends_on: + db: + condition: service_healthy + environment: + # PgBouncer connects to Postgres as the dedicated `pgbouncer` role and + # looks up every other role's password on demand via the auth_query below. + DB_USER: pgbouncer + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_NAME: ${POSTGRES_DB} + LISTEN_PORT: 6432 + AUTH_TYPE: scram-sha-256 + AUTH_QUERY: SELECT * FROM pgbouncer.get_auth($$1) + # POOL_MODE can be: session, transaction, or statement + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + # Only the `pgbouncer` role may run get_auth / the admin console. + ADMIN_USERS: pgbouncer + STATS_USERS: pgbouncer diff --git a/infra/supabase/docker/docker-compose.prod.yml b/infra/supabase/docker/docker-compose.prod.yml new file mode 100644 index 0000000..4aad928 --- /dev/null +++ b/infra/supabase/docker/docker-compose.prod.yml @@ -0,0 +1,34 @@ +# Override di produzione, pensato per girare anche su hardware limitato (es. Raspberry Pi 4). +# +# 1. Nessuna porta esposta pubblicamente: l'API gateway (Kong/envoy, che serve anche Studio) +# resta raggiungibile solo su 127.0.0.1 — mettici davanti un reverse proxy TLS (vedi +# Caddyfile.example). Postgres di suo non espone porte nel compose base (supavisor sì, +# ma non ci serve: l'app si connette al DB sulla rete interna Docker, non da host esterno). +# 2. Servizi mai usati da CrAPP disattivati (nessuna route usa Storage/imgproxy/Realtime/Edge +# Functions/pooler — verificato: zero `.storage.`, `.functions.invoke`, `.channel()` nel +# codice, e gli avatar sono salvati come dataURL nel DB, non su Storage). +# 3. `studio` + `meta` restano attivi: servono per guardare/gestire il database via interfaccia +# grafica (raggiungibile tramite lo stesso api-gw su 127.0.0.1:8000, con le credenziali +# DASHBOARD_USERNAME/DASHBOARD_PASSWORD del tuo .env). +# +# Uso: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: + api-gw: + # Compose unisce le liste `ports` tra file invece di sostituirle: serve !override + # per rimpiazzare davvero il binding pubblico del file base con uno solo-locale. + ports: !override + - "127.0.0.1:${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp" + + # Mai usati da CrAPP: profilo "unused" mai attivato, quindi `docker compose up -d` + # di default non li avvia nemmeno. + realtime: + profiles: ["unused"] + storage: + profiles: ["unused"] + imgproxy: + profiles: ["unused"] + functions: + profiles: ["unused"] + supavisor: + profiles: ["unused"] diff --git a/infra/supabase/docker/docker-compose.rustfs.yml b/infra/supabase/docker/docker-compose.rustfs.yml new file mode 100644 index 0000000..6502656 --- /dev/null +++ b/infra/supabase/docker/docker-compose.rustfs.yml @@ -0,0 +1,47 @@ +services: + + rustfs: + image: rustfs/rustfs:1.0.0-beta.11 + 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/infra/supabase/docker/docker-compose.s3.yml b/infra/supabase/docker/docker-compose.s3.yml new file mode 100644 index 0000000..165463f --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/docker-compose.yml b/infra/supabase/docker/docker-compose.yml new file mode 100644 index 0000000..d058076 --- /dev/null +++ b/infra/supabase/docker/docker-compose.yml @@ -0,0 +1,587 @@ +# 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 +# +# Notes: +# - Nested variable interpolation (${A:-${B}}) requires podman-compose >= 1.6.0 +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.08.03-sha-022b374 + 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://api-gw: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 + + # Envoy is the default API gateway + # See: https://github.com/orgs/supabase/discussions/48048 + api-gw: + container_name: supabase-envoy + image: envoyproxy/envoy:v1.39.0 + restart: unless-stopped + networks: + default: + # Expose `envoy` and `kong` as network aliases, so internal configs + # that reference either hostname resolve to whichever gateway is active. + aliases: + - envoy + - kong + healthcheck: + # Using a TCP port check because this image does not include curl or wget. + test: ["CMD-SHELL", "timeout 1 bash -c '" + + # 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 + healthcheck: + test: [ "CMD", "postgrest", "--ready" ] + interval: 5s + timeout: 5s + retries: 3 + 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 + + PGRST_ADMIN_SERVER_PORT: 3001 + PGRST_ADMIN_SERVER_HOST: localhost + + # 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: ${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: postgres + 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: + api-gw: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "timeout 1 bash -c ' 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/infra/supabase/docker/run.sh b/infra/supabase/docker/run.sh new file mode 100755 index 0000000..3978168 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/setup.sh b/infra/supabase/docker/setup.sh new file mode 100755 index 0000000..605f0fc --- /dev/null +++ b/infra/supabase/docker/setup.sh @@ -0,0 +1,455 @@ +#!/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. Records the base version the deployment was set up from (.supabase-version) +# 7. Prompts for the main URLs and writes them to .env +# 8. 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 +# sh setup.sh --ref self-hosted/v0.7.0 # clone docker/ from a specific git ref +# sh setup.sh --head # clone docker/ from HEAD (skip tags) +# +# curl -fsSL | sh # bootstrap from scratch in CWD +# +# By default the docker/ sources are cloned from the latest self-hosted release +# tag (self-hosted/v*), falling back to the default branch (HEAD) if none exist. +# + +set -e + +PROJECT_DIR="supabase-project" +SKIP_DEPS=0 +WITH_AWS=0 +ASSUME_YES=0 +SOURCE_REF="" +FORCE_HEAD=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 + --ref Clone docker/ from this git ref instead of the + latest self-hosted tag (no HEAD fallback) + --head Clone docker/ from the default branch (HEAD), + skipping self-hosted tag detection + -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 ;; + --ref) SOURCE_REF="$2"; shift 2 ;; + --head) FORCE_HEAD=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 + +# Interactive vs not: -y forces non-interactive; otherwise we're non-interactive +# when there's no controlling terminal to prompt on (e.g. curl | sh in CI). +if [ "$ASSUME_YES" = "1" ] || ! ( : < /dev/tty ) 2>/dev/null; then + NON_INTERACTIVE=1 +else + NON_INTERACTIVE=0 +fi + +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 [ "$NON_INTERACTIVE" = "1" ]; 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" +} + +# True if the value looks like an http(s) URL (scheme check only, not validation). +valid_url() { + case "$1" in + http://?*|https://?*) return 0 ;; + *) return 1 ;; + esac +} + +# Like ask, but requires an http(s):// value. Re-prompts interactively; with no +# usable terminal (-y or curl | sh) a bad value is fatal rather than silently kept. +ask_url() { + # ask_url -> echoes a validated URL + while :; do + _url=$(ask "$1" "$2") + valid_url "$_url" && { printf '%s' "$_url"; return 0; } + if [ "$NON_INTERACTIVE" = "1" ]; then + die "$1 must start with http:// or https:// (got: '$_url')" + fi + printf " '%s' is not a URL - it must start with http:// or https://.\n" "$_url" > /dev/tty + done +} + +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="" +RESOLVED_REF="" +REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}" +STAMP_FILE=".supabase-version" + +# Highest self-hosted/v* tag on the remote, or empty when the remote has none. +# Returns non-zero (printing nothing) when the remote can't be reached. +latest_release_tag() { + _refs=$(git ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || return 1 + printf '%s\n' "$_refs" \ + | sed 's#^.*refs/tags/##' \ + | grep -E '^self-hosted/v[0-9]' \ + | sort -V | tail -n1 +} + +# Clone only docker/ from (empty = default branch) into . +sparse_clone() { + # sparse_clone [ref] + _dest="$1" + _ref="$2" + if [ -n "$_ref" ]; then + git clone --filter=blob:none --no-checkout --depth=1 --quiet \ + --branch "$_ref" "$REPO_URL" "$_dest" 2>/dev/null || return 1 + else + git clone --filter=blob:none --no-checkout --depth=1 --quiet \ + "$REPO_URL" "$_dest" 2>/dev/null || return 1 + fi + ( cd "$_dest" && + git sparse-checkout init --cone && + git sparse-checkout set docker && + git checkout --quiet ) 2>/dev/null || return 1 +} + +# Echo the commit the clone resolved to (for stamping HEAD-based checkouts). +resolved_sha() { + git -C "$1" rev-parse HEAD 2>/dev/null || true +} + +prepare_source() { + SRC_TMP=$(mktemp -d) || die "Could not create a temporary directory" + dest="$SRC_TMP/supabase" + + # Pick the ref to clone; empty means the default branch (HEAD), used only + # when no release tag exists yet (or --head). A resolved tag that fails to + # clone is an error, not a reason to silently install HEAD instead. + if [ -n "$SOURCE_REF" ]; then + ref="$SOURCE_REF" + elif [ "$FORCE_HEAD" = "1" ]; then + ref="" + else + if ref=$(latest_release_tag); then + [ -n "$ref" ] || log "No self-hosted release tag found; using the default branch (HEAD)" + else + die "Could not reach $REPO_URL to look up release tags. Check your network and retry, or pass --head (default branch) or --ref ." + fi + fi + + log "Sparse-cloning supabase repo at ${ref:-HEAD}" + sparse_clone "$dest" "$ref" || die "Could not clone '${ref:-HEAD}' from $REPO_URL" + + # Stamp the tag name for a release tag, else the exact commit SHA. + case "$ref" in + self-hosted/v*) RESOLVED_REF="$ref" ;; + *) RESOLVED_REF=$(resolved_sha "$dest") ;; + esac + + SRC_DIR="$dest/docker" +} + +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- +} + +# Record the base version this deployment was set up from, so update.sh can +# 3-way merge future upgrades against it: it fetches the snapshot at this ref +# as the merge base, and derives the base version from the ref itself. Just the +# ref - per-deployment state, not vendor content: gitignored. +write_version_stamp() { + [ -n "$1" ] || { warn "Could not resolve a base ref; skipping $STAMP_FILE"; return 0; } + { + echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh." + echo "# Do not commit or edit by hand. Records the ref this deployment was based on." + echo "ref=$1" + } > "$STAMP_FILE" + log "Recorded base version in $STAMP_FILE (ref=$1)" +} + +# --- 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_site_url=$(read_env SITE_URL) + +[ -z "$current_public_url" ] && current_public_url="http://localhost:8000" +[ -z "$current_site_url" ] && current_site_url="http://localhost:3000" + +if [ "$NON_INTERACTIVE" = "1" ]; then + log "Non-interactive: using default URLs (edit .env to change)" +else + echo "" + echo "Configure the main URLs (press Enter to accept the default)." + echo "" +fi + +public_url=$(ask_url "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url") +api_url=$(ask_url "API_EXTERNAL_URL (Auth callbacks)" "$public_url/auth/v1") +site_url=$(ask_url "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 + +write_version_stamp "$RESOLVED_REF" + +log "Pulling Docker images" +if [ "$NON_INTERACTIVE" = "1" ]; then + docker compose --progress quiet pull || warn "docker compose pull failed; you can retry later." +else + docker compose pull || warn "docker compose pull failed; you can retry later." +fi + +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 (caddy, nginx, logs, rustfs, s3, kong):" +echo " sh run.sh config add caddy" +echo "" diff --git a/infra/supabase/docker/tests/docker-compose.rustfs.test.yml b/infra/supabase/docker/tests/docker-compose.rustfs.test.yml new file mode 100644 index 0000000..e7a7641 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/tests/docker-compose.s3.test.yml b/infra/supabase/docker/tests/docker-compose.s3.test.yml new file mode 100644 index 0000000..c2e34f4 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/tests/test-auth-keys.sh b/infra/supabase/docker/tests/test-auth-keys.sh new file mode 100644 index 0000000..1f58ae3 --- /dev/null +++ b/infra/supabase/docker/tests/test-auth-keys.sh @@ -0,0 +1,398 @@ +#!/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 -> 403" "403" \ + "$(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 -> 403" "403" \ + "$(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, the API gateway 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 - use /api/ping to verify key auth (expect 200 with a valid key) +check "Legacy ANON_KEY -> 200" "200" \ + "$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY -> 200" "200" \ + "$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/realtime/v1/api/ping")" + +# Management endpoints must be blocked at the gateway (even with a valid key) +check "/api/tenants blocked -> 403" "403" \ + "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" +check "/api/openapi blocked -> 403" "403" \ + "$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")" + +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)" "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" + + check "secret apikey + Authorization: Bearer sb_secret" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_SECRET_KEY" \ + -H "Authorization: Bearer $SUPABASE_SECRET_KEY")" +fi + +check "Legacy apikey + Authorization: Bearer " "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ANON_KEY")" + +check "Service role apikey + Authorization: Bearer " "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_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" "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + check "Session JWT with PostgREST + service role key" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SERVICE_ROLE_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" "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Secret apikey + user JWT -> PostgREST allowed" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_SECRET_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)" "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $hs256_token")" + check "HS256 token with PostgREST + service role key" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SERVICE_ROLE_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/infra/supabase/docker/tests/test-container-logs.sh b/infra/supabase/docker/tests/test-container-logs.sh new file mode 100644 index 0000000..85a64b6 --- /dev/null +++ b/infra/supabase/docker/tests/test-container-logs.sh @@ -0,0 +1,179 @@ +#!/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. +# Logs are written to a temp file so that grep -q exits cleanly. +check_logs() { + service="$1" + shift + + logfile=$(mktemp) + + docker compose logs "$service" > "$logfile" 2>/dev/null || true + if [ ! -s "$logfile" ]; then + container_id=$(get_container_id "$service") + if [ -n "$container_id" ]; then + docker logs "$container_id" > "$logfile" 2>&1 || true + fi + fi + + if [ ! -s "$logfile" ]; then + rm -f "$logfile" + fail_msg "$service (no logs found)" + return + fi + + for pattern in "$@"; do + if ! grep -q -i -E "$pattern" "$logfile"; then + rm -f "$logfile" + fail_msg "$service (missing: $pattern)" + return + fi + done + + rm -f "$logfile" + 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' + +# API gateway: Envoy by default, or Kong when the kong override is enabled. +# The service is named api-gw in both cases, so accept either startup marker. +check_logs api-gw \ + 'init\.lua.*declarative config loaded|Envoy configuration generated successfully' + +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' + +# Database pooler: Supavisor by default, or PgBouncer when the pgbouncer +# override is enabled (which disables Supavisor). Check whichever is running. +if is_service_running supavisor; then + check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' +elif is_service_running pgbouncer; then + check_logs pgbouncer \ + 'process up: PgBouncer' \ + 'listening on .*6432' +else + fail_msg "pooler (neither supavisor nor pgbouncer is running)" +fi + +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/infra/supabase/docker/tests/test-pg17-upgrade.sh b/infra/supabase/docker/tests/test-pg17-upgrade.sh new file mode 100644 index 0000000..16aeb1f --- /dev/null +++ b/infra/supabase/docker/tests/test-pg17-upgrade.sh @@ -0,0 +1,355 @@ +#!/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. +# Postgres 17 is now the default, so start PG 15 explicitly via the override: +# docker compose -f docker-compose.yml -f docker-compose.pg15.yml 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 PG 15 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; + +-- pg_cron: created here as supabase_admin (the common manually-enabled case), +-- so the extension is owned by supabase_admin, NOT postgres. complete.sh's +-- drop+recreate only fires for postgres-owned pg_cron, so this exercises the +-- version reconcile in the upgrade script: Supabase PG 15 registers pg_cron +-- as '1.6', while the target image packages it as '1.6.4' with no update +-- path between them. +CREATE EXTENSION IF NOT EXISTS pg_cron; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN + PERFORM cron.unschedule('upgrade_test_job'); + END IF; +END $$; +SELECT cron.schedule('upgrade_test_job', '5 4 * * *', 'SELECT 1'); +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" + +# --- Seed a Vault secret --------------------------------------------------- +# Verifies the pgsodium root key survives the volume swap/chown AND that Vault +# secrets still decrypt on Postgres 17. For legacy (key_id-based) secrets this +# also exercises complete.sh's pgsodium->Vault re-encryption; on a stock +# self-hosted stack the secret is already pgsodium-less, so this confirms the +# round-trip and the post-upgrade invariant (key_id IS NULL). +VAULT_SECRET_NAME="upgrade_test_secret" +VAULT_SECRET_VALUE="upgrade-test-secret-value-42" +vault_available="f" +if [ "$(run_sql -A -t -c "SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'supabase_vault');" | tr -d '[:space:]')" = "t" ]; then + echo "" + echo "Seeding Vault secret on Postgres 15..." + run_sql <&2 + fi + vault_available="t" +else + echo "" + echo "Skipping Vault seed: supabase_vault extension not available." +fi + +# --- Run upgrade ----------------------------------------------------------- + +echo "" +echo "Running upgrade script..." +echo "" + +bash utils/upgrade-pg17.sh --yes + +echo "" + +# --- Verify with pgTAP ---------------------------------------------------- + +echo "Running pgTAP verification..." +echo "" + +# Optional Vault assertions, only when a secret was seeded above. +vault_plan=0 +vault_tests="" +if [ "$vault_available" = "t" ]; then + vault_plan=3 + vault_tests=$(cat </dev/null) || rest_status="000" + check "PostgREST connectivity" "200" "$rest_status" +fi + +# Auth health (needs apikey header through the API gateway) +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; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN + PERFORM cron.unschedule('upgrade_test_job'); + END IF; +END $$; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'supabase_vault') THEN + DELETE FROM vault.secrets WHERE name = 'upgrade_test_secret'; + END IF; +END $$; +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/infra/supabase/docker/tests/test-s3-backend.sh b/infra/supabase/docker/tests/test-s3-backend.sh new file mode 100644 index 0000000..9c0d619 --- /dev/null +++ b/infra/supabase/docker/tests/test-s3-backend.sh @@ -0,0 +1,371 @@ +#!/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. +# +# Always exits 0: under `set -e` a failing aws call would kill the suite on the +# spot, so the check never records a FAIL and no summary is printed. The aws +# error is echoed to stderr so a FAIL stays explainable even where the caller +# discards stdout. +s3() { + s3_out=$(AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \ + aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1) || + echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2 + printf '%s\n' "$s3_out" +} + +# Wrapper for jq that yields empty output instead of aborting the suite when +# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero. +jq_r() { + jq -r "$@" 2>/dev/null || true +} + +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') +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') + 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 + +# 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') +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') +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') +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') +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') +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') +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') +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') +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" || true) +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') +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/infra/supabase/docker/tests/test-s3.sh b/infra/supabase/docker/tests/test-s3.sh new file mode 100644 index 0000000..36518df --- /dev/null +++ b/infra/supabase/docker/tests/test-s3.sh @@ -0,0 +1,301 @@ +#!/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. +# +# Always exits 0: under `set -e` a failing aws call would kill the suite on the +# spot, so the check never records a FAIL and no summary is printed. The aws +# error is echoed to stderr so a FAIL stays explainable even where the caller +# discards stdout. +s3() { + s3_out=$(AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1) || + echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2 + printf '%s\n' "$s3_out" +} + +# Wrapper for jq that yields empty output instead of aborting the suite when +# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero. +jq_r() { + jq -r "$@" 2>/dev/null || true +} + +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') +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 + +# 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') +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') +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') +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') +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') +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') +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). + +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" || true) +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') +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/infra/supabase/docker/tests/test-self-hosted.sh b/infra/supabase/docker/tests/test-self-hosted.sh new file mode 100644 index 0000000..d842cc9 --- /dev/null +++ b/infra/supabase/docker/tests/test-self-hosted.sh @@ -0,0 +1,543 @@ +#!/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) +# - sha256sum or shasum (for file integrity checks) +# + +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 + +# Portable file hash: prefers sha256sum (Linux), falls back to shasum (macOS) +if command -v sha256sum >/dev/null 2>&1; then + file_hash() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + file_hash() { shasum -a 256 "$1" | awk '{print $1}'; } +else + echo "Error: sha256sum or shasum not found." + 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-) +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_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" +} + +# Is a compose service running? Falls back to a label lookup so it works +# regardless of which override files are loaded in this shell. +service_running() { + svc="$1" + docker compose ps --services --status running 2>/dev/null | grep -qx "$svc" && return 0 + docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME:-supabase}" \ + --filter "label=com.docker.compose.service=$svc" \ + --filter "status=running" --quiet | grep -q '.' +} + +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 route with anon key" "403" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY")" + +echo "" +echo "--- PostgREST ---" +check "REST API route with service role key" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SERVICE_ROLE_KEY")" + +# --------------------------------------------- +# 5. GraphQL +# --------------------------------------------- + +echo "" +echo "--- GraphQL (optional; off by default) ---" +# pg_graphql is OFF by default since the PG17 image (the image drops the extension +# on init, matching platform behavior for new projects), but users may enable it +# (Studio extensions UI / CREATE EXTENSION pg_graphql). Both are valid states. A +# healthy endpoint returns HTTP 200 either way: +# enabled => {"data": ...} +# disabled => {"errors":[{"message":"pg_graphql extension is not enabled."}]} +# Assert the status AND the response shape, so a non-200, non-JSON, or empty body +# (a real gateway/runtime failure) is not silently classified as "disabled". +gql_status=$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}') +gql_body=$(http_body "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}') +if [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.data' >/dev/null 2>&1; then + gql_state="enabled" +elif [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.errors' >/dev/null 2>&1; then + gql_state="disabled" +else + gql_state="unhealthy (HTTP $gql_status)" +fi +case "$gql_state" in + enabled | disabled) gql_health="healthy" ;; + *) gql_health="unhealthy" ;; +esac +check "GraphQL endpoint healthy" "healthy" "$gql_health" +echo " (GraphQL is $gql_state)" + +# --------------------------------------------- +# 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 integrity + download_tmp=$(mktemp); cleanup_files="$cleanup_files $download_tmp" + curl -s "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" -o "$download_tmp" + original_size=$(wc -c < "$tmpfile" | tr -d ' ') + download_size=$(wc -c < "$download_tmp" | tr -d ' ') + check "Download file (size matches)" "$original_size" "$download_size" + original_hash=$(file_hash "$tmpfile") + download_hash=$(file_hash "$download_tmp") + check "Download file (hash matches)" "$original_hash" "$download_hash" + rm -f "$download_tmp" + + 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 the API gateway) + 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 + +# --------------------------------------------- +# 6b. Storage: TUS resumable upload +# --------------------------------------------- + +echo "" +echo "--- Storage: TUS resumable upload ---" + +tus_bucket="smoke-tus-$$" + +tus_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\":\"$tus_bucket\",\"name\":\"$tus_bucket\",\"public\":true}") +check "TUS: create bucket" "200" "$tus_bucket_status" + +if [ "$tus_bucket_status" = "200" ]; then + # Generate a ~7MB file (above Studio's 6MB TUS threshold) + tusfile=$(mktemp); cleanup_files="$cleanup_files $tusfile" + dd if=/dev/urandom of="$tusfile" bs=1048576 count=7 2>/dev/null + tus_file_size=$(wc -c < "$tusfile" | tr -d ' ') + tus_chunk_size=$((4 * 1048576)) # 4MB first chunk + + # Encode TUS metadata values as base64 + tus_bucket_b64=$(printf '%s' "$tus_bucket" | base64) + tus_object_b64=$(printf '%s' "tus-test-file.bin" | base64) + tus_mime_b64=$(printf '%s' "application/octet-stream" | base64) + + # 1. Create resumable upload + tus_create_resp=$(curl -s -i -X POST \ + "$BASE_URL/storage/v1/upload/resumable" \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Tus-Resumable: 1.0.0" \ + -H "Upload-Length: $tus_file_size" \ + -H "Upload-Metadata: bucketName $tus_bucket_b64,objectName $tus_object_b64,contentType $tus_mime_b64" \ + -H "x-upsert: true") + tus_create_status=$(echo "$tus_create_resp" | grep -m1 '^HTTP/' | grep -o '[0-9][0-9][0-9]') + # Supabase Storage always returns an absolute Location URL (see generateUrl in storage/src/http/routes/tus/lifecycle.ts) + tus_location=$(echo "$tus_create_resp" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//') + check "TUS: create resumable upload" "201" "$tus_create_status" + + if [ -n "$tus_location" ]; then + # 2. Upload first chunk (0 to 4MB) + tus_chunk1_status=$(dd if="$tusfile" bs=1048576 count=4 2>/dev/null | \ + curl -s -o /dev/null -w "%{http_code}" -X PATCH \ + "$tus_location" \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Tus-Resumable: 1.0.0" \ + -H "Upload-Offset: 0" \ + -H "Content-Type: application/offset+octet-stream" \ + --data-binary @-) + check "TUS: upload chunk 1 (4MB)" "204" "$tus_chunk1_status" + + # 3. Upload second chunk (4MB to end) + tus_remaining=$((tus_file_size - tus_chunk_size)) + tus_chunk2_status=$(dd if="$tusfile" bs=1048576 skip=4 count=3 2>/dev/null | \ + curl -s -o /dev/null -w "%{http_code}" -X PATCH \ + "$tus_location" \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Tus-Resumable: 1.0.0" \ + -H "Upload-Offset: $tus_chunk_size" \ + -H "Content-Type: application/offset+octet-stream" \ + --data-binary @-) + check "TUS: upload chunk 2 (remaining)" "204" "$tus_chunk2_status" + + # 4. Verify download matches original (hash check proves correct chunk reassembly) + tus_download_tmp=$(mktemp); cleanup_files="$cleanup_files $tus_download_tmp" + curl -s "$BASE_URL/storage/v1/object/public/$tus_bucket/tus-test-file.bin" -o "$tus_download_tmp" + tus_download_size=$(wc -c < "$tus_download_tmp" | tr -d ' ') + check "TUS: download size matches" "$tus_file_size" "$tus_download_size" + tus_original_hash=$(file_hash "$tusfile") + tus_download_hash=$(file_hash "$tus_download_tmp") + check "TUS: download hash matches" "$tus_original_hash" "$tus_download_hash" + rm -f "$tus_download_tmp" + fi + + rm -f "$tusfile" + + # Cleanup: delete file and bucket + http_status "$BASE_URL/storage/v1/object/$tus_bucket/tus-test-file.bin" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 + + tus_delete_bucket=$(http_status "$BASE_URL/storage/v1/bucket/$tus_bucket" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "TUS: delete bucket" "200" "$tus_delete_bucket" +fi + +# --------------------------------------------- +# 7. Edge Functions +# --------------------------------------------- + +echo "" +echo "--- Edge Functions ---" +fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ + -X POST \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{}') +check "Call hello function" '{"message":"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 (ping)" "200" \ + "$(http_status "$BASE_URL/realtime/v1/api/ping" \ + -H "apikey: $ANON_KEY")" + +# Management endpoints must be blocked at the gateway (even with a valid key) +check "Realtime /api/tenants blocked" "403" \ + "$(http_status "$BASE_URL/realtime/v1/api/tenants" \ + -H "apikey: $ANON_KEY")" +check "Realtime /api/openapi blocked" "403" \ + "$(http_status "$BASE_URL/realtime/v1/api/openapi" \ + -H "apikey: $ANON_KEY")" + +# --------------------------------------------- +# 10. Database pooler (transaction mode) +# --------------------------------------------- + +echo "" +echo "--- Database pooler (transaction mode) ---" +if command -v docker >/dev/null 2>&1; then + pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-) + pooler_tenant_id=$(grep '^POOLER_TENANT_ID=' .env | cut -d= -f2-) + # Connect as the postgres role through whichever transaction pooler is running: + # Supavisor (default) -> service 'supavisor', port 6543, user 'postgres.' + # PgBouncer (override) -> service 'pgbouncer', port 6432 (published as 6543), user 'postgres' + # psql runs inside the db container (always has a client) and reaches the + # pooler over the compose network. + if service_running supavisor; then + pooler_user="postgres.$pooler_tenant_id"; pooler_host="supavisor"; pooler_port=6543 + elif service_running pgbouncer; then + pooler_user="postgres"; pooler_host="pgbouncer"; pooler_port=6432 + else + pooler_user="" + fi + if [ -n "$pooler_user" ]; then + pooler_result=$(docker exec -e PGPASSWORD="$pg_password" supabase-db \ + psql "host=$pooler_host port=$pooler_port user=$pooler_user dbname=postgres sslmode=disable" \ + -tAc "select 'pooler_ok';" 2>/dev/null | tr -d '[:space:]') + check "Pooler transaction-mode query (as postgres)" "pooler_ok" "$pooler_result" + else + check "Pooler running (supavisor or pgbouncer)" "true" "false" + fi +else + echo " SKIP: docker not available" +fi + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/infra/supabase/docker/tests/test-update.sh b/infra/supabase/docker/tests/test-update.sh new file mode 100644 index 0000000..c1bc0e7 --- /dev/null +++ b/infra/supabase/docker/tests/test-update.sh @@ -0,0 +1,380 @@ +#!/bin/sh +# +# Hermetic test for update.sh (the self-hosted in-place update script). +# +# Builds a tiny synthetic "upstream" git repo with two tagged releases +# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key), +# where the target ships a breaking-change manifest with entries at the base and +# inside the window. It then simulates a configured deployment based on v0.9.0 +# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting: +# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not +# ones the user commented out), applies clean merges, reports real conflicts, +# honors .gitignore for user-owned paths, surfaces only the in-window gate +# entries (base-version entry excluded), and advances the stamp ONLY on a clean +# apply. Also covers the clean-apply path, default-target resolution, --from, +# --dry-run, the missing-stamp report-only path, and a malformed manifest being +# refused. +# +# Usage: +# sh tests/test-update.sh # run from the docker/ directory +# + +set -eu + +# Isolate from the developer's global/system git config (gpgsign, hooksPath, +# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's +# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so +# the update.sh subprocess inherits them too. +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +DOCKER_DIR=$(dirname "$SCRIPT_DIR") +UPDATE_SH="$DOCKER_DIR/update.sh" + +[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; } + +WORK=$(mktemp -d) +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT INT TERM + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; } +bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; } + +assert_file_contains() { # + if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi +} +assert_file_missing_pattern() { # + if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi +} +assert_line() { # + if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi +} +assert_no_line() { # + if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi +} +assert_path_exists() { # + if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi +} +assert_path_absent() { # + if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi +} + +# portable in-place sed (BSD + GNU): sedi +sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; } + +# --- 1. Build the synthetic upstream repo ----------------------------------- + +SRC="$WORK/upstream" +mkdir -p "$SRC/docker/volumes/api" +cd "$SRC" +git init -q +git config user.email t@t.t +git config user.name t + +cat > docker/docker-compose.yml <<'EOF' +services: + studio: + image: supabase/studio:OLD + db: + image: supabase/postgres:15 +EOF +cat > docker/.env.example <<'EOF' +POSTGRES_PASSWORD=changeme +JWT_SECRET=changeme +KEEP_ME=base-default +EOF +printf 'base kong\n' > docker/volumes/api/kong.yml +printf 'remove me\n' > docker/old-only.txt +cp "$DOCKER_DIR/.gitignore" docker/.gitignore +mkdir -p docker/volumes/functions/main +printf 'base main\n' > docker/volumes/functions/main/index.ts +git add -A && git commit -qm base && git tag self-hosted/v0.9.0 + +# target commit +cat > docker/docker-compose.yml <<'EOF' +services: + studio: + image: supabase/studio:NEW + db: + image: supabase/postgres:17 +EOF +cat > docker/.env.example <<'EOF' +POSTGRES_PASSWORD=changeme +JWT_SECRET=changeme +KEEP_ME=base-default +NEW_KEY=new-default +EOF +printf 'brand new\n' > docker/new-only.txt +printf 'target main\n' > docker/volumes/functions/main/index.ts +mkdir -p docker/volumes/functions/hello +printf 'target hello\n' > docker/volumes/functions/hello/index.ts +# A gitignored sample under volumes/snippets shipped by upstream: must NOT +# overwrite the user's file of the same name (exercises is_excluded directly). +mkdir -p docker/volumes/snippets +printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql +# Manifest with entries at/below and inside the window: +# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]). +# 1.0.0 inside -> surfaces (carries requires+gate). +# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after), +# guarding the set -e regression where a requires-less final entry +# aborted the script. Do NOT add a version key after 1.1.0. +cat > docker/upgrades.json <<'EOF' +{ + "0.9.0": { + "breaking": true + }, + "1.0.0": { + "breaking": true, + "gate": "utils/demo-migrate.sh", + "migration_guide_url": "https://example.test/guide", + "requires": ["Run the demo migration first."] + }, + "1.1.0": { + "breaking": true + } +} +EOF +git rm -q docker/old-only.txt +git add -A +git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql +# Release tag for the target / default-target (latest self-hosted/v*) path. +git commit -qm target && git tag self-hosted/v1.1.0 + +# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must +# refuse (die) rather than silently skip the gate. Named so latest_release_tag +# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0. +printf '["valid JSON, but not an object"]\n' > docker/upgrades.json +git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest + +# --- helper: lay down a deployment based on v0.9.0 -------------------------- +# make_deploy [conflict] - pass "conflict" to pin the studio image so the +# merge produces a real conflict; omit for a clean apply. + +make_deploy() { # [conflict] + d="$1" + _mode="${2:-clean}" + mkdir -p "$d" + git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1 + cp "$UPDATE_SH" "$d/update.sh" + # configured .env: real secret, an extra user key, and KEEP_ME commented out + # on purpose (must NOT be re-added by the .env key-union). + cp "$d/.env.example" "$d/.env" + sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env" + sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env" + printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env" + # user-owned override (must never be touched) + printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml" + # data dirs with sentinels (must never be touched) + mkdir -p "$d/volumes/db/data" "$d/volumes/storage" + printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt" + printf 'OBJ\n' > "$d/volumes/storage/keep.txt" + # user adds a line to kong (upstream unchanged -> clean merge, must survive) + printf 'user added line\n' >> "$d/volumes/api/kong.yml" + # user-owned paths per .gitignore (must not be touched by the merge) + mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn" + printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql" + printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts" + # user file at the SAME path as the vendor snippet shipped at target: + # is_excluded must skip it so the user's content survives. + printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql" + # legacy sample fn in snapshot at target but gitignored - must not overwrite + mkdir -p "$d/volumes/functions/hello" + printf 'user hello\n' > "$d/volumes/functions/hello/index.ts" + # version stamp pointing at the base (ref only; update.sh derives the rest) + printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version" + if [ "$_mode" = "conflict" ]; then + # user pins the studio image (same line upstream changes -> conflict) + sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml" + fi +} + +echo "" +echo "=== update.sh: apply path (with a conflict) ===" + +DEPLOY="$WORK/deploy" +make_deploy "$DEPLOY" conflict +cd "$DEPLOY" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$? + +sed 's/^/ | /' "$WORK/apply.log" + +if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi +assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved" +assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended" +assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept" +assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented" +assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented" +assert_file_contains "docker-compose.override.yml" "my override" "override untouched" +assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched" +assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched" +assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written" +assert_file_contains "docker-compose.yml" "=======" "conflict separator written" +assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written" +assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict" +assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict" +assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)" +assert_path_exists "new-only.txt" "new upstream file added" +assert_path_exists "old-only.txt" "removed-upstream file left in place" +assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict" +assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary" +assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced" +assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)" +assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)" +assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced" +assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced" +assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced" +assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown" +assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report" +if ls backups/*.tgz >/dev/null 2>&1; then + ok "backup archive created" + for _bk in backups/*.tgz; do break; done + tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true + assert_file_contains "$WORK/bk.list" ".env" "backup includes .env" + assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir" + assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir" + assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir" +else + bad "no backup archive" +fi +assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched" +# seed.sql and hello/index.ts are the only files that are BOTH shipped in the +# target snapshot AND gitignored, so they are the real is_excluded coverage. +# Assert the user's content survives AND no vendor content / conflict markers +# leaked in - i.e. the file was skipped, not merged/conflicted. +assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept" +assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content" +assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)" +assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched" +assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated" +assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept" +assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content" +assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)" + +echo "" +echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ===" + +CLEAN="$WORK/clean" +make_deploy "$CLEAN" +cd "$CLEAN" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$? +if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi +assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced" +assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply" +assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)" +assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)" +assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)" + +echo "" +echo "=== update.sh: default target resolves to latest self-hosted/v* tag ===" + +TAGD="$WORK/tagdefault" +make_deploy "$TAGD" +cd "$TAGD" +SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true +assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag" +assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target" + +echo "" +echo "=== update.sh: --from supplies the base when the stamp is missing ===" + +FROMD="$WORK/fromd" +make_deploy "$FROMD" +cd "$FROMD" +rm -f .supabase-version +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$? +assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)" +assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp" + +echo "" +echo "=== update.sh: --dry-run writes nothing ===" + +DRYD="$WORK/dry" +make_deploy "$DRYD" conflict +cd "$DRYD" +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true +assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key" +assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged" +assert_path_absent "backups" "dry-run took no backup" +assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output" + +echo "" +echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ===" + +MISS="$WORK/miss" +make_deploy "$MISS" +cd "$MISS" +rm -f .supabase-version +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$? +if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi +assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced" +assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env" +assert_path_absent "backups" "report-only took no backup" +assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate" +assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release" +assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry" + +echo "" +echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ===" + +BADM="$WORK/badmanifest" +make_deploy "$BADM" +cd "$BADM" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$? +if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi +assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced" +assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched" +assert_path_absent "backups" "malformed manifest: no backup taken" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced" + +echo "" +echo "=== update.sh: never overwrites the running script; stages the target's copy as .dist ===" + +# A dedicated upstream whose docker/ ships an update.sh that DIFFERS from the one +# we run, so the merge must divert it to update.sh.dist rather than rewrite the +# live script mid-run (which would corrupt this process). +SELFSRC="$WORK/selfsrc" +mkdir -p "$SELFSRC/docker" +cd "$SELFSRC" +git init -q +git config user.email t@t.t +git config user.name t +printf 'services:\n db:\n image: x\n' > docker/docker-compose.yml +printf 'KEEP=1\n' > docker/.env.example +cp "$DOCKER_DIR/.gitignore" docker/.gitignore +printf '#!/bin/sh\necho THIS-IS-THE-NEW-UPDATE-SH\n' > docker/update.sh +git add -A && git commit -qm self && git tag self-hosted/v2.0.0 + +SELFDEP="$WORK/selfdep" +mkdir -p "$SELFDEP" +printf 'services:\n db:\n image: x\n' > "$SELFDEP/docker-compose.yml" +printf 'KEEP=1\n' > "$SELFDEP/.env" +cp "$UPDATE_SH" "$SELFDEP/update.sh" # the running script = the real update.sh +printf 'ref=self-hosted/v2.0.0\n' > "$SELFDEP/.supabase-version" +cd "$SELFDEP" +rc=0 +SUPABASE_REPO_URL="$SELFSRC" sh ./update.sh --to self-hosted/v2.0.0 --yes > "$WORK/self.log" 2>&1 || rc=$? +if [ "$rc" = "0" ]; then ok "self-update run exits 0"; else bad "expected exit 0, got $rc"; fi +if cmp -s ./update.sh "$UPDATE_SH"; then ok "running update.sh left byte-identical"; else bad "running update.sh was modified in place"; fi +assert_no_line "./update.sh" '^(<<<<<<<|=======|>>>>>>>)' "no conflict markers written into the running update.sh" +assert_path_exists "$SELFDEP/update.sh.dist" "target's update.sh staged as update.sh.dist" +assert_file_contains "$SELFDEP/update.sh.dist" "THIS-IS-THE-NEW-UPDATE-SH" "update.sh.dist holds the target's version" +assert_file_contains "$WORK/self.log" "update.sh.dist" "summary points the user at update.sh.dist" + +# --- summary ----------------------------------------------------------------- + +echo "" +echo "=== Result: $PASS passed, $FAIL failed ===" +[ "$FAIL" = "0" ] || exit 1 diff --git a/infra/supabase/docker/tests/test-upgrades-manifest.sh b/infra/supabase/docker/tests/test-upgrades-manifest.sh new file mode 100644 index 0000000..4fa6a3e --- /dev/null +++ b/infra/supabase/docker/tests/test-upgrades-manifest.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# +# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq. +# +# - json: upgrades.json is valid JSON +# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the +# optional "_schema" documentation block +# - schema: each version-keyed entry has only known fields, with valid types +# (an unknown/misspelled key like "breakng" would silently disarm +# its gate, so it is rejected here) +# - gate: any non-null "gate" points at a script that exists in the repo +# +# The manifest is the source of truth for gating; the CHANGELOG is display only, +# so this test deliberately does NOT cross-check the two. Requires jq (already a +# runtime dependency of update.sh); no yq, no generation step. +# +# Usage: +# sh tests/test-upgrades-manifest.sh # run from the docker/ directory +# +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +DOCKER_DIR=$(dirname "$SCRIPT_DIR") +cd "$DOCKER_DIR" + +JSON=upgrades.json + +command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; } +[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; } + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT INT TERM + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; } +bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; } + +echo "" +echo "=== upgrades.json is valid JSON ===" +if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then + ok "upgrades.json parses" +else + bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)" + echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1 +fi + +echo "" +echo "=== top-level keys are versions (or _schema) ===" +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in + _schema) ok "doc block '_schema' present" ;; + [0-9]*.[0-9]*) ok "version key $k" ;; + *) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;; + esac +done + +echo "" +echo "=== version-keyed entries have only known fields, with valid types ===" +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + errs=$(jq -r --arg k "$k" '.[$k] as $e + | (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown + | [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end), + (if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end), + (if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end), + (if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end), + (if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end) + ] | join("; ")' "$JSON") + if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi +done + +echo "" +echo "=== gate scripts referenced by entries exist ===" +checked=0 +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON") + [ -n "$gate" ] || continue + checked=$((checked+1)) + if [ -f "$gate" ]; then + ok "gate for $k exists: $gate" + else + bad "gate for $k missing: $gate" + fi +done +[ "$checked" = 0 ] && echo " (no entries reference a gate script)" + +echo "" +echo "=== Result: $PASS passed, $FAIL failed ===" +[ "$FAIL" = 0 ] || exit 1 diff --git a/infra/supabase/docker/update.sh b/infra/supabase/docker/update.sh new file mode 100755 index 0000000..cf3313b --- /dev/null +++ b/infra/supabase/docker/update.sh @@ -0,0 +1,692 @@ +#!/bin/sh +# +# Update an existing self-hosted Supabase deployment in place. +# +# The deployment directory mixes vendor-owned files (docker-compose.yml, the +# override files, volumes/*, scripts, .env.example) with user-owned state +# (.env, docker-compose.override.yml, volumes/db/data, volumes/storage, etc.). +# This script pulls a newer version of the Supabase files on top of yours +# using a 3-way merge against the version you started from, so local edits +# survive and genuine conflicts are surfaced rather than silently overwritten. +# +# The version you started from is recorded in .supabase-version (written by +# setup.sh). If it is missing, pass --from or follow the printed guidance. +# +# What it never touches: .env values you set, docker-compose.override.yml, and +# the data directories (volumes/db/data, volumes/storage, etc.). New keys from +# .env.example are appended to your .env; existing values are kept as-is. +# +# By default it updates to the latest self-hosted/v* release tag (or 'master' +# until the first tag exists). Pass --to to pin a specific tag/branch. +# +# Usage: +# sh update.sh # update to the latest release tag +# sh update.sh --dry-run # show what would change, write nothing +# sh update.sh --to # update to a specific tag/branch +# sh update.sh --from # base to merge from (if no .supabase-version) +# sh update.sh --yes # don't prompt, even on breaking changes +# +# Env: +# SUPABASE_REPO_URL Override the upstream repo (default: github supabase/supabase) +# +# Documentation: https://supabase.com/docs/guides/self-hosting/updating +# + +set -e + +cd "$(dirname "$0")" + +# Pipeline (see main at the bottom): +# resolve refs -> fetch base+target snapshots -> [report-only exit] +# -> build manifest gate -> confirm_gate (before any writes) +# -> backup → merge vendor files + .env keys -> summary → stamp +# +# Three trees for every vendor file path: +# - base - upstream at BASE_REF (.supabase-version ref=, or --from) +# - target - upstream at TARGET_REF (--to, or latest self-hosted/v* tag) +# - user's - the deployment directory (cwd); _not_ a git checkout +# +# Git is only used to fetch snapshots (fetch_snapshot) and to run git merge-file. +# +# Breaking-change gate (upgrades.json on the target snapshot): +# - Keyed by version (e.g. "0.7.0"); window = entries in (BASE_VER, TARGET_VER], +# where the bounds are the base/target refs reduced to bare semver +# (self-hosted/vX.Y.Z -> X.Y.Z) and compared with sort -V. +# - If a bound is not a release tag (a commit SHA, or "master"), that side of +# the window is left open and all applicable entries are shown with a warning. +# - Prompt when an entry has breaking:true or a gate script; runs before +# backup/merge so abort leaves the deployment untouched. +# - CHANGELOG.md is never parsed (update.sh only points users at it); routine +# "requires compose update" items are applied by the merge, not listed here. +# +# User-owned paths skipped during merge are defined in .gitignore (loaded from the +# target snapshot). git check-ignore --no-index applies negation rules (e.g. +# volumes/functions/** ignored except volumes/functions/main/index.ts). +# .git/ is excluded here only - never listed in .gitignore. +# +# .env is never 3-way merged: append missing keys from .env.example only. + +# --- globals (set during main) ----------------------------------------------- + +REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}" +STAMP_FILE=".supabase-version" +SELF_NAME=$(basename "$0") +DRY_RUN=0 +ASSUME_YES=0 +TO_REF="" +FROM_REF="" + +TARGET_REF="" +BASE_REF="" +BASE_VER="" +TARGET_VER="" +REPORT_ONLY=0 + +TMP_ROOT="" +TARGET_DIR="" +BASE_DIR="" +REPORT="" +ENV_ADDED="" +ENV_REMOVED="" +GATE_REPORT="" +GATE_REQUIRED=0 +IGNORE_FILE="" +IGNORE_GIT_DIR="" + +# --- logging ----------------------------------------------------------------- + +log() { printf "===> %s\n" "$*"; } +warn() { printf "WARNING: %s\n" "$*" >&2; } +die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } + +print_help() { + awk 'NR==1 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0" +} + +# --- small helpers ----------------------------------------------------------- + +# load_ignore_file - vendor/user split from the target snapshot's .gitignore. +# Uses a throwaway git dir so check-ignore works on deployment trees that are +# not git repos (and on Apple Git, which rejects --git-dir=/dev/null). +load_ignore_file() { + if [ -f "$TARGET_DIR/.gitignore" ]; then + IGNORE_FILE="$TARGET_DIR/.gitignore" + elif [ -f .gitignore ]; then + IGNORE_FILE="$(pwd)/.gitignore" + else + IGNORE_FILE="" + warn "No .gitignore found; only .git paths are excluded from the merge." + return 0 + fi + case "$IGNORE_FILE" in + /*) ;; + *) IGNORE_FILE="$(cd "$(dirname "$IGNORE_FILE")" && pwd)/$(basename "$IGNORE_FILE")" ;; + esac + IGNORE_GIT_DIR="$TMP_ROOT/ignore-git" + git init -q "$IGNORE_GIT_DIR" +} + +# is_excluded - true when the path is user-owned and must not merge. +is_excluded() { + case "$1" in + .git|.git/*) return 0 ;; + esac + [ -n "$IGNORE_FILE" ] || return 1 + git -C "$IGNORE_GIT_DIR" -c "core.excludesfile=$IGNORE_FILE" \ + check-ignore -q --no-index "$1" 2>/dev/null +} + +read_stamp_ref() { + [ -f "$STAMP_FILE" ] || return 1 + val=$(grep -E '^ref=' "$STAMP_FILE" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d "\r\"' ") + if [ -z "$val" ]; then + val=$(grep -vE '^[[:space:]]*#' "$STAMP_FILE" 2>/dev/null \ + | grep -vE '^[[:space:]]*$' | head -n1 | tr -d "\r\"' ") + fi + [ -n "$val" ] && printf '%s' "$val" +} + +# env_has_key - true if the key appears as KEY=, even commented +# (e.g. "#GOOGLE_ENABLED="), so we never re-add a key the user disabled on purpose. +env_has_key() { + grep -qE "^[[:space:]]*#?[[:space:]]*$1=" "$2" 2>/dev/null +} + +record() { printf '%s:%s\n' "$1" "$2" >> "$REPORT"; } + +count_status() { grep -cE "^$1:" "$REPORT" 2>/dev/null || true; } + +list_status() { + grep -E "^$1:" "$REPORT" 2>/dev/null | cut -d: -f2- | sed 's/^/ /' +} + +# --- upstream snapshots ------------------------------------------------------ + +_sparse_init() { + git -C "$1" init -q + git -C "$1" remote add origin "$REPO_URL" + git -C "$1" config core.sparseCheckout true + git -C "$1" sparse-checkout init --cone >/dev/null 2>&1 + git -C "$1" sparse-checkout set docker >/dev/null 2>&1 +} + +# fetch_snapshot +# Materializes ./docker at into via a shallow fetch. Also the +# seam an artifact source (tarball + sha256) would slot into later. +fetch_snapshot() { + _ref="$1" + _dest="$2" + _work=$(mktemp -d "$TMP_ROOT/fetch.XXXXXX") + if _sparse_init "$_work" \ + && git -C "$_work" fetch --depth=1 --filter=blob:none -q origin "$_ref" 2>/dev/null \ + && git -C "$_work" checkout -q FETCH_HEAD 2>/dev/null \ + && [ -d "$_work/docker" ]; then + mkdir -p "$_dest" + cp -rf "$_work/docker/." "$_dest/" + rm -rf "$_work" + else + rm -rf "$_work" + return 1 + fi +} + +latest_release_tag() { + git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \ + | sed 's#^.*refs/tags/##' \ + | grep -E '^self-hosted/v[0-9]' \ + | sort -V | tail -n1 +} + +list_files() { + ( cd "$1" && find . -type f | sed 's|^\./||' | grep -vE '^\.git(/|$)' | sort ) +} + +# normalize_version - reduce a ref to bare semver (0.7.0) for comparison, +# or empty when it is not a self-hosted release tag (commit SHA, "master", …). +normalize_version() { + _v="${1#refs/tags/}" + _v="${_v#self-hosted/}" + _v="${_v#v}" + case "$_v" in + [0-9]*.[0-9]*) printf '%s' "$_v" ;; + *) ;; + esac +} + +# ver_gt A B - true when version A is strictly greater than B (sort -V order). +ver_gt() { + if [ "$1" = "$2" ]; then return 1; fi + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] +} + +# ver_in_window VER - true when BASE_VER < VER <= TARGET_VER. An empty bound +# leaves that side open (over-reports a gate rather than hiding one). +ver_in_window() { + if [ -n "$TARGET_VER" ] && ver_gt "$1" "$TARGET_VER"; then return 1; fi + if [ -n "$BASE_VER" ] && ! ver_gt "$1" "$BASE_VER"; then return 1; fi + return 0 +} + +# --- ref resolution ---------------------------------------------------------- + +resolve_target_ref() { + if [ -n "$TO_REF" ]; then + TARGET_REF="$TO_REF" + return + fi + TARGET_REF=$(latest_release_tag) + if [ -n "$TARGET_REF" ]; then + log "Latest release tag: $TARGET_REF" + else + TARGET_REF="master" + warn "No self-hosted/v* release tags found; targeting 'master'. Pin a version with --to." + fi +} + +resolve_base_ref() { + REPORT_ONLY=0 + if [ -n "$FROM_REF" ]; then + BASE_REF="$FROM_REF" + elif BASE_REF=$(read_stamp_ref) && [ -n "$BASE_REF" ]; then + : + else + REPORT_ONLY=1 + BASE_REF="" + fi +} + +print_report_only_guidance() { + warn "No $STAMP_FILE found and no --from given; cannot determine the version you started from." + cat >&2 < $STAMP_FILE + 3. Re-run: sh update.sh + +Or supply it inline for this run: sh update.sh --from + +Continuing in REPORT-ONLY mode. NOTE: this is NOT the full set of changes - +without a base version it can only list files and .env keys that are entirely +NEW to you; it CANNOT show which existing files would change or conflict. Record +a base and re-run for the real preview. Nothing will be written. +EOF +} + +fetch_snapshots() { + TARGET_DIR="$TMP_ROOT/target" + log "Fetching target snapshot ($TARGET_REF)" + fetch_snapshot "$TARGET_REF" "$TARGET_DIR" \ + || die "Could not fetch target snapshot '$TARGET_REF' from $REPO_URL" + + if [ "$REPORT_ONLY" = "1" ]; then + return + fi + + BASE_DIR="$TMP_ROOT/base" + log "Fetching base snapshot ($BASE_REF)" + fetch_snapshot "$BASE_REF" "$BASE_DIR" \ + || die "Could not fetch base snapshot '$BASE_REF' from $REPO_URL" +} + +run_report_only() { + log "Files in '$TARGET_REF' you do NOT have yet (brand-new only; existing files that changed are NOT shown here):" + while IFS= read -r f; do + is_excluded "$f" && continue + [ -f "$f" ] || echo " + $f" + done </dev/null 2>&1; then + die "$_manifest is present but is not a valid JSON object; refusing to update without a working breaking-change gate. Please report this to the maintainers." + fi + + if [ -z "$BASE_VER" ] || [ -z "$TARGET_VER" ]; then + warn "Base or target is not a self-hosted/vX.Y.Z tag; cannot compute an exact update window." + warn "Showing all applicable manual-action releases." + warn "Review which ones apply to your deployment." + fi + + for k in $(jq -r 'keys[]' "$_manifest" 2>/dev/null); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + ver_in_window "$k" || continue + + breaking=$(jq -r --arg k "$k" '.[$k].breaking // false' "$_manifest") + gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$_manifest") + url=$(jq -r --arg k "$k" '.[$k].migration_guide_url // empty' "$_manifest") + reqs=$(jq -r --arg k "$k" '.[$k].requires[]? // empty' "$_manifest") + + if [ "$breaking" = "true" ] || [ -n "$gate" ]; then + GATE_REQUIRED=1 + fi + + { + [ "$breaking" = "true" ] && echo "[$k] BREAKING" || echo "[$k]" + [ -n "$gate" ] && echo " gate: '$gate' must be run first (see the steps below for the exact command)" + [ -n "$url" ] && echo " guide: $url" + [ -n "$reqs" ] && printf '%s\n' "$reqs" | sed 's/^/ - /' + } >> "$GATE_REPORT" + done + # Explicit success: the loop's last iteration can end on a false test (e.g. + # an entry with no 'requires'), which would otherwise make this function + # return non-zero and abort the whole script under 'set -e'. + return 0 +} + +confirm_gate() { + [ "$GATE_REQUIRED" = "1" ] || return 0 + [ "$DRY_RUN" != "1" ] || return 0 + [ "$ASSUME_YES" != "1" ] || return 0 + + echo "" >&2 + warn "This update requires manual action - review before continuing:" + sed 's/^/ /' "$GATE_REPORT" >&2 + echo "" >&2 + + if { : > /dev/tty; } 2>/dev/null; then + printf "Have you completed the required steps and want to continue? [y/N]: " > /dev/tty + read -r reply < /dev/tty + case "$reply" in + y|Y|yes|YES) return 0 ;; + *) die "Aborted by user. Nothing was modified." ;; + esac + fi + die "Breaking/gated changes present and no controlling terminal to confirm. Re-run with --yes to proceed." +} + +# --- backup ------------------------------------------------------------------ + +take_backup() { + if [ "$DRY_RUN" = "1" ]; then + log "Dry run: no backup taken, nothing will be written." + return 0 + fi + mkdir -p backups + _backup="backups/pre-update-$(date +%Y%m%d-%H%M%S).tgz" + log "Backing up current configuration to $_backup (excluding data directories)" + tar czf "$_backup" \ + --exclude='./backups' \ + --exclude='./volumes/db/data' \ + --exclude='./volumes/storage' \ + . 2>/dev/null || warn "Backup archive reported errors; review $_backup before relying on it." + warn "This does NOT back up your database. Back it up separately before updating." +} + +# --- vendor file merge ------------------------------------------------------- + +apply_file() { + [ "$DRY_RUN" = "1" ] && return 0 + _dir=$(dirname "$1") + [ "$_dir" = "." ] || mkdir -p "$_dir" + cp -f "$2" "$1" +} + +# Per-file 3-way merge (u=yours, b=base snapshot, t=target snapshot): +# no t → keep u; report removed-upstream +# no u → copy t; report new +# u == t → report unchanged +# u == b → copy t; report updated (user never edited) +# else → git merge-file u b t; report merged-clean or CONFLICT +# If b had no file, an empty file stands in for b. +merge_one_file() { + f="$1" + empty="$2" + merged="$TMP_ROOT/merged.out" + + b="$BASE_DIR/$f" + t="$TARGET_DIR/$f" + u="$f" + + if [ ! -f "$t" ]; then + [ -f "$u" ] && record "removed-upstream" "$f" + return 0 + fi + + if [ ! -f "$u" ]; then + apply_file "$f" "$t" + record "new" "$f" + return 0 + fi + + if cmp -s "$u" "$t"; then + record "unchanged" "$f" + return 0 + fi + + base_for_merge="$b" + [ -f "$base_for_merge" ] || base_for_merge="$empty" + + if cmp -s "$u" "$base_for_merge"; then + apply_file "$f" "$t" + record "updated" "$f" + return 0 + fi + + if git merge-file -p -q \ + -L "yours ($f)" -L "base" -L "new ($TARGET_REF)" \ + "$u" "$base_for_merge" "$t" > "$merged" 2>/dev/null; then + [ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u" + record "merged-clean" "$f" + elif [ -s "$merged" ]; then + # Non-zero exit with output = a normal conflict (markers written). + [ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u" + record "CONFLICT" "$f" + else + # git merge-file errored and produced no output; keep the user's file + # intact rather than truncating it to empty. + record "merge-failed" "$f" + fi +} + +# The running script is a vendor file too, but overwriting it in place would +# corrupt this process (the shell reads $0 as it runs). Never write it directly: +# if the target ships a different version, stage it as .dist to review. +stage_self_update() { + _t="$TARGET_DIR/$SELF_NAME" + if [ ! -f "$_t" ] || cmp -s "$SELF_NAME" "$_t"; then + record "unchanged" "$SELF_NAME" + return 0 + fi + [ "$DRY_RUN" = "1" ] || cp -f "$_t" "$SELF_NAME.dist" + record "self-staged" "$SELF_NAME" +} + +merge_vendor_files() { + _empty="$TMP_ROOT/empty" + : > "$_empty" + : > "$REPORT" + + while IFS= read -r f; do + [ -n "$f" ] || continue + is_excluded "$f" && continue + if [ "$f" = "$SELF_NAME" ]; then + stage_self_update + continue + fi + merge_one_file "$f" "$_empty" + done < "$ENV_ADDED" + : > "$ENV_REMOVED" + [ -f "$_example" ] || return 0 + + while IFS= read -r k; do + env_has_key "$k" .env || echo "$k" >> "$ENV_ADDED" + done <>>>>>> markers):" + list_status CONFLICT + fi + if [ "$(count_status merge-failed)" != "0" ]; then + echo "" + warn "Files git could not merge (left unchanged - update these manually):" + list_status merge-failed + fi + if [ "$(count_status merged-clean)" != "0" ]; then + echo "" + log "Files merged cleanly (review recommended):" + list_status merged-clean + fi + if [ "$(count_status removed-upstream)" != "0" ]; then + echo "" + log "Removed upstream but kept in place (you may no longer need these):" + list_status removed-upstream + fi + if [ "$(count_status self-staged)" != "0" ]; then + echo "" + if [ "$DRY_RUN" = "1" ]; then + log "$SELF_NAME differs from the version in '$TARGET_REF'; a real run would stage that version as $SELF_NAME.dist (the running script is never overwritten in place)." + else + log "$SELF_NAME differs from the version in '$TARGET_REF', staged as $SELF_NAME.dist (the running script was not modified)." + log "Review it, then swap it in if needed: mv $SELF_NAME.dist $SELF_NAME" + fi + fi + if [ -s "$ENV_ADDED" ]; then + echo "" + log ".env keys added (review values):" + sed 's/^/ + /' "$ENV_ADDED" + fi + if [ -s "$ENV_REMOVED" ]; then + echo "" + log ".env keys you have that are gone from the new .env.example (review/remove manually):" + sed 's/^/ - /' "$ENV_REMOVED" + fi + if [ -s "$GATE_REPORT" ]; then + echo "" + log "Required manual steps for this update (from upgrades.json):" + sed 's/^/ /' "$GATE_REPORT" + fi + echo "" + log "For what changed in this update, see CHANGELOG.md (from $BASE_REF to $TARGET_REF)." +} + +write_stamp() { + { + echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh." + echo "# Do not commit or edit by hand. Records the ref this deployment was based on." + echo "ref=$TARGET_REF" + } > "$STAMP_FILE" +} + +print_next_steps() { + echo "" + log "Next steps:" + echo " 1. Review the changes above (compare against the latest backup in backups/ if needed)." + echo " 2. sh run.sh pull" + echo " 3. sh run.sh recreate" +} + +# --- main -------------------------------------------------------------------- + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --to) [ $# -ge 2 ] || die "--to requires a argument."; TO_REF="$2"; shift 2 ;; + --from) [ $# -ge 2 ] || die "--from requires a argument."; FROM_REF="$2"; shift 2 ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; + esac +done + +[ -f docker-compose.yml ] || die "docker-compose.yml not found in $(pwd). Run this from your deployment directory." +[ -f .env ] || die ".env not found in $(pwd). This does not look like a configured deployment." +command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH." +command -v jq >/dev/null 2>&1 || die "jq is required but was not found on PATH." + +resolve_target_ref +resolve_base_ref +TARGET_VER=$(normalize_version "$TARGET_REF") +BASE_VER=$(normalize_version "$BASE_REF") +[ "$REPORT_ONLY" = "1" ] && print_report_only_guidance + +TMP_ROOT=$(mktemp -d) +REPORT="$TMP_ROOT/report" +ENV_ADDED="$TMP_ROOT/env_added" +ENV_REMOVED="$TMP_ROOT/env_removed" +GATE_REPORT="$TMP_ROOT/gate_report" +trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM + +fetch_snapshots +load_ignore_file + +if [ "$REPORT_ONLY" = "1" ]; then + run_report_only + build_gate_report + if [ -s "$GATE_REPORT" ]; then + echo "" + log "Breaking changes / required manual steps in this range (from upgrades.json):" + sed 's/^/ /' "$GATE_REPORT" + warn "Record a base version (see above) and re-run to apply with the gate enforced." + fi + exit 0 +fi + +build_gate_report +confirm_gate + +take_backup +merge_vendor_files +merge_env_file +print_summary + +if [ "$DRY_RUN" = "1" ]; then + echo "" + log "Dry run complete. Re-run without --dry-run to apply." + exit 0 +fi + +if [ "$(count_status CONFLICT)" != "0" ] || [ "$(count_status merge-failed)" != "0" ]; then + echo "" + warn "Update applied WITH CONFLICTS. Resolve the files listed above (remove the" + warn "<<<<<<< ======= >>>>>>> markers, or fix the files git could not merge)" + warn "before starting the stack." + warn "The version stamp was NOT advanced; it will update on your next clean run." + warn "Exiting with status 2." + exit 2 +fi + +write_stamp +print_next_steps +log "Update applied cleanly." diff --git a/infra/supabase/docker/upgrades.json b/infra/supabase/docker/upgrades.json new file mode 100644 index 0000000..2170444 --- /dev/null +++ b/infra/supabase/docker/upgrades.json @@ -0,0 +1,39 @@ +{ + "_schema": { + "description": "Manual-action manifest for self-hosted upgrades, read by update.sh with jq. Hand-edit this file directly; it is the source of truth (no generation step). Keys are self-hosted release versions as bare semver (e.g. \"0.7.0\"), matching the self-hosted/vX.Y.Z tag and the \"## [0.7.0]\" CHANGELOG heading. Only add an entry for a release that needs an action a file diff cannot encode: a data migration, a run-this-first script, or a breaking default. Routine config changes are applied automatically by update.sh's 3-way merge and must NOT be listed here. update.sh gates on entries in (your version, target version], ordered with sort -V.", + "fields": { + "breaking": "bool - requires explicit user confirmation before applying", + "gate": "string|null - script to run before upgrading past this release (e.g. utils/upgrade-pg17.sh)", + "migration_guide_url": "string|null - link to the per-release migration guide", + "requires": "string[] - free-text manual steps shown to the user" + } + }, + "0.6.0": { + "breaking": true, + "gate": "utils/upgrade-pg17.sh", + "migration_guide_url": "https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17", + "requires": [ + "Postgres 17 is now the default. Do NOT start Postgres 17 against an existing Postgres 15 data directory - back up your database first.", + "Run 'sudo bash utils/upgrade-pg17.sh' (needs bash + root) to migrate Postgres 15 -> 17, then recreate containers. To defer, pin Postgres 15 with the docker-compose.pg15.yml override.", + "Includes a security fix for the API gateway (Realtime /api/tenants and /api/openapi routes) - strongly recommended for any instance running Realtime." + ] + }, + "0.7.0": { + "breaking": true, + "gate": null, + "migration_guide_url": "https://github.com/orgs/supabase/discussions/47093", + "requires": [ + "API_EXTERNAL_URL now includes the /auth/v1 path prefix, and SAML SSO endpoints moved to /auth/v1/sso/saml/*. Update custom OAuth provider callback URLs and any SAML configuration accordingly.", + "Anon (publishable) key access to the OpenAPI spec at /rest/v1/ has been removed. Use the service role or secret API key if you relied on it; normal data access via /rest/v1/ is unaffected." + ] + }, + "0.8.0": { + "breaking": true, + "gate": null, + "migration_guide_url": "https://github.com/orgs/supabase/discussions/48048", + "requires": [ + "Envoy is now the default API gateway, replacing Kong. The gateway service is renamed from 'kong' to 'api-gw' (container 'supabase-envoy'); the 'kong' network alias still resolves, so internal service references keep working.", + "If you customized volumes/api/kong.yml or the gateway service, enable the Kong override with 'sh run.sh config add kong' to keep running Kong; otherwise the merge switches you to Envoy." + ] + } +} diff --git a/infra/supabase/docker/utils/add-new-auth-keys.sh b/infra/supabase/docker/utils/add-new-auth-keys.sh new file mode 100644 index 0000000..3073ca7 --- /dev/null +++ b/infra/supabase/docker/utils/add-new-auth-keys.sh @@ -0,0 +1,226 @@ +#!/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 " Functions: SUPABASE_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/#//' \ + -e '/^[ ]*#SUPABASE_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 && \ + grep -q '^[ ]*SUPABASE_JWKS:' docker-compose.yml; then + echo "Done." +else + echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually." +fi diff --git a/infra/supabase/docker/utils/db-passwd.sh b/infra/supabase/docker/utils/db-passwd.sh new file mode 100644 index 0000000..40c9122 --- /dev/null +++ b/infra/supabase/docker/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) +realtime_db_enc_key=$(gen_hex 8) +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 "REALTIME_DB_ENC_KEY=${realtime_db_enc_key}" +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|^REALTIME_DB_ENC_KEY=.*$|REALTIME_DB_ENC_KEY=${realtime_db_enc_key}|" \ + -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/infra/supabase/docker/utils/reassign-owner.sh b/infra/supabase/docker/utils/reassign-owner.sh new file mode 100644 index 0000000..d13b170 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/utils/rotate-new-api-keys.sh b/infra/supabase/docker/utils/rotate-new-api-keys.sh new file mode 100644 index 0000000..48fd9f0 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/utils/upgrade-pg17.sh b/infra/supabase/docker/utils/upgrade-pg17.sh new file mode 100644 index 0000000..c05a7b2 --- /dev/null +++ b/infra/supabase/docker/utils/upgrade-pg17.sh @@ -0,0 +1,822 @@ +#!/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 PG 15 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 -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/ +# 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml 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 PG 15 (the extracted ELF binaries run inside PG15). +# Pinned to .063: later images bumped glibc, which breaks the ELF extraction. +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" + +# Final Postgres 17 image that runs after the upgrade. Pinned here (not read from +# the compose files). Keep in sync with the db image in docker-compose.yml (and +# docker-compose.pg17.yml). Used for pulling, chowning data/db-config to the +# target's postgres UID, and running the post-upgrade migrations. +PG17_TARGET_IMAGE="supabase/postgres:17.6.1.136" + +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 PG 15 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?" + + 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 -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + warn " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml 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 PG 17 binaries, libraries, share data, and upgrade scripts from +# the PG 17 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 PG 17 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 PG 17 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 PG 17 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 PG 17 ownership to start postgres, but if it fails the + # user needs to fall back to PG 15 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 PG 15 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 -f docker-compose.yml -f docker-compose.pg15.yml 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 PG 17. + # 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 PG 17 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. On the platform a tracked migration +# runner (dbmate) applies them; self-hosted has no such tracking, so we apply +# the idempotent, safe-on-an-existing-DB ones directly here. +# +# 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 +# - 20260211120934_supabase_privileged_role.sql (target image's supautils.conf +# references this role; it must exist or supautils config is broken) +# - 20260413000000_fix-authenticator-session-preload-libraries.sql +# - 20260421000001_rescope_pg_graphql_access_trigger.sql +# +# NOT applied: +# - 20260421000000_pg_graphql-off-by-default.sql: drops pg_graphql. Safe on a +# fresh install, but destructive on an existing DB that uses pg_graphql. +# +# This step also reconciles extension versions (complete.sh ran with the .063 +# binaries) and the pg_cron 1.6 -> 1.6.4 version label. See below. + +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 PG 15 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 PG 17 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 + 20260211120934_supabase_privileged_role.sql + 20260413000000_fix-authenticator-session-preload-libraries.sql + 20260421000001_rescope_pg_graphql_access_trigger.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 + + # Reconcile extension versions to the target image. pg_upgrade generates + # update_extensions.sql, but complete.sh runs it inside the .063 container, so + # extensions only get updated to .063's versions. The final image ships newer + # .so files, leaving the catalog version behind what's loaded. + # + # Mirror what update_extensions.sql does, but against the running target image + # and for every installed extension: ALTER EXTENSION ... UPDATE brings each one + # up to the image's default version. This is generic on purpose - it tracks the + # image (matching platform behavior) and stays correct across future image + # bumps without maintaining a hardcoded list. ALTER ... UPDATE is a no-op when + # already at the default. Per-extension exceptions are caught so one extension + # with no update path (e.g. pg_cron 1.6 -> 1.6.4, handled below) does not abort + # the rest. + info "Reconciling extension versions to the target image" + run_sql_on "$DB_CONTAINER" -c " + DO \$\$ + DECLARE r record; + BEGIN + FOR r IN SELECT extname FROM pg_extension LOOP + BEGIN + EXECUTE format('ALTER EXTENSION %I UPDATE', r.extname); + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'skipped extension %: %', r.extname, SQLERRM; + END; + END LOOP; + END + \$\$;" || warn "extension version reconcile had errors (non-fatal)" + + # pg_cron: PG 15 registers the extension as '1.6'; PG 17 images package it as + # '1.6.4' with no '1.6' -> '1.6.4' update path, so ALTER EXTENSION ... UPDATE + # has no path to follow. complete.sh only fixes this when pg_cron is owned by + # 'postgres' (it drops + recreates). Manually-enabled instances are often + # owned by supabase_admin, where that path is skipped. Reconcile the catalog + # label directly: the loaded .so and the SQL objects are already + # 1.6.4-equivalent (no SQL delta between 1.6 and 1.6.4). No DROP, so any + # scheduled jobs are untouched. + run_sql_on "$DB_CONTAINER" -c " + DO \$\$ + DECLARE want text; + BEGIN + SELECT default_version INTO want FROM pg_available_extensions WHERE name = 'pg_cron'; + IF want IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron' AND extversion <> want) THEN + UPDATE pg_extension SET extversion = want WHERE extname = 'pg_cron'; + END IF; + END + \$\$;" || warn "pg_cron version reconcile had errors (non-fatal)" +} + +# --- 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 -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + echo " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml 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/infra/supabase/docker/versions.md b/infra/supabase/docker/versions.md new file mode 100644 index 0000000..36792c9 --- /dev/null +++ b/infra/supabase/docker/versions.md @@ -0,0 +1,145 @@ +# Docker image version updates in docker-compose.yml + +## 2026-08-03 +- supabase/studio:2026.08.03-sha-022b374 (prev supabase/studio:2026.07.07-sha-a6a04f2) +- kong/kong:3.9.3 (prev kong/kong:3.9.1) + +## 2026-07-07 +- supabase/studio:2026.07.07-sha-a6a04f2 (prev supabase/studio:2026.06.03-sha-0bca601) + +## 2026-06-17 +- supabase/postgres:17.6.1.136 (prev supabase/postgres:15.8.1.085) + +## 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/infra/supabase/docker/volumes/api/envoy/cds.yaml b/infra/supabase/docker/volumes/api/envoy/cds.yaml new file mode 100644 index 0000000..2d47842 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/api/envoy/docker-entrypoint.sh b/infra/supabase/docker/volumes/api/envoy/docker-entrypoint.sh new file mode 100755 index 0000000..2c67584 --- /dev/null +++ b/infra/supabase/docker/volumes/api/envoy/docker-entrypoint.sh @@ -0,0 +1,35 @@ +#!/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|\${SUPABASE_PUBLIC_URL}|${SUPABASE_PUBLIC_URL}|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/infra/supabase/docker/volumes/api/envoy/envoy.yaml b/infra/supabase/docker/volumes/api/envoy/envoy.yaml new file mode 100644 index 0000000..def443b --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/api/envoy/lds.template.yaml b/infra/supabase/docker/volumes/api/envoy/lds.template.yaml new file mode 100644 index 0000000..1a3c345 --- /dev/null +++ b/infra/supabase/docker/volumes/api/envoy/lds.template.yaml @@ -0,0 +1,1101 @@ +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: /auth/v1/sso/saml/acs + route: + cluster: auth + prefix_rewrite: /sso/saml/acs + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/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: /auth/v1/sso/saml/metadata + route: + cluster: auth + prefix_rewrite: /sso/saml/metadata + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/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-openapi-protected + match: + path: /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 + envoy.filters.http.rbac: + '@type': type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + admin: + permissions: + - any: true + principals: + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY}' + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}' + + - 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-openapi-blocked + match: + prefix: /realtime/v1/api/openapi + route: + cluster: realtime + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/api/openapi + 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 + + - name: realtime-v1-api-tenants-blocked + match: + prefix: /realtime/v1/api/tenants + route: + cluster: realtime + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/api/tenants + 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 + + - 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 + envoy.filters.http.cors: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy + allow_origin_string_match: + - exact: '${SUPABASE_PUBLIC_URL}' + - safe_regex: + regex: 'https?://(localhost|127\.0\.0\.1)(:[0-9]+)?' + allow_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD" + allow_headers: "*" + expose_headers: "*" + max_age: "3600" + + - 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-openapi-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/infra/supabase/docker/volumes/api/kong-entrypoint.sh b/infra/supabase/docker/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..daf1d5e --- /dev/null +++ b/infra/supabase/docker/volumes/api/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# 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/infra/supabase/docker/volumes/api/kong.yml b/infra/supabase/docker/volumes/api/kong.yml new file mode 100644 index 0000000..84433c1 --- /dev/null +++ b/infra/supabase/docker/volumes/api/kong.yml @@ -0,0 +1,467 @@ +_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: + - /auth/v1/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: + - /auth/v1/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 + + ## OpenAPI root - admin only + - name: rest-v1-openapi + _comment: 'PostgREST OpenAPI root: /rest/v1/ -> (admin only). See ' + url: http://rest:3000/ + routes: + - name: rest-v1-openapi-root + strip_path: true + expression: 'http.path == "/rest/v1/"' + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Secure PostgREST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + 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 + + # Block access to /realtime/v1/api/openapi + - name: realtime-v1-rest-openapi + _comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)' + url: http://realtime-dev.supabase-realtime:4000/api/openapi + protocol: http + routes: + - name: realtime-v1-rest-openapi + strip_path: true + paths: + - /realtime/v1/api/openapi + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + + # Block access to /realtime/v1/api/tenants + - name: realtime-v1-rest-tenants + _comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)' + url: http://realtime-dev.supabase-realtime:4000/api/tenants + protocol: http + routes: + - name: realtime-v1-rest-tenants + strip_path: true + paths: + - /realtime/v1/api/tenants + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*' + url: http://realtime-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/infra/supabase/docker/volumes/db/_supabase.sql b/infra/supabase/docker/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/infra/supabase/docker/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/infra/supabase/docker/volumes/db/init/data.sql b/infra/supabase/docker/volumes/db/init/data.sql new file mode 100755 index 0000000..e69de29 diff --git a/infra/supabase/docker/volumes/db/jwt.sql b/infra/supabase/docker/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/db/logs.sql b/infra/supabase/docker/volumes/db/logs.sql new file mode 100644 index 0000000..255c0f4 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/db/pooler.sql b/infra/supabase/docker/volumes/db/pooler.sql new file mode 100644 index 0000000..162c5b9 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/db/realtime.sql b/infra/supabase/docker/volumes/db/realtime.sql new file mode 100644 index 0000000..4d4b9ff --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/db/roles.sql b/infra/supabase/docker/volumes/db/roles.sql new file mode 100644 index 0000000..8f7161a --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/db/webhooks.sql b/infra/supabase/docker/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/infra/supabase/docker/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/infra/supabase/docker/volumes/functions/deno.jsonc b/infra/supabase/docker/volumes/functions/deno.jsonc new file mode 100644 index 0000000..db206e8 --- /dev/null +++ b/infra/supabase/docker/volumes/functions/deno.jsonc @@ -0,0 +1,6 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + "@supabase/server": "npm:@supabase/server@^1" + } +} diff --git a/infra/supabase/docker/volumes/functions/hello/index.ts b/infra/supabase/docker/volumes/functions/hello/index.ts new file mode 100644 index 0000000..50d0e6b --- /dev/null +++ b/infra/supabase/docker/volumes/functions/hello/index.ts @@ -0,0 +1,37 @@ +// 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. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts" +import { withSupabase } from "@supabase/server" + +// Logs are visible from 'functions' container inspector +console.log("Hello from Functions!"); + +// This endpoint uses 'publishable' | 'secret' access, apiKey is required. +// Use publishable for Client-facing, key-validated endpoints +// Use secret for Server-to-server, internal calls +export default { + fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => { + // Called by another service with a secret key + // ctx.supabaseAdmin bypasses RLS — use for privileged operations + /* + if (ctx.authMode === "secret") { + const { user_id } = await req.json(); + const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id); + + return Response.json({ + email: data?.user?.email, + }); + } + */ + + return Response.json({ message: "Hello from Edge Functions!" }); + }), +}; + +// To invoke: +// curl 'http://localhost:/functions/v1/hello' \ +// --header 'apiKey: ' + diff --git a/infra/supabase/docker/volumes/functions/main/index.ts b/infra/supabase/docker/volumes/functions/main/index.ts new file mode 100644 index 0000000..fee63d4 --- /dev/null +++ b/infra/supabase/docker/volumes/functions/main/index.ts @@ -0,0 +1,177 @@ +import * as jose from 'jsr:@panva/jose@6' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_JWKS = parseJwks(Deno.env.get('SUPABASE_JWKS')) +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// NOTE:(kallebysantos) We don't check for valid keys but just the bare array parsing, +// let this for 'jose' lib verification +export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) + if (parsed?.keys && Array.isArray(parsed.keys)) { + return parsed as jose.JSONWebKeySet + } + return null + } catch { + return null + } +} + +/** + * 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_JWKS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + const localJwks = jose.createLocalJWKSet(SUPABASE_JWKS); + await jose.jwtVerify(jwt, localJwks); + } 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 + // Using a common Import Map for all functions + // to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name' + const importMapPath = `/home/deno/functions/deno.jsonc` + // SUPABASE_FUNCTION_SLUG is listed after the container env snapshot so + // nothing in it can shadow the value, and it is per-request because only this + // worker knows which function the request resolved to. + const envVarsObj = { ...Deno.env.toObject(), SUPABASE_FUNCTION_SLUG: service_name } + 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/infra/supabase/docker/volumes/pooler/pooler.exs b/infra/supabase/docker/volumes/pooler/pooler.exs new file mode 100644 index 0000000..06f05e1 --- /dev/null +++ b/infra/supabase/docker/volumes/pooler/pooler.exs @@ -0,0 +1,30 @@ +{:ok, _} = Application.ensure_all_started(:supavisor) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => System.get_env("POOLER_TENANT_ID"), + "db_host" => System.get_env("POSTGRES_HOST") || "db", + "db_port" => System.get_env("POSTGRES_PORT"), + "db_database" => System.get_env("POSTGRES_DB"), + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => System.get_env("POOLER_MAX_CLIENT_CONN"), + "default_pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"), + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => System.get_env("POSTGRES_PASSWORD"), + "mode_type" => System.get_env("POOLER_POOL_MODE"), + "pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"), + "is_manager" => true + }] +} + +if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + {:ok, _} = Supavisor.Tenants.create_tenant(params) +end diff --git a/infra/supabase/docker/volumes/proxy/caddy/Caddyfile b/infra/supabase/docker/volumes/proxy/caddy/Caddyfile new file mode 100644 index 0000000..0401681 --- /dev/null +++ b/infra/supabase/docker/volumes/proxy/caddy/Caddyfile @@ -0,0 +1,18 @@ +{$PROXY_DOMAIN} { + @supabase_api path /auth/v1/* /rest/v1/* /graphql/v1 /realtime/v1/* /storage/v1/* /functions/v1/* /mcp /sso/* + + handle @supabase_api { + reverse_proxy api-gw:8000 + } + + handle { + basic_auth { + # PROXY_AUTH_PASSWORD is overwritten with the bcrypt on startup + {$PROXY_AUTH_USERNAME} {$PROXY_AUTH_PASSWORD} + } + + reverse_proxy studio:3000 + } + + header -server +} diff --git a/infra/supabase/docker/volumes/proxy/nginx/supabase-nginx.conf.tpl b/infra/supabase/docker/volumes/proxy/nginx/supabase-nginx.conf.tpl new file mode 100644 index 0000000..5a5c4f1 --- /dev/null +++ b/infra/supabase/docker/volumes/proxy/nginx/supabase-nginx.conf.tpl @@ -0,0 +1,95 @@ +upstream api_gw_upstream { + server api-gw:8000; + keepalive 2; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + server_name ${PROXY_DOMAIN}; + server_tokens off; + + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + + ssl_certificate /etc/letsencrypt/live/${PROXY_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${PROXY_DOMAIN}/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/${PROXY_DOMAIN}/chain.pem; + + ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Prevent 502 errors from large Supabase auth cookies + large_client_header_buffers 4 16k; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + location / { + auth_basic "supabase"; + auth_basic_user_file /etc/nginx/user_conf.d/dashboard-passwd; + + proxy_pass http://studio:3000; + } + + location /auth { + proxy_pass http://api_gw_upstream; + } + + location /rest { + proxy_pass http://api_gw_upstream; + } + + location /graphql { + proxy_pass http://api_gw_upstream; + } + + location /realtime/v1/ { + proxy_pass http://api_gw_upstream; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600s; + } + + location /storage/v1/ { + proxy_pass http://api_gw_upstream; + + proxy_buffering off; + proxy_request_buffering off; + + chunked_transfer_encoding off; + + client_max_body_size 0; + } + + location /functions { + proxy_pass http://api_gw_upstream; + } + + location /mcp { + proxy_pass http://api_gw_upstream; + } + + location /sso { + proxy_pass http://api_gw_upstream; + } +} diff --git a/vite.config.ts b/vite.config.ts index 174e074..c2c95a3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,4 +12,7 @@ export default defineConfig({ // nitro/vite builds from this server: { entry: "server" }, }, + // Target plain Node hosting (self-host deploy), overriding the shared config's Cloudflare + // Workers default — see docs/PORTABILITA.md. + nitro: { preset: "node-server" }, });