commit cb7c512c572e24dd49850b76194d3879f1a0839a Author: Elzo codeur Date: Thu Jun 11 21:28:31 2026 +0000 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..417e384 --- /dev/null +++ b/.env.example @@ -0,0 +1,366 @@ +############ +# Docker compose override files to layer on top of docker-compose.yml. +# Native docker compose COMPOSE_FILE: colon-separated list, base file first. +# Manage with: ./run.sh config add|remove +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +############ +COMPOSE_FILE=docker-compose.yml + + +############ +# Secrets +# +# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING +# THE CONTAINERS FOR THE FIRST TIME! +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase +# +# To generate secrets and API keys: +# 1. sh utils/generate-keys.sh +# 2. sh utils/add-new-auth-keys.sh +# +############ + +# Postgres +POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password + +# Legacy symmetric HS256 key +JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +# Legacy API keys (HS256-signed JWTs) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + +# Asymmetric key pair (ES256) and opaque API keys +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys +# +# To generate: +# sh ./utils/add-new-auth-keys.sh +# +# Opaque API key for client-side use (anon role). +SUPABASE_PUBLISHABLE_KEY= +# Opaque API key for server-side use (service_role). Never expose in client code. +SUPABASE_SECRET_KEY= +# JSON array of signing JWKs (EC private + legacy symmetric). +# Used by Auth. +JWT_KEYS= +# JWKS for token verification (EC public + legacy symmetric). +# Used by PostgREST, Realtime, Storage to verify tokens. +JWT_JWKS= + +# Access to Dashboard +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated + +# Used by Realtime and Supavisor +SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + +# Used by Supavisor +VAULT_ENC_KEY=your-32-character-encryption-key + +# Used by Studio to access Postgres via postgres-meta +PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min + +# Analytics - API tokens for log ingestion/querying, and for management +# If Logflare has to be externally exposed - configure securely! +# Used in the docker-compose.logs.yml override. +LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public +LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private + +# Access to Storage via S3 protocol endpoint (see below) +S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c +S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 + + +############ +# URLs - Configure hostnames below to reflect your actual domain name +############ + +# Access to Dashboard and REST API +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Full external URL of the Auth service, used to construct OAuth callbacks, +# SAML endpoints, and email links +API_EXTERNAL_URL=http://localhost:8000 + +# See also the Auth section below for Site URL and Redirect URLs configuration + + +############ +# Database - Postgres configuration +############ + +# Using default user (postgres) +POSTGRES_HOST=db +POSTGRES_DB=postgres + +# Default configuration includes Supavisor exposing POSTGRES_PORT +# Postgres uses POSTGRES_PORT inside the container +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres-through-supavisor +POSTGRES_PORT=5432 + + +############ +# Supavisor - Database pooler +############ + +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres +POOLER_TENANT_ID=your-tenant-id + +# Pool size for internal metadata storage used by Supavisor +# This is separate from client connections and used only by Supavisor itself +POOLER_DB_POOL_SIZE=5 + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +# Add your OpenAI API key to enable AI Assistant +OPENAI_API_KEY=sk-proj-xxxxxxxx + + +############ +# Auth - Configuration for the authentication server +############ + +## General settings + +# Equivalent to "Site URL" and "Redirect URLs" platform configuration options +# Documentation: https://supabase.com/docs/guides/auth/redirect-urls +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= + +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + +## OAuth / Social login providers + +# Uncomment and fill in the providers you want to enable. +# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth +# GOOGLE_ENABLED=false +# GOOGLE_CLIENT_ID= +# GOOGLE_SECRET= + +# GITHUB_ENABLED=false +# GITHUB_CLIENT_ID= +# GITHUB_SECRET= + +# AZURE_ENABLED=false +# AZURE_CLIENT_ID= +# AZURE_SECRET= + +# Phone / SMS provider configuration +# Uncomment to configure SMS delivery for phone auth and phone MFA. +# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa +# SMS_PROVIDER=twilio +# SMS_OTP_EXP=60 +# SMS_OTP_LENGTH=6 +# SMS_MAX_FREQUENCY=60s +# SMS_TEMPLATE=Your code is {{ .Code }} + +# SMS_TWILIO_ACCOUNT_SID= +# SMS_TWILIO_AUTH_TOKEN= +# SMS_TWILIO_MESSAGE_SERVICE_SID= + +# Test OTP: map phone numbers to fixed OTP codes for development +# Format: phone1:code1,phone2:code2 +# SMS_TEST_OTP= + +# Multi-factor authentication (MFA) +# Uncomment to change MFA defaults. +# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml + +# App Authenticator (TOTP) - enabled by default +# MFA_TOTP_ENROLL_ENABLED=true +# MFA_TOTP_VERIFY_ENABLED=true + +# Phone MFA - disabled by default (opt-in) +# MFA_PHONE_ENROLL_ENABLED=false +# MFA_PHONE_VERIFY_ENABLED=false + +# Maximum MFA factors a user can enroll +# MFA_MAX_ENROLLED_FACTORS=10 + +## SAML SSO + +# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso + +# SAML_ENABLED=true +# SAML_PRIVATE_KEY= + +# Optional: accept encrypted SAML assertions from IdPs (default: false) +# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false + +# Optional: how long relay state tokens remain valid (default: 2m0s) +# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s + +# Optional: override the SAML entity ID / ACS base URL +# Defaults to API_EXTERNAL_URL if not set +# SAML_EXTERNAL_URL=https://supabase.example.com:8000 + +# Optional: rate limit on the ACS endpoint (requests per second, default: 15) +# SAML_RATE_LIMIT_ASSERTION=15 + + +############ +# Storage - Configuration for Storage +############ + +# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and +# refer to the documentation at: +# https://supabase.com/docs/guides/self-hosting/self-hosted-s3 +# to learn how to configure the S3 protocol endpoint + +# S3 bucket when using S3 backend, directory name when using 'file' +GLOBAL_S3_BUCKET=stub + +# Used for S3 protocol endpoint configuration +REGION=stub + +# Used by MinIO when added via: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=secret1234 + +# Equivalent to project_ref as described here: +# https://supabase.com/docs/guides/storage/s3/authentication#session-token +STORAGE_TENANT_ID=stub + + +############ +# Functions - Configuration for Edge functions +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-functions + +# NOTE: VERIFY_JWT applies to all functions +FUNCTIONS_VERIFY_JWT=false + + +############ +# API - Configuration for PostgREST +############ + +# Postgres schemas exposed via the REST API +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# Max number of rows returned by a request +PGRST_DB_MAX_ROWS=1000 + +# Extra schemas added to the search_path of every request +PGRST_DB_EXTRA_SEARCH_PATH=public + + +############ +# Logs and Analytics +############ + +## Vector log collection and routing + +# Docker socket location - required for proper Vector operation +DOCKER_SOCKET_LOCATION=/var/run/docker.sock +# For Podman use the following: +# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock + +## Analytics (Logflare) + +# Check the LOGFLARE_* access token configuration _above_. +# If Logflare has to be externally exposed - configure securely! + +# Google Cloud Project details +# Documentation: +# https://supabase.com/docs/reference/self-hosting-analytics/introduction +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER + + +############ +# API gateway +############ + +# Kong configuration variables +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + +# Used internally by the API gateway - DO NOT use in any client or server code. +# Pre-signed ES256 JWT "API key" for anon role. +ANON_KEY_ASYMMETRIC= +# Pre-signed ES256 JWT "API key" for service_role. +SERVICE_ROLE_KEY_ASYMMETRIC= + + +############ +# imgproxy +############ + +# Enable webp support +IMGPROXY_AUTO_WEBP=true + + +############ +# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https + +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + +# Domain name for the proxy (must point to your server) +PROXY_DOMAIN=your-domain.example.com + +# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN). +# This should be a valid email, not a placehoder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/.env.old b/.env.old new file mode 100644 index 0000000..9fbf77f --- /dev/null +++ b/.env.old @@ -0,0 +1,366 @@ +############ +# Docker compose override files to layer on top of docker-compose.yml. +# Native docker compose COMPOSE_FILE: colon-separated list, base file first. +# Manage with: ./run.sh config add|remove +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +############ +COMPOSE_FILE=docker-compose.yml + + +############ +# Secrets +# +# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING +# THE CONTAINERS FOR THE FIRST TIME! +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase +# +# To generate secrets and API keys: +# 1. sh utils/generate-keys.sh +# 2. sh utils/add-new-auth-keys.sh +# +############ + +# Postgres +POSTGRES_PASSWORD=7da49c3b4a62d446430082bd0cb3c238 + +# Legacy symmetric HS256 key +JWT_SECRET=gNAGgi6vYfyMqFMyHT5nbFow1MklGE57/fiap9pA +# Legacy API keys (HS256-signed JWTs) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgwNjY1NDc0LCJleHAiOjE5MzgzNDU0NzR9.IdSVAHLcELmrQj19YU-7Tb_oI0BwQyADfRDu4qdW1sk +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3ODA2NjU0NzQsImV4cCI6MTkzODM0NTQ3NH0.jvJnzgYVbx1aQOhhs3nBL99LdfPTGoa2haDtpIYW-dU + +# Asymmetric key pair (ES256) and opaque API keys +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys +# +# To generate: +# sh ./utils/add-new-auth-keys.sh +# +# Opaque API key for client-side use (anon role). +SUPABASE_PUBLISHABLE_KEY=sb_publishable_PNDlYaG13ZGdG67ZlpKlC3_bc8PpuRg +# Opaque API key for server-side use (service_role). Never expose in client code. +SUPABASE_SECRET_KEY=sb_secret_ePbz8YDnis8hpLJb4z7dMY_oRwPtoUh +# JSON array of signing JWKs (EC private + legacy symmetric). +# Used by Auth. +JWT_KEYS=[{"kty":"EC","kid":"ed13496a-53df-43e8-afb3-2016fe217508","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext":true,"crv":"P-256","x":"2eIZ3MgewDAJwxrmYJRXjOvZsf3VkWKz8THYYfyHlK8","y":"qad03ZZhMadwdlCkNgI8Lyatjh22EKvNVGsCPq84B18","d":"TPZ3pFwn0bmgKSo6T81PCuJm8BDcNOTqUXbKN6NVkQc"},{"kty":"oct","k":"Z05BR2dpNnZZZnlNcUZNeUhUNW5iRm93MU1rbEdFNTcvZmlhcDlwQQ","alg":"HS256"}] +# JWKS for token verification (EC public + legacy symmetric). +# Used by PostgREST, Realtime, Storage to verify tokens. +JWT_JWKS={"keys":[{"kty":"EC","kid":"71dc613b-7ab3-493c-8df2-cd6c66026e70","use":"sig","key_ops":["verify"],"alg":"ES256","ext":true,"crv":"P-256","x":"TOy2AYvt1WuUNSrxy2eWZQmT5yMmDUKNv74Kz9Yt964","y":"sxNABQp1bA12RZjoVSvp4wWO3GTDDF_U54YrfPP3YlY"},{"kty":"oct","k":"QzFiRUpHNElqcE51dlIxODJZUDZFeFZjN1g0Y2dQU2dsOXF5UGlUQg","alg":"HS256"}]} + +# Access to Dashboard +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=909f5980a3542c84c6ae6bcb82fdf8a0 + +# Used by Realtime and Supavisor +SECRET_KEY_BASE=cnrV6e3IdLYYQRMXW7ya+Q5nNAKj6sX23NDcWPQ1X8za/elrvWI/r96iIa/MRZgo + +# Used by Supavisor +VAULT_ENC_KEY=5a7707d9068ca3fc7473eee5ac372e35 + +# Used by Studio to access Postgres via postgres-meta +PG_META_CRYPTO_KEY=Q7yvLaLiy3T9A97LBmnDUJMjfE3HTbei + +# Analytics - API tokens for log ingestion/querying, and for management +# If Logflare has to be externally exposed - configure securely! +# Used in the docker-compose.logs.yml override. +LOGFLARE_PUBLIC_ACCESS_TOKEN=dJ78MZD//eVLtP1y0YAA4p+vjTjEvukX +LOGFLARE_PRIVATE_ACCESS_TOKEN=3PK3vgvRoX5Np9CxBQ1aQgL7OEC+/SS+ + +# Access to Storage via S3 protocol endpoint (see below) +S3_PROTOCOL_ACCESS_KEY_ID=7791a77abdfbd8dbbb4855ec341388e0 +S3_PROTOCOL_ACCESS_KEY_SECRET=46fbaccdd6188dec014200b89e43e5f1c98d95c3be40196defc504190d1d98c0 + + +############ +# URLs - Configure hostnames below to reflect your actual domain name +############ + +# Access to Dashboard and REST API +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Full external URL of the Auth service, used to construct OAuth callbacks, +# SAML endpoints, and email links +API_EXTERNAL_URL=http://localhost:8000 + +# See also the Auth section below for Site URL and Redirect URLs configuration + + +############ +# Database - Postgres configuration +############ + +# Using default user (postgres) +POSTGRES_HOST=db +POSTGRES_DB=postgres + +# Default configuration includes Supavisor exposing POSTGRES_PORT +# Postgres uses POSTGRES_PORT inside the container +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres-through-supavisor +POSTGRES_PORT=5432 + + +############ +# Supavisor - Database pooler +############ + +# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, +# POSTGRES_PORT is used for session mode pooling +# +# Port to use for transaction mode pooling connections +POOLER_PROXY_PORT_TRANSACTION=6543 + +# Maximum number of PostgreSQL connections Supavisor opens per pool +POOLER_DEFAULT_POOL_SIZE=20 + +# Maximum number of client connections Supavisor accepts per pool +POOLER_MAX_CLIENT_CONN=100 + +# Unique Supavisor tenant identifier +# Documentation: +# https://supabase.com/docs/guides/self-hosting/docker#accessing-postgres +POOLER_TENANT_ID=your-tenant-id + +# Pool size for internal metadata storage used by Supavisor +# This is separate from client connections and used only by Supavisor itself +POOLER_DB_POOL_SIZE=5 + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +# Add your OpenAI API key to enable AI Assistant +OPENAI_API_KEY=sk-proj-xxxxxxxx + + +############ +# Auth - Configuration for the authentication server +############ + +## General settings + +# Equivalent to "Site URL" and "Redirect URLs" platform configuration options +# Documentation: https://supabase.com/docs/guides/auth/redirect-urls +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= + +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + +## OAuth / Social login providers + +# Uncomment and fill in the providers you want to enable. +# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth +# GOOGLE_ENABLED=false +# GOOGLE_CLIENT_ID= +# GOOGLE_SECRET= + +# GITHUB_ENABLED=false +# GITHUB_CLIENT_ID= +# GITHUB_SECRET= + +# AZURE_ENABLED=false +# AZURE_CLIENT_ID= +# AZURE_SECRET= + +# Phone / SMS provider configuration +# Uncomment to configure SMS delivery for phone auth and phone MFA. +# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa +# SMS_PROVIDER=twilio +# SMS_OTP_EXP=60 +# SMS_OTP_LENGTH=6 +# SMS_MAX_FREQUENCY=60s +# SMS_TEMPLATE=Your code is {{ .Code }} + +# SMS_TWILIO_ACCOUNT_SID= +# SMS_TWILIO_AUTH_TOKEN= +# SMS_TWILIO_MESSAGE_SERVICE_SID= + +# Test OTP: map phone numbers to fixed OTP codes for development +# Format: phone1:code1,phone2:code2 +# SMS_TEST_OTP= + +# Multi-factor authentication (MFA) +# Uncomment to change MFA defaults. +# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml + +# App Authenticator (TOTP) - enabled by default +# MFA_TOTP_ENROLL_ENABLED=true +# MFA_TOTP_VERIFY_ENABLED=true + +# Phone MFA - disabled by default (opt-in) +# MFA_PHONE_ENROLL_ENABLED=false +# MFA_PHONE_VERIFY_ENABLED=false + +# Maximum MFA factors a user can enroll +# MFA_MAX_ENROLLED_FACTORS=10 + +## SAML SSO + +# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml +# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso + +# SAML_ENABLED=true +# SAML_PRIVATE_KEY= + +# Optional: accept encrypted SAML assertions from IdPs (default: false) +# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false + +# Optional: how long relay state tokens remain valid (default: 2m0s) +# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s + +# Optional: override the SAML entity ID / ACS base URL +# Defaults to API_EXTERNAL_URL if not set +# SAML_EXTERNAL_URL=https://supabase.example.com:8000 + +# Optional: rate limit on the ACS endpoint (requests per second, default: 15) +# SAML_RATE_LIMIT_ASSERTION=15 + + +############ +# Storage - Configuration for Storage +############ + +# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and +# refer to the documentation at: +# https://supabase.com/docs/guides/self-hosting/self-hosted-s3 +# to learn how to configure the S3 protocol endpoint + +# S3 bucket when using S3 backend, directory name when using 'file' +GLOBAL_S3_BUCKET=stub + +# Used for S3 protocol endpoint configuration +REGION=stub + +# Used by MinIO when added via: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=2cc00cf3c9aa04b63d22b8e9549deae6 + +# Equivalent to project_ref as described here: +# https://supabase.com/docs/guides/storage/s3/authentication#session-token +STORAGE_TENANT_ID=stub + + +############ +# Functions - Configuration for Edge functions +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-functions + +# NOTE: VERIFY_JWT applies to all functions +FUNCTIONS_VERIFY_JWT=false + + +############ +# API - Configuration for PostgREST +############ + +# Postgres schemas exposed via the REST API +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# Max number of rows returned by a request +PGRST_DB_MAX_ROWS=1000 + +# Extra schemas added to the search_path of every request +PGRST_DB_EXTRA_SEARCH_PATH=public + + +############ +# Logs and Analytics +############ + +## Vector log collection and routing + +# Docker socket location - required for proper Vector operation +DOCKER_SOCKET_LOCATION=/var/run/docker.sock +# For Podman use the following: +# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock + +## Analytics (Logflare) + +# Check the LOGFLARE_* access token configuration _above_. +# If Logflare has to be externally exposed - configure securely! + +# Google Cloud Project details +# Documentation: +# https://supabase.com/docs/reference/self-hosting-analytics/introduction +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER + + +############ +# API gateway +############ + +# Kong configuration variables +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + +# Used internally by the API gateway - DO NOT use in any client or server code. +# Pre-signed ES256 JWT "API key" for anon role. +ANON_KEY_ASYMMETRIC=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImVkMTM0OTZhLTUzZGYtNDNlOC1hZmIzLTIwMTZmZTIxNzUwOCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgwNjY1NDk0LCJleHAiOjE5MzgzNDU0OTR9.rhh-E42KhEp6F_znI-fT9PrBFOANyk89xuzcdlwrMfQ5tBVnHr-oHt088yLn0G_qzpuG-3p8L3RpCBCjiQC3yw +# Pre-signed ES256 JWT "API key" for service_role. +SERVICE_ROLE_KEY_ASYMMETRIC=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImVkMTM0OTZhLTUzZGYtNDNlOC1hZmIzLTIwMTZmZTIxNzUwOCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3ODA2NjU0OTQsImV4cCI6MTkzODM0NTQ5NH0.qaoG8kfkwXDw1ap0R3k1mqn-NLItjxxrOn9BWiXXwIs40dXTfcW1hEx2NlUWKR_-VJuFg1GpiS3kBrrGs-WqjA + + +############ +# imgproxy +############ + +# Enable webp support +IMGPROXY_AUTO_WEBP=true + + +############ +# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt +############ + +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https + +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + +# Domain name for the proxy (must point to your server) +PROXY_DOMAIN=your-domain.example.com + +# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN). +# This should be a valid email, not a placehoder (otherwise Certbot may fail to start). +CERTBOT_EMAIL=admin@example.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..95aa9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto + +*.md eol=lf + +*.env eol=lf +.env.example eol=lf + +*.sh eol=lf +*.sql eol=lf +*.yml eol=lf +*.yaml eol=lf +*.ts eol=lf +*.exs eol=lf +*.conf eol=lf +*.tpl eol=lf +Caddyfile eol=lf +Dockerfile* eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1e9dc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +volumes/db/data +volumes/storage +.env +test.http +docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0149332 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,538 @@ +# Changelog + +All notable changes to the Supabase self-hosted Docker configuration. + +Changes are grouped by service rather than by change type. See [versions.md](./versions.md) for complete image version history and rollback information. + +See per-service updates below for details. Only the most important changes relevant to [self-hosted Supabase](https://supabase.com/docs/guides/self-hosting) are included here. For the full list of changes, refer to the release notes and changelogs of each individual service. + +**Note:** Configuration updates marked with "requires [...] update" are already included in the latest version of the repository. Pull the latest changes or refer to the linked PR for manual updates. After updating `docker-compose.yml`, pull the latest images and recreate containers - use `docker compose pull && docker compose down && docker compose up -d`. + +--- + +## Unreleased + +⚠️ **Upcoming changes:** Check the main Supabase [changelog](https://github.com/orgs/supabase/discussions/categories/changelog?discussions_q=is%3Aopen+category%3AChangelog+label%3Aself-hosted) for updates: + +- [Upgrading from PG 15 to 17 (breaking change)](https://github.com/orgs/supabase/discussions/46080) +- [Switching Studio from `supabase_admin` to `postgres` (breaking change)](https://github.com/orgs/supabase/discussions/46081) + +--- + +## [2026-06-03] + +⚠️ **Note:** This update includes **important changes**. Please check the details below. + +### Configuration +- ⚠️ Logs and analytics are now [optional](https://github.com/orgs/supabase/discussions/46084) and were removed from the default `docker-compose.yml`. A new `docker-compose.logs.yml` override has been added. Check the main [configuration guide](https://supabase.com/docs/guides/self-hosting/docker#enabling-analytics) and the changes to Studio below for more information - PR [#45327](https://github.com/supabase/supabase/pull/45327) (via [@luizfelmach](https://github.com/luizfelmach/)) +- ⚠️ Added `COMPOSE_FILE` to `.env.example` for configuring compose overrides (also used by `run.sh`) - PR [#45603](https://github.com/supabase/supabase/pull/45603) + +### Documentation +- Added a new [reference list](https://github.com/supabase/supabase/blob/master/docker/CONFIG.md) of all configuration environment variables - PR [#46124](https://github.com/supabase/supabase/pull/46124) +- Updated the main installation and configuration [guide](https://supabase.com/docs/guides/self-hosting/docker) (added "quick start" path and opt-in for logs and analytics; removed the legacy JWT secrets generator) - PR [#46416](https://github.com/supabase/supabase/pull/46416), PR [#45359](https://github.com/supabase/supabase/pull/45359) +- Updated the logs and analytics [how-to guide](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#46452](https://github.com/supabase/supabase/pull/46452) + +### Utils +- Added `setup.sh` and `run.sh` to support quick start and easier management of the compose configuration - PR [#45603](https://github.com/supabase/supabase/pull/45603) +- Updated `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-key.sh` to remove the dependency on OpenSSL and Node.js - PR [#45941](https://github.com/supabase/supabase/pull/45941) +- Updated `tests/test-container-logs.sh` to skip checks for `kong`, `analytics` and `vector` when the services are not running - PR [#46099](https://github.com/supabase/supabase/pull/46099) + +### API gateway +- Updated Envoy version to `1.38.0` (see `docker-compose.envoy.yml`) - PR [#46023](https://github.com/supabase/supabase/pull/46023) +- Updated Envoy configuration to address a discrepancy in API key checking (requires `volumes/api/envoy` update) - PR [#46023](https://github.com/supabase/supabase/pull/46023) + +### Studio +- Updated to `2026.06.03-sha-0bca601` +- ⚠️ Added `ENABLED_FEATURES_LOGS_ALL` to Studio service configuration (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327) +- ⚠️ Added `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` to Studio service configuration (requires `docker-compose.yml` update) - PR [#46173](https://github.com/supabase/supabase/pull/46173) +- ⚠️ Added `start_period` to Studio healthcheck for more reliable cold-boot on slower hosts (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327) +- Fixed incorrect connection strings in the connect sheet for self-hosted environments - PR [#46217](https://github.com/supabase/supabase/pull/46217) +- Updated project home and functions page, and added a minimal project settings implementation - PR [#46544](https://github.com/supabase/supabase/pull/46544), PR [#46550](https://github.com/supabase/supabase/pull/46550), PR [#46554](https://github.com/supabase/supabase/pull/46554) + +### Auth +- Updated to `v2.189.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.189.0) +- ⚠️ Added `GOTRUE_JWT_ISSUER` to Auth service configuration (requires `docker-compose.yml` update) - PR [#46020](https://github.com/supabase/supabase/pull/46020) + +### PostgREST +- Updated to `v14.12` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.12) + +### Realtime +- Updated to `v2.102.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.102.3) + +### Storage +- Updated to `v1.60.4` - [Release](https://github.com/supabase/storage/releases/tag/v1.60.4) + +### Postgres Meta +- Updated to `v0.96.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.6) + +### Edge Runtime +- Updated to `v1.74.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.74.0) + +### Supavisor +- Updated to `2.9.5` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.9.5) +- Added `POSTGRES_HOST` to Supavisor service configuration (requires `docker-compose.yml` and `volumes/pooler/pooler.exs` update) - PR [#41273](https://github.com/supabase/supabase/pull/41273) + +### Analytics (Logflare) +- Updated to `1.43.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.43.1) +- ⚠️ Changed default `docker-compose.yml` to no longer include logs & analytics. Read more in Supabase's [changelog](https://github.com/orgs/supabase/discussions/46084) - PR [#45327](https://github.com/supabase/supabase/pull/45327) + +--- + +## [2026-04-27] + +### Configuration +- ⚠️ Added `docker-compose.envoy.yml` and `volumes/api/envoy`. See also the API gateway updates below - PR [#43838](https://github.com/supabase/supabase/pull/43838) +- ⚠️ Changed Studio healthcheck and some other configuration for better compatibility with Podman (requires `docker-compose.yml` update) - PR [#44754](https://github.com/supabase/supabase/pull/44754) +- ⚠️ Changed Studio configuration to bind to all IPv4 interfaces only (requires `docker-compose.yml` update) - PR [#44772](https://github.com/supabase/supabase/pull/44772) + +### Documentation +- Added a new [how-to](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) describing how to switch from `supabase_admin` to `postgres` role for Studio - PR [#42975](https://github.com/supabase/supabase/pull/42975) (via [@singh-inder](https://github.com/singh-inder/)) +- Added a new [how-to](https://github.com/supabase/supabase/pull/45152) for configuring Envoy as the new API gateway - PR [#45152](https://github.com/supabase/supabase/pull/45152) +- Updated the main [setup guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos to reflect the state of the self-hosted Supabase configuration - PR [#45011](https://github.com/supabase/supabase/pull/45011) + +### Utils +- ⚠️ Added `utils/reassign-owner.sh` to update database objects. Read more in the "[Remove superuser access](https://supabase.com/docs/guides/self-hosting/remove-superuser-access)" how-to guide - PR [#42975](https://github.com/supabase/supabase/pull/42975) +- ⚠️ Changed `utils/add-new-auth-keys.sh` to also update `docker-compose.yml` - PR [#45056](https://github.com/supabase/supabase/pull/45056) + +### API gateway +- ⚠️ Added Envoy as the new optional API gateway (requires `docker-compose.envoy.yml`, `volumes/api/envoy`, and `volumes/logs/vector.yml` update) - PR [#43838](https://github.com/supabase/supabase/pull/43838) (via [@luizfelmach](https://github.com/luizfelmach/)) + +### Studio +- Updated to `2026.04.27-sha-5f60601` +- ⚠️ Added 4 new lints to the Security Advisor. Read more about lint rules 0026 - 0029 in the [Performance and Security Advisors](https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0026_pg_graphql_anon_table_exposed) section of the Supabase documentation - PR [#45253](https://github.com/supabase/supabase/pull/45253), PR [#45260](https://github.com/supabase/supabase/pull/45260) +--- + +## [2026-04-08] + +### Documentation +- Added new how-to guides for configuring [custom email templates](https://supabase.com/docs/guides/self-hosting/custom-email-templates), setting up [SAML SSO](https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso), and [using Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) - PR [#42832](https://github.com/supabase/supabase/pull/42832), PR [#43386](https://github.com/supabase/supabase/pull/43386), PR [#44147](https://github.com/supabase/supabase/pull/44147) + +### Utils +- ⚠️ Added `utils/upgrade-pg17.sh`. Read more in the "[Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17)" how-to guide - PR [#44147](https://github.com/supabase/supabase/pull/44147) + +### API gateway +- ⚠️ Added configuration for SAML SSO (requires `.env`, `docker-compose.yml` and `volumes/api/kong.yml` update) - PR [#43385](https://github.com/supabase/supabase/pull/43385) (via [@luizfelmach](https://github.com/luizfelmach/)) + +### Studio +- Updated to `2026.04.08-sha-205cbe7` + +### PostgREST +- Updated to `v14.8` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.8) + +### Storage +- Updated to `v1.48.26` - [Release](https://github.com/supabase/storage/releases/tag/v1.48.26) + +### imgproxy +- Changed `IMGPROXY_ENABLE_WEBP_DETECTION` environment variable to `IMGPROXY_AUTO_WEBP` (requires `.env` and `docker-compose.yml` update) - PR [#43919](https://github.com/supabase/supabase/pull/43919) + +### Postgres Meta +- Updated to `v0.96.3` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.3) + +### Analytics (Logflare) +- Updated to `1.36.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.36.1) + +### Postgres +- ⚠️ Added `docker-compose.pg17.yml` override - PR [#44147](https://github.com/supabase/supabase/pull/44147) +- ⚠️ Added `utils/upgrade-pg17.sh` - PR [#44147](https://github.com/supabase/supabase/pull/44147) +- ⚠️ Added [documentation](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) explaining the upgrade to Postgres 17 + +--- + +## [2026-03-16] + +⚠️ **Note:** This update includes **important changes**. Please check the details below. The following configuration files have been added/updated: `utils/add-new-auth-keys.sh`, `utils/rotate-new-api-keys.sh`, `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `docker-compose.rustfs.yml`, `volumes/api/kong.yml`, `volumes/api/kong-entrypoint.sh`, `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/functions/main/index.ts`, and `volumes/proxy`. + +### Configuration +- ⚠️ Added scripts and templates to support the new API key format (`sb_` API keys) and the new asymmetric authentication. Check the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) for detailed instructions - PR [#43554](https://github.com/supabase/supabase/pull/43554) +- Added optional proxy configuration for Caddy and nginx. Read the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) to learn more - PR [#43291](https://github.com/supabase/supabase/pull/43291) + +### Documentation +- Added several new how-to guides to the self-hosted Supabase [documentation](https://supabase.com/docs/guides/self-hosting) - PR [#42745](https://github.com/supabase/supabase/pull/42745), PR [#42953](https://github.com/supabase/supabase/pull/42953), PR [#43177](https://github.com/supabase/supabase/pull/43177), PR [#43286](https://github.com/supabase/supabase/pull/43286), PR [#43293](https://github.com/supabase/supabase/pull/43293) + +### Utils and tests +- Added `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-keys.sh` - PR [#43554](https://github.com/supabase/supabase/pull/43554) +- Added `tests/` with 100+ test cases - PR [#43573](https://github.com/supabase/supabase/pull/43573) + +### Studio +- Updated to `2026.03.16-sha-5528817` +- ⚠️ Added the link to the Data API page in Integrations - PR [#43268](https://github.com/supabase/supabase/pull/43268) +- ⚠️ Added `PGRST_DB_SCHEMAS`, `PGRST_DB_EXTRA_SEARCH_PATH`, and `PGRST_DB_MAX_ROWS` to Studio configuration (requires `docker-compose.yml` update) - PR [#43268](https://github.com/supabase/supabase/pull/43268) + +### MCP Server +- Updated to `v0.7.0` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.7.0) + +### API gateway +- ⚠️ Updated Kong to `3.9.1` - PR [#43554](https://github.com/supabase/supabase/pull/43554) + +### PostgREST +- Updated to `v14.6` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.6) + +### Realtime +- ⚠️ Added **mandatory** `METRICS_JWT_SECRET` environment variable (requires `docker-compose.s3.yml` update) - PR [realtime#1729](https://github.com/supabase/realtime/pull/1729) + +### Storage +- Updated to `v1.44.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.44.2) +- ⚠️ Added `STORAGE_PUBLIC_URL` environment variable to simplify proxy configuration (requires `docker-compose.s3.yml` update) - PR [storage#900](https://github.com/supabase/storage/pull/900) +- ⚠️ Added RustFS as an optional S3 backend - PR [#42935](https://github.com/supabase/supabase/pull/42935) +- ⚠️ Changed Docker Compose configuration for S3 backends to use named volumes - PR [#43815](https://github.com/supabase/supabase/pull/43815) + +### Edge Runtime +- Updated to `v1.71.2` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.71.2) +- ⚠️ Added `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_PUBLIC_URL` environment variables (requires `docker-compose.yml` update) +- ⚠️ Added an option for a "hybrid" JWT verification following the addition of the new API keys and the new asymmetric authentication (requires `volumes/functions/main/index.ts` update) - PR [#42130](https://github.com/supabase/supabase/pull/42130) +- ⚠️ Added optional rate limiter - PR [edge-runtime#670](https://github.com/supabase/edge-runtime/pull/670) + +--- + +## [2026-02-18] + +### Storage +- Changed MinIO image to use Chainguard [minio](https://images.chainguard.dev/directory/image/minio/overview) and [minio-client](https://images.chainguard.dev/directory/image/minio-client/overview) (requires `docker-compose.s3.yml` update) - PR [#42942](https://github.com/supabase/supabase/pull/42942) +- Updated Storage image version to `v1.37.8` in `docker-compose.s3.yml` +- Removed `imgproxy` service from `docker-compose.s3.yml` to minimize redundancy - PR [#42942](https://github.com/supabase/supabase/pull/42942) +- Fixed inconsistent `storage` service entry ordering in `docker-compose.yml` and `docker-compose.s3.yml` to improve diff readability - PR [#42942](https://github.com/supabase/supabase/pull/42942) + +### Edge Runtime +- Added a `deno-cache` named volume to avoid re-downloading dependencies (requires `docker-compose.yml` and `volumes/functions/*` update) - PR [#40822](https://github.com/supabase/supabase/pull/40822) + +--- + +## [2026-02-16] + +⚠️ **Note:** This update includes several breaking changes, including a security fix for Analytics. Please check the details below. The following configuration files have been updated: `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `volumes/api/kong.yml`, and `volumes/logs/vector.yml`. + +### Studio +- Updated to `2026.02.16-sha-26c615c` +- Added Edge Functions management UI (requires `docker-compose.yml` update) - PR [#40690](https://github.com/supabase/supabase/pull/40690), PR [#42322](https://github.com/supabase/supabase/pull/42322), PR [#42349](https://github.com/supabase/supabase/pull/42349), PR [#42350](https://github.com/supabase/supabase/pull/42350) + +### MCP Server +- Updated to `v0.6.3` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.6.3) + +### Auth +- Updated to `v2.186.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.186.0) + +### PostgREST +- Updated to `v14.5` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.5) + +### Realtime +- Updated to `v2.76.5` - [Release](https://github.com/supabase/realtime/releases/tag/v2.76.5) + +### Storage +- Updated to `v1.37.8` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.8) +- ⚠️ Changed environment variable configuration for Storage (requires `docker-compose.yml`, `.env.example` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185), PR [#42862](https://github.com/supabase/supabase/pull/42862) +- ⚠️ Added **default** configuration to access buckets via `/storage/v1/s3` endpoint (requires `docker-compose.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185) +- ⚠️ Changed MinIO configuration for the S3 backend (requires `docker-compose.s3.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185) + +### Edge Runtime +- Updated to `v1.70.3` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.3) + +### Analytics (Logflare) +- Updated to `1.31.2` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.31.2) +- ⚠️ Changed default configuration to disable Logflare on `0.0.0.0:4000` to prevent access to `/dashboard` (requires `docker-compose.yml` update). Read more in the "Production Recommendations" section of Logflare [documentation](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#42857](https://github.com/supabase/supabase/pull/42857) +- ⚠️ Changed Kong routes to not include `/analytics/v1` by default (requires `/volumes/api/kong.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) + +### Vector +- Updated to `0.53.0-alpine` - [Changelog](https://vector.dev/releases/0.53.0/) | [Release](https://github.com/vectordotdev/vector/releases/tag/v0.53.0) +- ⚠️ Major version jump from `0.28.1` (requires `volumes/logs/vector.yml` update) - PR [#42525](https://github.com/supabase/supabase/pull/42525) +- ⚠️ Changed Postgres sink configuration to bypass Kong (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) +- ⚠️ Changed retry settings for all sinks to increase timeouts (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857) + +--- + +## [2026-02-05] + +### Storage +- Updated to `v1.37.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.1) +- Fixed an issue with Storage not starting because of an issue with migrations - PR [storage#845](https://github.com/supabase/storage/pull/845) + +--- + +## [2026-01-27] + +### Studio +- Updated to `2026.01.27-sha-6aa59ff` +- Added SQL snippets (requires `docker-compose.yml` update) - PR [#41112](https://github.com/supabase/supabase/pull/41112), PR [#41557](https://github.com/supabase/supabase/pull/41557), discussion [#42031](https://github.com/orgs/supabase/discussions/42031) +- Fixed type generator - PR [#40481](https://github.com/supabase/supabase/pull/40481) +- Fixed minor UI discrepancies - PR [#40579](https://github.com/supabase/supabase/pull/40579), PR [#41936](https://github.com/supabase/supabase/pull/41936), PR [#41970](https://github.com/supabase/supabase/pull/41970), PR [#41971](https://github.com/supabase/supabase/pull/41971), PR [#41972](https://github.com/supabase/supabase/pull/41972), PR [#42015](https://github.com/supabase/supabase/pull/42015) + +### Auth +- Updated to `v2.185.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.185.0) +- ⚠️ Fixed security-related issues + +### PostgREST +- Updated to `v14.3` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.3) + +### Realtime +- Updated to `v2.72.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.72.0) +- Changed healthchecks logging to off by default (requires `docker-compose.yml` update) - PR [realtime#1677](https://github.com/supabase/realtime/pull/1677), PR [#42156](https://github.com/supabase/supabase/pull/42156) +- Changed logging configuration and healthcheck frequency to reduce log volume (requires `docker-compose.yml` update) - PR [#42112](https://github.com/supabase/supabase/pull/42112) + +### Storage +- Updated to `v1.33.5` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.5) + +### imgproxy +- Updated to `v3.30.1` - [Changelog](https://github.com/imgproxy/imgproxy/blob/master/CHANGELOG.md) | [Release](https://github.com/imgproxy/imgproxy/releases/tag/v3.30.1) + +### Postgres Meta +- Updated to `v0.95.2` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.2) + +### Edge Runtime +- Updated to `v1.70.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.0) + +### Analytics (Logflare) +- Updated to `1.30.3` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.30.3) + +### Postgres +- No image update +- Fixed Postgres logging configuration (requires `volumes/logs/vector.yml` update) - PR [#41800](https://github.com/supabase/supabase/pull/41800) + +--- + +## [2025-12-18] + +### Documentation +- Updated self-hosting installation and configuration guide - PR [#40901](https://github.com/supabase/supabase/pull/40901), PR [#41438](https://github.com/supabase/supabase/pull/41438) + +### Utils +- Added `utils/generate-keys.sh` - PR [#41363](https://github.com/supabase/supabase/pull/41363) +- Added `utils/db-passwd.sh` - PR [#41432](https://github.com/supabase/supabase/pull/41432) +- Changed `reset.sh` to POSIX and added more checks - PR [#41361](https://github.com/supabase/supabase/pull/41361) + +### Studio +- Updated to `2025.12.17-sha-43f4f7f` +- ⚠️ Fixed additional issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell) +- Fixed an issue with the Users page not being updated on changes - PR [#41254](https://github.com/supabase/supabase/pull/41254) + +### MCP Server +- Updated to `v0.5.10` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.5.10) + +### Auth +- Updated to `v2.184.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.184.0) + +### Postgres Meta +- Updated to `v0.95.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.1) + +### Analytics (Logflare) +- Updated to `1.27.0` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.27.0) +- Fixed multiple issues, including a race condition + +--- + +## [2025-12-10] + +### Studio +- Updated to `2025.12.09-sha-434634f` +- ⚠️ Fixed security issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell) + +### MCP Server +- Updated to `v0.5.9` - [Release](https://github.com/supabase-community/supabase-mcp/releases/tag/v0.5.9) +- ⚠️ Changed MCP tool `get_anon_key` to `get_publishable_keys` + +### PostgREST +- Updated to `v14.1` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.1) +- ⚠️ **Major upgrade from v13.x to v14.x** - please report any unexpected behavior + +### Realtime +- Updated to `v2.68.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.68.0) + +### Storage +- Updated to `v1.33.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.0) + +### Edge Runtime +- Updated to `v1.69.28` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.28) + +### Analytics (Logflare) +- Updated to `1.26.25` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.25) + +--- + +## [2025-12-08] + +### Realtime +- No image update +- Changed boolean values to strings in Docker Compose for better compatibility with Podman - PR [#40994](https://github.com/supabase/supabase/pull/40994), also PR [realtime#1614](https://github.com/supabase/realtime/pull/1614) +- Changed healthcheck in Docker Compose for better compatibility with Podman - PR [#41159](https://github.com/supabase/supabase/pull/41159) + +--- + +## [2025-11-26] + +### Studio +- Updated to `2025.11.26-sha-8f096b5` +- Fixed MCP `get_advisors` tool - PR [#40783](https://github.com/supabase/supabase/pull/40783) +- Fixed AI Assistant request schema - PR [#40830](https://github.com/supabase/supabase/pull/40830) +- Fixed log drains page - PR [#40835](https://github.com/supabase/supabase/pull/40835) + +### Realtime +- Updated to `v2.65.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.3) + +### Analytics (Logflare) +- Updated to `1.26.13` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.13) +- Fixed crashdump when `POSTGRES_BACKEND_URL` is malformed - PR [logflare#2954](https://github.com/Logflare/logflare/pull/2954) + +--- + +## [2025-11-25] + +### Studio +- Updated to `2025.11.24-sha-d990ae8` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40734) +- Fixed Queues configuration UI and added [documentation for exposed queue schema](https://supabase.com/docs/guides/queues/expose-self-hosted-queues) - PR [#40078](https://github.com/supabase/supabase/pull/40078) +- Fixed parameterized SQL queries in MCP tools - PR [#40499](https://github.com/supabase/supabase/pull/40499) +- Fixed Studio showing paid options for log drains - PR [#40510](https://github.com/supabase/supabase/pull/40510) +- Fixed AI Assistant authentication - PR [#40654](https://github.com/supabase/supabase/pull/40654) + +### Auth +- Updated to `v2.183.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.183.0) + +### Realtime +- Updated to `v2.65.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.2) +- Fixed handling of boolean configuration options - PR [realtime#1614](https://github.com/supabase/realtime/pull/1614) + +### Storage +- Updated to `v1.32.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.32.0) + +### Edge Runtime +- Updated to `v1.69.25` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.25) + +### Analytics (Logflare) +- Updated to `1.26.12` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.12) +- Fixed Auth logs query - PR [logflare#2936](https://github.com/Logflare/logflare/pull/2936) +- Fixed build configuration to prevent crashes with "Illegal instruction (core dumped)" - PR [logflare#2942](https://github.com/Logflare/logflare/pull/2942) + +--- + +## [2025-11-17] + +### Storage +- No image update +- Fixed resumable uploads for files larger than 6MB (requires `docker-compose.yml` update) - PR [#40500](https://github.com/supabase/supabase/pull/40500) + +--- + +## [2025-11-12] + +### Studio +- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083) +- Added log drains - PR [#28297](https://github.com/supabase/supabase/pull/28297) +- Fixed Studio using `postgres` role instead of `supabase_admin` - PR [#39946](https://github.com/supabase/supabase/pull/39946) + +### Auth +- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1) + +### Realtime +- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0) + +### Storage +- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0) + +### Edge Runtime +- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23) + +### Supavisor +- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4) + +--- + +## [2025-11-05] + +### Studio +- No image update +- Fixed Studio failing to connect to Postgres with non-default settings (requires `docker-compose.yml` update) - PR [#40169](https://github.com/supabase/supabase/pull/40169) + +### Realtime +- No image update +- Fixed realtime logs not showing in Studio (requires `volumes/logs/vector.yml` update) - PR [#39963](https://github.com/supabase/supabase/pull/39963) + +--- + +## [2025-10-28] + +### Studio +- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083) +- Fixed broken authentication when uploading files to Storage - PR [#39829](https://github.com/supabase/supabase/pull/39829) + +### Realtime +- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2) + +### Storage +- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2) + +### Postgres Meta +- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1) + +### Edge Runtime +- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15) + +--- + +## [2025-10-27] + +### Studio +- No image update +- Added Kong configuration for MCP server routes (requires `volumes/api/kong.yml` update) - PR [#39849](https://github.com/supabase/supabase/pull/39849) +- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) for MCP server configuration - PR [#39952](https://github.com/supabase/supabase/pull/39952) + +--- + +## [2025-10-21] + +### Studio +- Updated to `2025.10.20-sha-5005fc6` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709) +- Fixed issues with Edge Functions and cron logs not being visible in Studio - PR [#39388](https://github.com/supabase/supabase/pull/39388), PR [#39704](https://github.com/supabase/supabase/pull/39704), PR [#39711](https://github.com/supabase/supabase/pull/39711) + +### Realtime +- Updated to `v2.56.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.56.0) + +### Storage +- Updated to `v1.28.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.1) + +### Postgres Meta +- Updated to `v0.93.0` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.0) + +### Edge Runtime +- Updated to `v1.69.14` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.14) + +### Supavisor +- Updated to `2.7.3` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.3) + +--- + +## [2025-10-13] + +### Analytics (Logflare) +- Updated to `1.22.6` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.6) + +--- + +## [2025-10-08] + +### Studio +- Updated to `2025.10.01-sha-8460121` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709) +- Added "local" remote MCP server - PR [#38797](https://github.com/supabase/supabase/pull/38797), PR [#39041](https://github.com/supabase/supabase/pull/39041) +- ⚠️ Changed Studio connection method to `postgres-meta` - affects non-standard database port configurations + +### Auth +- Updated to `v2.180.0` - [Release](https://github.com/supabase/auth/releases/tag/v2.180.0) + +### PostgREST +- Updated to `v13.0.7` - [Release](https://github.com/PostgREST/postgrest/releases/tag/v13.0.7) | [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) + +### Realtime +- Updated to `v2.51.11` - [Release](https://github.com/supabase/realtime/releases/tag/v2.51.11) + +### Storage +- Updated to `v1.28.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.0) + +### Postgres Meta +- Updated to `v0.91.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.91.6) + +### Analytics (Logflare) +- Updated to `1.22.4` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.4) + +### Postgres +- Updated to `15.8.1.085` - [Release](https://github.com/supabase/postgres/releases/tag/15.8.1.085) + +### Supavisor +- Updated to `2.7.0` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.0) + +--- diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..d139fc9 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,1454 @@ +Last updated: 2026-05-23 + +# Self-hosted Supabase configuration reference + +This document is the aggregated reference for environment variables relevant to a self-hosted Supabase deployment. It aims to be comprehensive for the self-hosted use case rather than literally exhaustive - variables that only apply on the hosted platform are typically omitted or marked as such. For the complete set a given service can read, refer to its [upstream repositories](#upstream-repositories) below. + +The default self-hosted setup already includes explicit values for all required variables, and the remaining configuration inherits sensible defaults from the services themselves. Otherwise, it serves as a reference for advanced customization or educational purposes. For more guidance on the essential keys and secrets, see [Configuring secrets](https://supabase.com/docs/guides/self-hosting/docker#configuring-secrets) in the self-hosting guide. + +> **A note on accuracy.** This reference is compiled from each service's source code and upstream docs as a self-hosting overview - it is **not** maintained by the individual product teams and shouldn't be treated as canonical. Within each row: +> +> - **Type, defaults, formats, and where the variable is read** are derived directly from code (parse sites in Go / TypeScript / Elixir / Rust) and are usually reliable. The `Type` cell uses a closed vocabulary with embedded unit hints (e.g. `integer (seconds)`, `integer (ms)`, `string (duration)`) to surface unit conventions that vary across services. +> - The **prose description of what the variable is *for*** is more interpretive. It captures the immediate mechanical effect well, but the underlying *intent* - why the variable exists, when you should change it, how it interacts with other variables - is partly synthesized by an LLM and can be subtly off. +> +> When the *what* matters operationally, trust the row. When the *why* matters, defer to the upstream service's own documentation or source. + +## Verification status + +The Type column was derived from each service's parse-site code (Go struct fields, TypeScript conversions, Elixir parse calls, Rust `clap` declarations). The Description column is best-effort and was cross-checked against upstream prose where a fresh, trusted source exists: + +- **Auth** - against [supabase/auth/README.md](https://github.com/supabase/auth/blob/master/README.md) +- **PostgREST** - against [postgrest.org/en/stable](https://docs.postgrest.org/en/stable/references/configuration.html) +- **Realtime** - against [supabase/realtime/ENVS.md](https://github.com/supabase/realtime/blob/main/ENVS.md) +- **Storage** - against the Supabase docs [YAML spec](https://github.com/supabase/supabase/blob/master/apps/docs/spec/storage_v0_config.yaml) for the variables it covers; otherwise from code reads +- **Analytics (Logflare)** - against the Supabase docs [YAML spec](https://github.com/supabase/supabase/blob/master/apps/docs/spec/analytics_v0_config.yaml) and [docs.logflare.app/self-hosting](https://docs.logflare.app/self-hosting/) for the rest, with [logflare/config/runtime.exs](https://github.com/Logflare/logflare/blob/main/config/runtime.exs) and [logflare/config/config.exs](https://github.com/Logflare/logflare/blob/main/config/config.exs) used as tiebreakers +- **Supavisor** - against [supabase/supavisor/docs/configuration/env.md](https://github.com/supabase/supavisor/blob/main/docs/configuration/env.md) + +Other sections (Studio, Edge Functions, Postgres) appeared to have no comparable upstream prose documentation and were documented by reading the source repos. Corrections welcome via PR. + +## How to read this document + +Each table has five columns: + +| Column | Meaning | +|---|---| +| **Variable** | Exact env var name as the service's code reads it. Names are case-sensitive. | +| **Type** | Closed vocabulary: `string`, `integer`, `number`, `boolean`, `JSON`, `enum`, `URL`, `path`, `JWT`, `JWKS`. Numeric forms carry a unit hint where one applies - e.g. `integer (seconds)`, `integer (ms)`, `integer (bytes)`, `integer (MB)`, `integer (count)`, `number (ratio)`. String forms with a semantic hint: `string (duration)` (Go `time.Duration` strings like `10s`, `5m`, distinct from `integer (seconds)`), `string (regex)`, `string (CSV)`. | +| **Set by (CLI, Self-hosted)** | `Both` if the variable is set inside the corresponding container when you run `supabase start` (see the [Local development & CLI](https://supabase.com/docs/guides/local-development)) *and* in `docker/docker-compose.yml` / `docker/.env.example`. `Self-hosted` if only in the self-hosted compose/.env (including inside commented-out lines). `CLI` if only in the CLI runtime env. Blank if neither - the variable is documented because the service's code reads it, but no Supabase-side config pre-wires it. | +| **Description** | What the variable controls. | +| **Notes** | Default value, requirement, deprecation, alias, or scope. | + +A few caveats: + +- **"Set by = blank" does not mean "unusable in self-hosted".** It only means the default `docker-compose.yml` does not pass the variable through. You can almost always try to add it under the service's `environment:` block. +- **Defaults shown are from the upstream service code.** Some defaults are overridden by `docker-compose.yml`; where that happens it is called out in Notes. +- **The CLI does not run Supavisor** as part of `supabase start`, so every Supavisor variable's `Set by` is either `Self-hosted` or blank - never `Both` or `CLI`. +- **Auth/gotrue env vars are programmatically derived** from a Go config struct (envconfig), so most fields are reachable via two names: a prefixed form (`GOTRUE_API_API_EXTERNAL_URL`) and a bare-name alias (`API_EXTERNAL_URL`). Both are documented. + +## Upstream repositories + +The image tags below are pinned in `docker-compose.yml` at the time of this document; check that file for the current versions. + +| Service | Image | Source repo | +|---|---|---| +| Studio (Dashboard) | `supabase/studio` | [supabase/supabase/apps/studio](https://github.com/supabase/supabase/tree/master/apps/studio) | +| Auth | `supabase/gotrue` | [supabase/auth](https://github.com/supabase/auth) | +| PostgREST | `postgrest/postgrest` | [PostgREST/postgrest](https://github.com/PostgREST/postgrest) | +| Realtime | `supabase/realtime` | [supabase/realtime](https://github.com/supabase/realtime) | +| Storage | `supabase/storage-api` | [supabase/storage](https://github.com/supabase/storage) | +| Edge Functions | `supabase/edge-runtime` | [supabase/edge-runtime](https://github.com/supabase/edge-runtime) | +| Analytics | `supabase/logflare` | [logflare/logflare](https://github.com/logflare/logflare) | +| Postgres | `supabase/postgres` | [supabase/postgres](https://github.com/supabase/postgres) | +| Supavisor (Pooler) | `supabase/supavisor` | [supabase/supavisor](https://github.com/supabase/supavisor) | + +## Table of contents + +- [Studio (Dashboard)](#studio-dashboard) +- [Auth (GoTrue)](#auth) +- [PostgREST](#postgrest) +- [Realtime](#realtime) +- [Storage](#storage) +- [Edge Functions](#edge-functions) +- [Analytics (Logflare)](#analytics) +- [Postgres (supabase/postgres)](#postgres) +- [Supavisor](#supavisor) + +--- + +## Studio (Dashboard) + +> Studio is a Next.js (Pages Router) app. `NEXT_PUBLIC_*` variables are inlined into the client bundle at build time and are visible in the browser - never store secrets in them. + +### Core (database, API gateway, public URL) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DEFAULT_ORGANIZATION_NAME` | string | Self-hosted | Name shown for the single default organization on the dashboard. | Mapped from `STUDIO_DEFAULT_ORGANIZATION` in `.env.example`. Default: `Default Organization`. | +| `DEFAULT_PROJECT_NAME` | string | Self-hosted | Name shown for the single default project on the dashboard. | Mapped from `STUDIO_DEFAULT_PROJECT` in `.env.example`. Default: `Default Project`. | +| `HOSTNAME` | string | Both | Network interface Next.js binds to inside the container. | Set to `0.0.0.0` so the container is reachable from outside. | +| `POSTGRES_DB` | string | Self-hosted | Postgres database name used for Studio's internal connections. | Default: `postgres`. | +| `POSTGRES_HOST` | string | Self-hosted | Postgres host (service name in compose network). | Default: `db`. | +| `POSTGRES_PASSWORD` | string | Both | Postgres password for the `POSTGRES_USER_READ_WRITE` role. | Supports `_FILE` suffix for Docker secrets. | +| `POSTGRES_PORT` | integer | Self-hosted | Postgres TCP port. | Default: `5432`. | +| `POSTGRES_USER_READ_ONLY` | string | | Postgres role used for read-only queries from the SQL editor. | Default: `supabase_read_only_user`. Only takes effect if you've manually created the role per the "remove superuser access" guide. | +| `POSTGRES_USER_READ_WRITE` | string | Both | Postgres role used for read/write queries from the SQL editor. | Default: `supabase_admin`. Commented out in default compose. See "remove superuser access" guide. | +| `STUDIO_PG_META_URL` | URL | Both | URL of the `postgres-meta` service used for schema introspection. | E.g. `http://meta:8080`. Required. | +| `SUPABASE_PUBLIC_URL` | URL | Both | Public URL of the Supabase stack (Kong gateway) as seen by end users. | Used to construct REST API URLs and connection strings shown in the dashboard. | +| `SUPABASE_URL` | URL | Both | Internal URL Studio uses to reach Kong from inside the Docker network. | E.g. `http://kong:8000`. | + +### Auth / JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AUTH_JWT_SECRET` | string | Both | HS256 JWT secret used to mint/verify legacy API keys; surfaced to the UI for "JWT settings" and PostgREST config. | Mapped from `JWT_SECRET` in `.env.example`. Must be at least 32 characters. | +| `SUPABASE_ANON_KEY` | string | Both | Anon API key surfaced in the Project API Settings page and used by in-dashboard clients. | Mapped from `ANON_KEY`. Supports `_FILE` suffix for Docker secrets. | +| `SUPABASE_SERVICE_KEY` | string | Both | Service-role API key surfaced in the Project API Settings page. | Mapped from `SERVICE_ROLE_KEY`. Supports `_FILE` suffix for Docker secrets. Keep secret. | + +### PG Meta + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PG_META_CRYPTO_KEY` | string | Self-hosted | Encryption key used by Studio's pg-meta routes to encrypt sensitive values (vault, foreign-server credentials) before storing them. | Falls back to `SAMPLE_KEY` if unset - set this to a random 32+ char string. | + +### PostgREST passthrough + +These mirror the running PostgREST configuration so the dashboard can display correct settings on the "API" page. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGRST_DB_EXTRA_SEARCH_PATH` | string (CSV) | Both | Extra Postgres schemas added to `search_path` for every PostgREST request. | Default: `public`. | +| `PGRST_DB_MAX_ROWS` | integer (count) | Both | Maximum rows returned by any single PostgREST request. | Default: `1000`. | +| `PGRST_DB_SCHEMAS` | string (CSV) | Both | Comma-separated list of schemas exposed via PostgREST. | Default: `public,storage,graphql_public`. Also used as the list of "Exposed schemas" in the API settings UI. | + +### Analytics / Logflare + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_API_KEY` | string | Self-hosted | Legacy alias for `LOGFLARE_PUBLIC_ACCESS_TOKEN`. | Deprecated. Declared in `apps/studio/turbo.jsonc` but not read by Studio code; kept only for backward compatibility with older deployments. | +| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | string | Both | Private API token Studio uses server-side to query Logflare endpoints (logs, charts). | Required for logs/analytics features to work on self-hosted. | +| `LOGFLARE_PUBLIC_ACCESS_TOKEN` | string | Self-hosted | Public API token used by the analytics (supabase/logflare) container for ingestion. | Not read by Studio code (despite being in `apps/studio/turbo.jsonc`). Passed through the `studio` service env in `docker-compose.yml` for parity only. | +| `LOGFLARE_URL` | URL | Both | Base URL of the Logflare/analytics service. | E.g. `http://analytics:4000`. Used to build the `PROJECT_ANALYTICS_URL`. | +| `NEXT_ANALYTICS_BACKEND_PROVIDER` | enum | Both | Historically intended to select the analytics container's backend (`postgres` or `bigquery`). | No-op today: not read by Studio code, and the `analytics` (supabase/logflare) container chooses its backend via `POSTGRES_BACKEND_URL` / `LOGFLARE_FEATURE_FLAG_OVERRIDE` instead. Safe to ignore. | +| `NEXT_PUBLIC_ENABLE_LOGS` | boolean | Both | Historically intended to toggle visibility of log explorer pages. | Not read by Studio code today, and not declared in `apps/studio/turbo.jsonc`. Use `ENABLED_FEATURES_LOGS_ALL` (see Feature flags below) for runtime control of the logs section. | + +### Feature flags (runtime overrides) + +Self-hosted Studio reads `ENABLED_FEATURES_*` env vars at container start time to disable or re-enable individual feature flags without rebuilding the image. The mapping rule is: uppercase the feature key from `packages/common/enabled-features/enabled-features.json` and replace every non-alphanumeric character with `_` (e.g. `logs:all` → `ENABLED_FEATURES_LOGS_ALL`). See `packages/common/enabled-features/README.md` for the full mechanism and the canonical flag list (~90 flags). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLED_FEATURES_*` | boolean | | Per-flag runtime override. Set to `true` or `false` (case-insensitive); other values are logged and ignored. | One env var per flag. Full key list: `packages/common/enabled-features/enabled-features.json`. No-op when `NEXT_PUBLIC_IS_PLATFORM=true`. | +| `ENABLED_FEATURES_LOGS_ALL` | boolean | | Disable the entire Logs section of the dashboard. Maps to the `logs:all` feature flag. | Documented explicitly as the runtime replacement for the legacy build-time `NEXT_PUBLIC_ENABLE_LOGS`. | + +### AI features + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `OPENAI_API_KEY` | string | Both | OpenAI API key used by the AI Assistant and SQL generator. | Optional; AI panel is disabled if unset. | + +### Edge Functions / Snippets management + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` | path | Both | Filesystem directory inside the container where edge function source is read from / written to when using the dashboard editor. | Mounted as a volume in `docker-compose.yml` (`./volumes/functions:/app/edge-functions`). | +| `SNIPPETS_MANAGEMENT_FOLDER` | path | Both | Filesystem directory inside the container where SQL editor snippets are persisted. | Mounted as a volume in `docker-compose.yml` (`./volumes/snippets:/app/snippets`). | + +### Platform flags / runtime mode + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `CURRENT_CLI_VERSION` | string | CLI | Version string set when Studio is started by the Supabase CLI. | Renames the default project to "Supabase Studio (CLI)" when set. Exposed to client via Next.js passthrough. | +| `NEXT_PUBLIC_IS_PLATFORM` | boolean | | Master switch: `"true"` runs Studio in hosted (multi-project) mode, anything else runs in self-hosted single-project mode. | Self-hosted images are **built** with this unset/`false`. Exposed to client. Setting this to `true` in a self-hosted deployment will break the dashboard. | +| `NEXT_PUBLIC_NODE_ENV` | string | | Marks the build as a test build (used by E2E setup). | Set to `test` only by `generateLocalEnv.js`. Exposed to client. | +| `NODE_ENV` | enum | Both | Standard Node.js environment (`development` / `production` / `test`). | Set automatically by Next.js. | + +--- + +## Auth + +> Auth (gotrue) uses Go's [envconfig](https://github.com/kelseyhightower/envconfig) library - the env var names are programmatically derived from the `Configuration` struct in `internal/conf/configuration.go` by combining the `GOTRUE_` prefix with each nested struct's path. Aliased fields are reachable via two names: the prefixed form (`GOTRUE_API_API_EXTERNAL_URL`) and a bare-name fallback (`API_EXTERNAL_URL`). + +> Auth's upstream `README.md` documents many of these variables with additional prose context - operator/Netlify history, default email-template bodies, OAuth provider examples, glob-matching syntax. The rows below stay reference-style; for prose backstory and template defaults, see [supabase/auth/README.md](https://github.com/supabase/auth/blob/master/README.md). + +### API + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `API_EXTERNAL_URL` | URL | Both | Externally reachable URL of the Auth API; used in emails, OAuth callbacks, SAML, etc. | Required. Alias of `GOTRUE_API_API_EXTERNAL_URL` | +| `GOTRUE_API_API_EXTERNAL_URL` | URL | | Externally reachable URL of the Auth API (prefixed form). | Required. Same field as `API_EXTERNAL_URL` | +| `GOTRUE_API_ENDPOINT` | string | | Override of the API endpoint base. | | +| `GOTRUE_API_HOST` | string | Both | Bind address for the API server. | | +| `GOTRUE_API_MAX_REQUEST_DURATION` | string (duration) | | Maximum total duration of a single API request. | Default: `10s` | +| `GOTRUE_API_PORT` | integer | Both | TCP port for the API server. | Default: `8081`. Alias of `PORT` | +| `PORT` | integer (count) | | TCP port for the API server (bare alias). | Default: `8081` | +| `GOTRUE_API_REQUEST_ID_HEADER` | string | | HTTP header name to read the request ID from. | Alias of `REQUEST_ID_HEADER` | +| `REQUEST_ID_HEADER` | string | | HTTP header name to read the request ID from (bare alias). | | + +### Database + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DATABASE_URL` | string | | Database connection string (bare alias). | Required. Alias of `GOTRUE_DB_DATABASE_URL` | +| `GOTRUE_DB_ADVISOR_ENABLED` | boolean | | Enables the DB connection-pool advisor. | Default: `true` | +| `GOTRUE_DB_ADVISOR_OBSERVATION_INTERVAL` | string (duration) | | Observation window length for the DB advisor. | Default: `20s` | +| `GOTRUE_DB_ADVISOR_SAMPLING_INTERVAL` | string (duration) | | Sampling interval for the DB advisor. | Default: `200ms` | +| `GOTRUE_DB_CLEANUP_ENABLED` | boolean | | Enables periodic cleanup of expired auth rows. | Default: `false` | +| `GOTRUE_DB_CONN_MAX_IDLE_TIME` | string (duration) | | Max time a DB connection may sit idle. | | +| `GOTRUE_DB_CONN_MAX_LIFETIME` | string (duration) | | Max lifetime of a DB connection. | | +| `GOTRUE_DB_CONN_PERCENTAGE` | integer (percent) | | Percentage of available DB connections the Auth server may use (1-100). | | +| `GOTRUE_DB_DATABASE_URL` | string | Both | Database connection string. | Required. Alias of `DATABASE_URL` | +| `GOTRUE_DB_DB_NAMESPACE` | string | | Database schema to use (prefixed alias). | Default: `auth` | +| `DB_NAMESPACE` | string | | Database schema to use (bare alias). | Default: `auth` | +| `GOTRUE_DB_DRIVER` | string | Both | Database driver name. | Required. Typically `postgres` | +| `GOTRUE_DB_HEALTH_CHECK_PERIOD` | string (duration) | | Interval between DB connection health checks. | | +| `GOTRUE_DB_MAX_IDLE_POOL_SIZE` | integer (count) | | Maximum number of idle DB connections. | | +| `GOTRUE_DB_MAX_POOL_SIZE` | integer (count) | | Maximum total DB connections (0 = unlimited). | | +| `GOTRUE_DB_MIGRATIONS_PATH` | path | CLI | Filesystem path containing migration SQL files. | Default: `./migrations` | + +### JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_JWT_ADMIN_GROUP_NAME` | string | | Group claim value treated as admin. | Default: `admin` | +| `GOTRUE_JWT_ADMIN_ROLES` | string (CSV) | Both | Comma-separated roles treated as admin. | Default: `service_role,supabase_admin` | +| `GOTRUE_JWT_AUD` | string | Both | Default `aud` claim for issued JWTs. | | +| `GOTRUE_JWT_DEFAULT_GROUP_NAME` | string | Both | Default group assigned to users. | | +| `GOTRUE_JWT_EXP` | integer (seconds) | Both | Access token lifetime in seconds. | Default: `3600` | +| `GOTRUE_JWT_ISSUER` | string | Both | `iss` claim for issued JWTs. | | +| `GOTRUE_JWT_KEY_ID` | string | | Key ID assigned to the symmetric secret key. | | +| `GOTRUE_JWT_KEYS` | JWKS | Both | JSON array of JWKs used for signing/verification. | Required when using the new API keys and new auth. | +| `GOTRUE_JWT_SECRET` | string | Both | Symmetric HS256 signing secret. | Required | +| `GOTRUE_JWT_VALID_METHODS` | string (CSV) | CLI | Allowed JWT signing methods (e.g. `HS256,RS256`). | | +| `GOTRUE_JWT_VALIDMETHODS` | string (CSV) | CLI | Alternate spelling seen in CLI; same field. | Alias artifact; prefer `GOTRUE_JWT_VALID_METHODS` | + +### Site / Redirect + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_DISABLE_SIGNUP` | boolean | Both | Disable new user signups. | | +| `GOTRUE_SITE_URL` | URL | Both | Primary site URL used in email/redirect defaults. | Required | +| `GOTRUE_URI_ALLOW_LIST` | string (CSV) | Both | Comma-separated list of allowed redirect URIs (supports glob). | | + +### Email / SMTP + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MAILER_ALLOW_UNVERIFIED_EMAIL_SIGN_INS` | boolean | | Allow sign in before email is confirmed. | Default: `false` | +| `GOTRUE_MAILER_AUTOCONFIRM` | boolean | Both | Skip email confirmation flow. | | +| `GOTRUE_MAILER_EMAIL_BACKGROUND_SENDING` | boolean | | Send emails in background (experimental). | Default: `false` | +| `GOTRUE_MAILER_EMAIL_VALIDATION_BLOCKED_MX` | JSON | | JSON array of blocked MX records for email validation. | Experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_EXTENDED` | boolean | | Enable extended email validation (MX/SMTP). | Default: `false`, experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_SERVICE_HEADERS` | JSON | | JSON object of headers sent to email validation service. | Experimental | +| `GOTRUE_MAILER_EMAIL_VALIDATION_SERVICE_URL` | URL | | External email-validation service URL. | Experimental | +| `GOTRUE_MAILER_EXTERNAL_HOSTS` | string (CSV) | | Additional hostnames allowed as the email-link host. | | +| `GOTRUE_MAILER_OTP_EXP` | integer (seconds) | CLI | OTP/email link expiry in seconds. | Default: `86400` | +| `GOTRUE_MAILER_OTP_LENGTH` | integer (count) | CLI | OTP code length (6-10). | Default: `6` | +| `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | boolean | Both | Require confirmation on both old and new emails when changing. | Default: `true`, commented out in compose | +| `GOTRUE_MAILER_TEMPLATE_MAX_AGE` | string (duration) | | Max age of a cached email template before refresh. | Default: `10m` | +| `GOTRUE_MAILER_TEMPLATE_MAX_SIZE` | integer (bytes) | | Max template size in bytes pulled from a URL. | Default: `1000000` | +| `GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED` | boolean | CLI | Enable background reloading of email templates. | Default: `false` | +| `GOTRUE_MAILER_TEMPLATE_RELOADING_MAX_IDLE` | string (duration) | | Max idle time before stopping template reload loop. | Default: `20m` | +| `GOTRUE_MAILER_TEMPLATE_RETRY_INTERVAL` | string (duration) | | Retry interval for failed template reloads. | Default: `10s` | +| `GOTRUE_SMTP_ADMIN_EMAIL` | string | Both | From address used as `admin_email`. | | +| `GOTRUE_SMTP_HEADERS` | JSON | | JSON object of extra headers added to outgoing emails. | | +| `GOTRUE_SMTP_HOST` | string | Both | SMTP relay hostname. | | +| `GOTRUE_SMTP_LOGGING_ENABLED` | boolean | | Verbose SMTP debug logging. | Default: `false` | +| `GOTRUE_SMTP_MAX_FREQUENCY` | string (duration) | Both | Minimum interval between emails per address. | Default: `1m`, commented out in compose | +| `GOTRUE_SMTP_PASS` | string | Self-hosted | SMTP password. | | +| `GOTRUE_SMTP_PORT` | integer | Both | SMTP relay port. | Default: `587` | +| `GOTRUE_SMTP_SENDER_NAME` | string | Both | From name displayed on emails. Falls back to `GOTRUE_SMTP_ADMIN_EMAIL` when unset. | | +| `GOTRUE_SMTP_USER` | string | Self-hosted | SMTP username. | | + +### Mailer notifications / subjects / templates / URL paths + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED` | boolean | | Send notification when email changes. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED` | boolean | | Send notification when an identity is linked. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED` | boolean | | Send notification when an identity is unlinked. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED` | boolean | | Send notification when an MFA factor is enrolled. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED` | boolean | | Send notification when an MFA factor is removed. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED` | boolean | | Send notification when password changes. | Default: `false` | +| `GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED` | boolean | | Send notification when phone changes. | Default: `false` | +| `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | string | | Subject for the confirmation email. | | +| `GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE` | string | | Subject for the email-change email. | | +| `GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION` | string | | Subject for the email-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION` | string | | Subject for the identity-linked notification. | | +| `GOTRUE_MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION` | string | | Subject for the identity-unlinked notification. | | +| `GOTRUE_MAILER_SUBJECTS_INVITE` | string | | Subject for the invite email. | | +| `GOTRUE_MAILER_SUBJECTS_MAGIC_LINK` | string | | Subject for the magic-link email. | | +| `GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | Subject for the MFA-enrolled notification. | | +| `GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | Subject for the MFA-unenrolled notification. | | +| `GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION` | string | | Subject for the password-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION` | string | | Subject for the phone-changed notification. | | +| `GOTRUE_MAILER_SUBJECTS_REAUTHENTICATION` | string | | Subject for the reauthentication email. | | +| `GOTRUE_MAILER_SUBJECTS_RECOVERY` | string | | Subject for the password-recovery email. | | +| `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` | string | | URL to the confirmation email template. | | +| `GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE` | string | | URL to the email-change email template. | | +| `GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION` | string | | URL to the email-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION` | string | | URL to the identity-linked notification template. | | +| `GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION` | string | | URL to the identity-unlinked notification template. | | +| `GOTRUE_MAILER_TEMPLATES_INVITE` | string | | URL to the invite email template. | | +| `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK` | string | | URL to the magic-link email template. | | +| `GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | URL to the MFA-enrolled notification template. | | +| `GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | URL to the MFA-unenrolled notification template. | | +| `GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` | string | | URL to the password-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION` | string | | URL to the phone-changed notification template. | | +| `GOTRUE_MAILER_TEMPLATES_REAUTHENTICATION` | string | | URL to the reauthentication email template. | | +| `GOTRUE_MAILER_TEMPLATES_RECOVERY` | string | | URL to the password-recovery email template. | | +| `GOTRUE_MAILER_URLPATHS_CONFIRMATION` | string | Both | URL path appended to the email confirmation link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE` | string | Both | URL path appended to the email-change link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_EMAIL_CHANGED_NOTIFICATION` | string | | URL path for the email-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_IDENTITY_LINKED_NOTIFICATION` | string | | URL path for the identity-linked notification link. | | +| `GOTRUE_MAILER_URLPATHS_IDENTITY_UNLINKED_NOTIFICATION` | string | | URL path for the identity-unlinked notification link. | | +| `GOTRUE_MAILER_URLPATHS_INVITE` | string | Both | URL path appended to the invite link. | Default: `/verify` | +| `GOTRUE_MAILER_URLPATHS_MAGIC_LINK` | string | | URL path for the magic-link redirect. | | +| `GOTRUE_MAILER_URLPATHS_MFA_FACTOR_ENROLLED_NOTIFICATION` | string | | URL path for the MFA-enrolled notification link. | | +| `GOTRUE_MAILER_URLPATHS_MFA_FACTOR_UNENROLLED_NOTIFICATION` | string | | URL path for the MFA-unenrolled notification link. | | +| `GOTRUE_MAILER_URLPATHS_PASSWORD_CHANGED_NOTIFICATION` | string | | URL path for the password-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_PHONE_CHANGED_NOTIFICATION` | string | | URL path for the phone-changed notification link. | | +| `GOTRUE_MAILER_URLPATHS_REAUTHENTICATION` | string | | URL path for the reauthentication link. | | +| `GOTRUE_MAILER_URLPATHS_RECOVERY` | string | Both | URL path appended to the recovery link. | Default: `/verify` | + +### External OAuth providers + +The fields below are repeated for each provider. Substitute `` with one of: `APPLE`, `AZURE`, `BITBUCKET`, `DISCORD`, `FACEBOOK`, `FIGMA`, `FLY`, `GITHUB`, `GITLAB`, `GOOGLE`, `KAKAO`, `KEYCLOAK`, `LINKEDIN`, `LINKEDIN_OIDC`, `NOTION`, `SLACK`, `SLACK_OIDC`, `SNAPCHAT`, `SPOTIFY`, `TWITCH`, `TWITTER`, `VERCEL_MARKETPLACE`, `WORKOS`, `X`, `ZOOM`. Each provider supports: `_ENABLED`, `_CLIENT_ID`, `_SECRET`, `_REDIRECT_URI`, `_URL`, `_API_URL`, `_SKIP_NONCE_CHECK`, `_EMAIL_OPTIONAL`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_ALLOWED_ID_TOKEN_ISSUERS` | string (CSV) | | Additional issuers accepted when verifying external ID tokens. | Defaults include `https://appleid.apple.com`, `https://accounts.google.com` | +| `GOTRUE_EXTERNAL_APPLE_API_URL` | URL | | Override Apple OAuth API endpoint. | | +| `GOTRUE_EXTERNAL_APPLE_CLIENT_ID` | string (CSV) | CLI | Apple OAuth client ID(s) (comma-separated). | | +| `GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL` | boolean | CLI | Allow accounts without an email from Apple. | | +| `GOTRUE_EXTERNAL_APPLE_ENABLED` | boolean | CLI | Enable the Apple provider. | | +| `GOTRUE_EXTERNAL_APPLE_REDIRECT_URI` | URL | CLI | Override redirect URI for Apple. | | +| `GOTRUE_EXTERNAL_APPLE_SECRET` | string | CLI | Apple OAuth client secret. | | +| `GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK` | boolean | CLI | Skip OIDC nonce check for Apple. | | +| `GOTRUE_EXTERNAL_APPLE_URL` | URL | | Override Apple OAuth base URL. | | +| `GOTRUE_EXTERNAL_AZURE_API_URL` | URL | | Override Azure API endpoint. | | +| `GOTRUE_EXTERNAL_AZURE_CLIENT_ID` | string (CSV) | Self-hosted | Azure OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Azure. | | +| `GOTRUE_EXTERNAL_AZURE_ENABLED` | boolean | Self-hosted | Enable the Azure provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for Azure. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_SECRET` | string | Self-hosted | Azure OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_AZURE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Azure. | | +| `GOTRUE_EXTERNAL_AZURE_URL` | URL | | Override Azure OAuth base URL. | | +| `GOTRUE_EXTERNAL_BITBUCKET_API_URL` | URL | | Override Bitbucket API endpoint. | | +| `GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID` | string | | Bitbucket OAuth client ID. | | +| `GOTRUE_EXTERNAL_BITBUCKET_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_ENABLED` | boolean | | Enable the Bitbucket provider. | | +| `GOTRUE_EXTERNAL_BITBUCKET_REDIRECT_URI` | URL | | Override redirect URI for Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_SECRET` | string | | Bitbucket OAuth client secret. | | +| `GOTRUE_EXTERNAL_BITBUCKET_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Bitbucket. | | +| `GOTRUE_EXTERNAL_BITBUCKET_URL` | URL | | Override Bitbucket OAuth base URL. | | +| `GOTRUE_EXTERNAL_DISCORD_API_URL` | URL | | Override Discord API endpoint. | | +| `GOTRUE_EXTERNAL_DISCORD_CLIENT_ID` | string | | Discord OAuth client ID. | | +| `GOTRUE_EXTERNAL_DISCORD_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_ENABLED` | boolean | | Enable the Discord provider. | | +| `GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI` | URL | | Override redirect URI for Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_SECRET` | string | | Discord OAuth client secret. | | +| `GOTRUE_EXTERNAL_DISCORD_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Discord. | | +| `GOTRUE_EXTERNAL_DISCORD_URL` | URL | | Override Discord OAuth base URL. | | +| `GOTRUE_EXTERNAL_EMAIL_AUTHORIZED_ADDRESSES` | string (CSV) | | Restrict email signup to a list of allowed addresses/domains. | | +| `GOTRUE_EXTERNAL_EMAIL_ENABLED` | boolean | Both | Enable email/password authentication. When disabled, OAuth providers can still be used to sign up / sign in. | Default: `true` | +| `GOTRUE_EXTERNAL_EMAIL_MAGIC_LINK_ENABLED` | boolean | | Enable email magic links. | Default: `true` | +| `GOTRUE_EXTERNAL_FACEBOOK_API_URL` | URL | | Override Facebook API endpoint. | | +| `GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID` | string | | Facebook OAuth client ID. | | +| `GOTRUE_EXTERNAL_FACEBOOK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_ENABLED` | boolean | | Enable the Facebook provider. | | +| `GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI` | URL | | Override redirect URI for Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_SECRET` | string | | Facebook OAuth client secret. | | +| `GOTRUE_EXTERNAL_FACEBOOK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Facebook. | | +| `GOTRUE_EXTERNAL_FACEBOOK_URL` | URL | | Override Facebook OAuth base URL. | | +| `GOTRUE_EXTERNAL_FIGMA_API_URL` | URL | | Override Figma API endpoint. | | +| `GOTRUE_EXTERNAL_FIGMA_CLIENT_ID` | string | | Figma OAuth client ID. | | +| `GOTRUE_EXTERNAL_FIGMA_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_ENABLED` | boolean | | Enable the Figma provider. | | +| `GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI` | URL | | Override redirect URI for Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_SECRET` | string | | Figma OAuth client secret. | | +| `GOTRUE_EXTERNAL_FIGMA_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Figma. | | +| `GOTRUE_EXTERNAL_FIGMA_URL` | URL | | Override Figma OAuth base URL. | | +| `GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION` | string (duration) | | Lifetime of the PKCE flow state. | Default: `5m` (minimum enforced) | +| `GOTRUE_EXTERNAL_FLY_API_URL` | URL | | Override Fly.io API endpoint. | | +| `GOTRUE_EXTERNAL_FLY_CLIENT_ID` | string | | Fly.io OAuth client ID. | | +| `GOTRUE_EXTERNAL_FLY_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_ENABLED` | boolean | | Enable the Fly.io provider. | | +| `GOTRUE_EXTERNAL_FLY_REDIRECT_URI` | URL | | Override redirect URI for Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_SECRET` | string | | Fly.io OAuth client secret. | | +| `GOTRUE_EXTERNAL_FLY_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Fly.io. | | +| `GOTRUE_EXTERNAL_FLY_URL` | URL | | Override Fly.io OAuth base URL. | | +| `GOTRUE_EXTERNAL_GITHUB_API_URL` | URL | | Override GitHub API endpoint. | | +| `GOTRUE_EXTERNAL_GITHUB_CLIENT_ID` | string | Self-hosted | GitHub OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from GitHub. | | +| `GOTRUE_EXTERNAL_GITHUB_ENABLED` | boolean | Self-hosted | Enable the GitHub provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for GitHub. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_SECRET` | string | Self-hosted | GitHub OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_GITHUB_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for GitHub. | | +| `GOTRUE_EXTERNAL_GITHUB_URL` | URL | | Override GitHub OAuth base URL. | | +| `GOTRUE_EXTERNAL_GITLAB_API_URL` | URL | | Override GitLab API endpoint. | | +| `GOTRUE_EXTERNAL_GITLAB_CLIENT_ID` | string | | GitLab OAuth client ID. | | +| `GOTRUE_EXTERNAL_GITLAB_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_ENABLED` | boolean | | Enable the GitLab provider. | | +| `GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI` | URL | | Override redirect URI for GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_SECRET` | string | | GitLab OAuth client secret. | | +| `GOTRUE_EXTERNAL_GITLAB_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for GitLab. | | +| `GOTRUE_EXTERNAL_GITLAB_URL` | URL | | Override GitLab OAuth base URL. | | +| `GOTRUE_EXTERNAL_GOOGLE_API_URL` | URL | | Override Google API endpoint. | | +| `GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID` | string | Self-hosted | Google OAuth client ID. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Google. | | +| `GOTRUE_EXTERNAL_GOOGLE_ENABLED` | boolean | Self-hosted | Enable the Google provider. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI` | URL | Self-hosted | Override redirect URI for Google. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_SECRET` | string | Self-hosted | Google OAuth client secret. | Commented out in compose | +| `GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Google. | | +| `GOTRUE_EXTERNAL_GOOGLE_URL` | URL | | Override Google OAuth base URL. | | +| `GOTRUE_EXTERNAL_IOS_BUNDLE_ID` | string | | Apple iOS bundle ID for the Apple provider. | | +| `GOTRUE_EXTERNAL_KAKAO_API_URL` | URL | | Override Kakao API endpoint. | | +| `GOTRUE_EXTERNAL_KAKAO_CLIENT_ID` | string | | Kakao OAuth client ID. | | +| `GOTRUE_EXTERNAL_KAKAO_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_ENABLED` | boolean | | Enable the Kakao provider. | | +| `GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI` | URL | | Override redirect URI for Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_SECRET` | string | | Kakao OAuth client secret. | | +| `GOTRUE_EXTERNAL_KAKAO_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Kakao. | | +| `GOTRUE_EXTERNAL_KAKAO_URL` | URL | | Override Kakao OAuth base URL. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_API_URL` | URL | | Override Keycloak API endpoint. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID` | string | | Keycloak OAuth client ID. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_ENABLED` | boolean | | Enable the Keycloak provider. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI` | URL | | Override redirect URI for Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` | string | | Keycloak OAuth client secret. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Keycloak. | | +| `GOTRUE_EXTERNAL_KEYCLOAK_URL` | URL | | Override Keycloak OAuth base URL (realm URL). | | +| `GOTRUE_EXTERNAL_LINKEDIN_API_URL` | URL | | Override LinkedIn API endpoint. | | +| `GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID` | string | | LinkedIn OAuth client ID. | | +| `GOTRUE_EXTERNAL_LINKEDIN_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_ENABLED` | boolean | | Enable the legacy LinkedIn provider. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_API_URL` | URL | | Override LinkedIn OIDC API endpoint. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID` | string | | LinkedIn OIDC client ID. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_ENABLED` | boolean | | Enable the LinkedIn OIDC provider. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_REDIRECT_URI` | URL | | Override redirect URI for LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_SECRET` | string | | LinkedIn OIDC client secret. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for LinkedIn OIDC. | | +| `GOTRUE_EXTERNAL_LINKEDIN_OIDC_URL` | URL | | Override LinkedIn OIDC base URL. | | +| `GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI` | URL | | Override redirect URI for LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_SECRET` | string | | LinkedIn OAuth client secret. | | +| `GOTRUE_EXTERNAL_LINKEDIN_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for LinkedIn. | | +| `GOTRUE_EXTERNAL_LINKEDIN_URL` | URL | | Override LinkedIn OAuth base URL. | | +| `GOTRUE_EXTERNAL_NOTION_API_URL` | URL | | Override Notion API endpoint. | | +| `GOTRUE_EXTERNAL_NOTION_CLIENT_ID` | string | | Notion OAuth client ID. | | +| `GOTRUE_EXTERNAL_NOTION_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Notion. | | +| `GOTRUE_EXTERNAL_NOTION_ENABLED` | boolean | | Enable the Notion provider. | | +| `GOTRUE_EXTERNAL_NOTION_REDIRECT_URI` | URL | | Override redirect URI for Notion. | | +| `GOTRUE_EXTERNAL_NOTION_SECRET` | string | | Notion OAuth client secret. | | +| `GOTRUE_EXTERNAL_NOTION_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Notion. | | +| `GOTRUE_EXTERNAL_NOTION_URL` | URL | | Override Notion OAuth base URL. | | +| `GOTRUE_EXTERNAL_OIDC_PROVIDER_CACHE_TTL` | string (duration) | | Cache lifetime for OIDC discovery documents. | Default: `1h` | +| `GOTRUE_EXTERNAL_REDIRECT_URL` | URL | | Global override of OAuth redirect URL. | | +| `GOTRUE_EXTERNAL_SKIP_NONCE_CHECK` | string | Self-hosted | Listed (commented) in compose but does not map to a configuration field. | Commented out in compose; no effect - use per-provider `*_SKIP_NONCE_CHECK` | +| `GOTRUE_EXTERNAL_SLACK_API_URL` | URL | | Override Slack API endpoint. | | +| `GOTRUE_EXTERNAL_SLACK_CLIENT_ID` | string | | Legacy Slack OAuth client ID. | | +| `GOTRUE_EXTERNAL_SLACK_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Slack. | | +| `GOTRUE_EXTERNAL_SLACK_ENABLED` | boolean | | Enable the legacy Slack provider. | Prefer `SLACK_OIDC` | +| `GOTRUE_EXTERNAL_SLACK_OIDC_API_URL` | URL | | Override Slack OIDC API endpoint. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID` | string | | Slack OIDC client ID. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_ENABLED` | boolean | | Enable the Slack OIDC provider. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_REDIRECT_URI` | URL | | Override redirect URI for Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_SECRET` | string | | Slack OIDC client secret. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Slack OIDC. | | +| `GOTRUE_EXTERNAL_SLACK_OIDC_URL` | URL | | Override Slack OIDC base URL. | | +| `GOTRUE_EXTERNAL_SLACK_REDIRECT_URI` | URL | | Override redirect URI for Slack. | | +| `GOTRUE_EXTERNAL_SLACK_SECRET` | string | | Legacy Slack OAuth client secret. | | +| `GOTRUE_EXTERNAL_SLACK_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Slack. | | +| `GOTRUE_EXTERNAL_SLACK_URL` | URL | | Override Slack OAuth base URL. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_API_URL` | URL | | Override Snapchat API endpoint. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_CLIENT_ID` | string | | Snapchat OAuth client ID. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_ENABLED` | boolean | | Enable the Snapchat provider. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_REDIRECT_URI` | URL | | Override redirect URI for Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_SECRET` | string | | Snapchat OAuth client secret. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Snapchat. | | +| `GOTRUE_EXTERNAL_SNAPCHAT_URL` | URL | | Override Snapchat OAuth base URL. | | +| `GOTRUE_EXTERNAL_SPOTIFY_API_URL` | URL | | Override Spotify API endpoint. | | +| `GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID` | string | | Spotify OAuth client ID. | | +| `GOTRUE_EXTERNAL_SPOTIFY_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_ENABLED` | boolean | | Enable the Spotify provider. | | +| `GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI` | URL | | Override redirect URI for Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_SECRET` | string | | Spotify OAuth client secret. | | +| `GOTRUE_EXTERNAL_SPOTIFY_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Spotify. | | +| `GOTRUE_EXTERNAL_SPOTIFY_URL` | URL | | Override Spotify OAuth base URL. | | +| `GOTRUE_EXTERNAL_TWITCH_API_URL` | URL | | Override Twitch API endpoint. | | +| `GOTRUE_EXTERNAL_TWITCH_CLIENT_ID` | string | | Twitch OAuth client ID. | | +| `GOTRUE_EXTERNAL_TWITCH_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_ENABLED` | boolean | | Enable the Twitch provider. | | +| `GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI` | URL | | Override redirect URI for Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_SECRET` | string | | Twitch OAuth client secret. | | +| `GOTRUE_EXTERNAL_TWITCH_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Twitch. | | +| `GOTRUE_EXTERNAL_TWITCH_URL` | URL | | Override Twitch OAuth base URL. | | +| `GOTRUE_EXTERNAL_TWITTER_API_URL` | URL | | Override Twitter API endpoint. | | +| `GOTRUE_EXTERNAL_TWITTER_CLIENT_ID` | string | | Twitter OAuth client ID. | | +| `GOTRUE_EXTERNAL_TWITTER_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_ENABLED` | boolean | | Enable the Twitter provider. | | +| `GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI` | URL | | Override redirect URI for Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_SECRET` | string | | Twitter OAuth client secret. | | +| `GOTRUE_EXTERNAL_TWITTER_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Twitter. | | +| `GOTRUE_EXTERNAL_TWITTER_URL` | URL | | Override Twitter OAuth base URL. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_API_URL` | URL | | Override Vercel Marketplace API endpoint. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_CLIENT_ID` | string | | Vercel Marketplace OAuth client ID. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_ENABLED` | boolean | | Enable the Vercel Marketplace provider. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_REDIRECT_URI` | URL | | Override redirect URI for Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_SECRET` | string | | Vercel Marketplace OAuth client secret. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Vercel Marketplace. | | +| `GOTRUE_EXTERNAL_VERCEL_MARKETPLACE_URL` | URL | | Override Vercel Marketplace OAuth base URL. | | +| `GOTRUE_EXTERNAL_WORKOS_API_URL` | URL | | Override WorkOS API endpoint. | | +| `GOTRUE_EXTERNAL_WORKOS_CLIENT_ID` | string | | WorkOS OAuth client ID. | | +| `GOTRUE_EXTERNAL_WORKOS_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_ENABLED` | boolean | | Enable the WorkOS provider. | | +| `GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI` | URL | | Override redirect URI for WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_SECRET` | string | | WorkOS OAuth client secret. | | +| `GOTRUE_EXTERNAL_WORKOS_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for WorkOS. | | +| `GOTRUE_EXTERNAL_WORKOS_URL` | URL | | Override WorkOS OAuth base URL. | | +| `GOTRUE_EXTERNAL_X_API_URL` | URL | | Override X (Twitter) API endpoint. | | +| `GOTRUE_EXTERNAL_X_CLIENT_ID` | string | | X (Twitter) OAuth client ID. | | +| `GOTRUE_EXTERNAL_X_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from X. | | +| `GOTRUE_EXTERNAL_X_ENABLED` | boolean | | Enable the X (Twitter) provider. | | +| `GOTRUE_EXTERNAL_X_REDIRECT_URI` | URL | | Override redirect URI for X. | | +| `GOTRUE_EXTERNAL_X_SECRET` | string | | X (Twitter) OAuth client secret. | | +| `GOTRUE_EXTERNAL_X_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for X. | | +| `GOTRUE_EXTERNAL_X_URL` | URL | | Override X (Twitter) OAuth base URL. | | +| `GOTRUE_EXTERNAL_ZOOM_API_URL` | URL | | Override Zoom API endpoint. | | +| `GOTRUE_EXTERNAL_ZOOM_CLIENT_ID` | string | | Zoom OAuth client ID. | | +| `GOTRUE_EXTERNAL_ZOOM_EMAIL_OPTIONAL` | boolean | | Allow accounts without an email from Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_ENABLED` | boolean | | Enable the Zoom provider. | | +| `GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI` | URL | | Override redirect URI for Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_SECRET` | string | | Zoom OAuth client secret. | | +| `GOTRUE_EXTERNAL_ZOOM_SKIP_NONCE_CHECK` | boolean | | Skip OIDC nonce check for Zoom. | | +| `GOTRUE_EXTERNAL_ZOOM_URL` | URL | | Override Zoom OAuth base URL. | | + +### Anonymous + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED` | boolean | Both | Enable anonymous user signup. | Default: `false` | + +### Custom OAuth / OAuth Server + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_CUSTOM_OAUTH_ENABLED` | boolean | | Enable user-defined custom OAuth/OIDC providers. | Default: `true` | +| `GOTRUE_CUSTOM_OAUTH_MAX_PROVIDERS` | integer (count) | | Maximum number of custom providers allowed. | Default: `0` (unlimited) | +| `GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION` | boolean | | Allow dynamic client registration on the OAuth server. | | +| `GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH` | string | | Path prefix for the OAuth authorization endpoint. | | +| `GOTRUE_OAUTH_SERVER_AUTHORIZATION_TTL` | string (duration) | | Lifetime of an authorization code. | Default: `10m` | +| `GOTRUE_OAUTH_SERVER_DEFAULT_SCOPE` | string | | Default scope returned to clients. | Default: `email` | +| `GOTRUE_OAUTH_SERVER_ENABLED` | boolean | | Enable the built-in OAuth authorization server. | Default: `false` | + +### Phone / SMS + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_PHONE_ENABLED` | boolean | Both | Enable phone-based authentication. | Default: `false` | +| `GOTRUE_SMS_AUTOCONFIRM` | boolean | Both | Skip phone verification flow. | | +| `GOTRUE_SMS_MAX_FREQUENCY` | string (duration) | Both | Minimum interval between SMS messages per phone. | Default: `1m`, commented out in compose | +| `GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY` | string | | Messagebird API access key. | | +| `GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR` | string | | Messagebird originator (sender ID). | | +| `GOTRUE_SMS_OTP_EXP` | integer (seconds) | Both | SMS OTP expiry in seconds. | Default: `60`, commented out in compose | +| `GOTRUE_SMS_OTP_LENGTH` | integer (count) | Both | SMS OTP code length (6-10). | Default: `6`, commented out in compose | +| `GOTRUE_SMS_PROVIDER` | string | Self-hosted | SMS provider name (`twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`). | Commented out in compose | +| `GOTRUE_SMS_TEMPLATE` | string | Both | Message template for SMS OTP. | Commented out in compose | +| `GOTRUE_SMS_TEST_OTP` | JSON | Both | JSON map of phone-to-OTP overrides for testing. | Commented out in compose | +| `GOTRUE_SMS_TEST_OTP_VALID_UNTIL` | string | | Cutoff time after which test OTPs stop being accepted. | | +| `GOTRUE_SMS_TEXTLOCAL_API_KEY` | string | | Textlocal API key. | | +| `GOTRUE_SMS_TEXTLOCAL_SENDER` | string | | Textlocal sender ID. | | +| `GOTRUE_SMS_TWILIO_ACCOUNT_SID` | string | Self-hosted | Twilio account SID. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_AUTH_TOKEN` | string | Self-hosted | Twilio auth token. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_CONTENT_SID` | string | | Twilio content SID (template). | | +| `GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID` | string | Self-hosted | Twilio message service SID / phone number. | Commented out in compose | +| `GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID` | string | | Twilio Verify account SID. | | +| `GOTRUE_SMS_TWILIO_VERIFY_AUTH_TOKEN` | string | | Twilio Verify auth token. | | +| `GOTRUE_SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID` | string | | Twilio Verify message service SID. | | +| `GOTRUE_SMS_VONAGE_API_KEY` | string | | Vonage API key. | | +| `GOTRUE_SMS_VONAGE_API_SECRET` | string | | Vonage API secret. | | +| `GOTRUE_SMS_VONAGE_FROM` | string | | Vonage `from` parameter (sender). | | + +### MFA + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_MFA_CHALLENGE_EXPIRY_DURATION` | integer (seconds) | | Lifetime of an MFA challenge (seconds). | Default: `300` | +| `GOTRUE_MFA_FACTOR_EXPIRY_DURATION` | string (duration) | | Lifetime of an unverified MFA factor. | Default: `300s` | +| `GOTRUE_MFA_MAX_ENROLLED_FACTORS` | integer (count) | Both | Maximum factors a user may enroll. | Default: `10`, commented out in compose | +| `GOTRUE_MFA_MAX_VERIFIED_FACTORS` | integer (count) | | Maximum verified factors per user. | Default: `10` | +| `GOTRUE_MFA_PHONE_ENROLL_ENABLED` | boolean | Both | Allow enrolling a phone MFA factor. | Default: `false`, commented out in compose | +| `GOTRUE_MFA_PHONE_MAX_FREQUENCY` | string (duration) | | Minimum interval between MFA phone OTPs. | Default: `1m` | +| `GOTRUE_MFA_PHONE_OTP_LENGTH` | integer (count) | | Phone MFA OTP code length. | Default: `6` | +| `GOTRUE_MFA_PHONE_TEMPLATE` | string | | Template string for MFA phone OTP messages. | | +| `GOTRUE_MFA_PHONE_VERIFY_ENABLED` | boolean | Both | Allow verifying a phone MFA factor. | Default: `false`, commented out in compose | +| `GOTRUE_MFA_RATE_LIMIT_CHALLENGE_AND_VERIFY` | number | | Rate limit for MFA challenge + verify. | Default: `15` | +| `GOTRUE_MFA_TOTP_ENROLL_ENABLED` | boolean | Both | Allow enrolling a TOTP MFA factor. | Default: `true`, commented out in compose | +| `GOTRUE_MFA_TOTP_VERIFY_ENABLED` | boolean | Both | Allow verifying a TOTP MFA factor. | Default: `true`, commented out in compose | +| `GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED` | string | CLI | Allow enrolling a WebAuthn MFA factor. | Default: `false` | +| `GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED` | string | CLI | Allow verifying a WebAuthn MFA factor. | Default: `false` | + +### WebAuthn / Passkey + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_PASSKEY_ENABLED` | boolean | | Enable passkey (passwordless WebAuthn) authentication. | Default: `false` | +| `GOTRUE_PASSKEY_MAX_PASSKEYS_PER_USER` | integer (count) | | Maximum passkeys a user may register. | Default: `10` | +| `GOTRUE_WEBAUTHN_CHALLENGE_EXPIRY_DURATION` | string (duration) | | Lifetime of a WebAuthn challenge. | Default: `5m` | +| `GOTRUE_WEBAUTHN_RP_DISPLAY_NAME` | string | | WebAuthn relying party display name. | Required when WebAuthn/Passkey is enabled | +| `GOTRUE_WEBAUTHN_RP_ID` | string | | WebAuthn relying party ID (host). | Required when WebAuthn/Passkey is enabled. Alias of `RP_ID` | +| `RP_ID` | string | | WebAuthn relying party ID (bare alias). | | +| `GOTRUE_WEBAUTHN_RP_ORIGINS` | string (CSV) | | Allowed WebAuthn origins (https or http://localhost). | Required when WebAuthn/Passkey is enabled | + +### SAML + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS` | boolean | Self-hosted | Permit encrypted SAML assertions. | Commented out in compose | +| `GOTRUE_SAML_ENABLED` | boolean | Self-hosted | Enable SAML SSO. | Commented out in compose | +| `GOTRUE_SAML_EXTERNAL_URL` | URL | Self-hosted | External URL used in SAML metadata (defaults to `API_EXTERNAL_URL`). | Commented out in compose | +| `GOTRUE_SAML_PRIVATE_KEY` | string | Self-hosted | Base64-encoded PKCS#1 RSA private key (>= 2048 bits). | Commented out in compose | +| `GOTRUE_SAML_RATE_LIMIT_ASSERTION` | number | Self-hosted | Rate limit for SAML assertion submissions. | Default: `15`, commented out in compose | +| `GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD` | string (duration) | Self-hosted | Lifetime of SAML RelayState. | Default: `2m`, commented out in compose | + +### Hooks + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_HOOK_AFTER_USER_CREATED_ENABLED` | boolean | | Enable the after-user-created hook. | | +| `GOTRUE_HOOK_AFTER_USER_CREATED_SECRETS` | string | | Standard webhook secrets (pipe-separated) for after-user-created hook. | | +| `GOTRUE_HOOK_AFTER_USER_CREATED_URI` | string | | URI of the after-user-created hook (pg-functions or https). | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_ENABLED` | boolean | | Enable the before-user-created hook. | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_SECRETS` | string | | Standard webhook secrets for the before-user-created hook. | | +| `GOTRUE_HOOK_BEFORE_USER_CREATED_URI` | string | | URI of the before-user-created hook. | | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED` | boolean | Self-hosted | Enable the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS` | string | Self-hosted | Standard webhook secrets for the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI` | string | Self-hosted | URI of the custom access token hook. | Commented out in compose | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED` | boolean | Self-hosted | Enable the MFA verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_SECRETS` | string | | Standard webhook secrets for the MFA verification attempt hook. | | +| `GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI` | string | Self-hosted | URI of the MFA verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED` | boolean | Self-hosted | Enable the password verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_SECRETS` | string | | Standard webhook secrets for the password verification attempt hook. | | +| `GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI` | string | Self-hosted | URI of the password verification attempt hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_ENABLED` | boolean | Self-hosted | Enable the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_SECRETS` | string | Self-hosted | Standard webhook secrets for the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_EMAIL_URI` | string | Self-hosted | URI of the send-email hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_ENABLED` | boolean | Self-hosted | Enable the send-SMS hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_SECRETS` | string | Self-hosted | Standard webhook secrets for the send-SMS hook. | Commented out in compose | +| `GOTRUE_HOOK_SEND_SMS_URI` | string | Self-hosted | URI of the send-SMS hook. | Commented out in compose | + +### Rate limits + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_RATE_LIMIT_ANONYMOUS_USERS` | number | CLI | Rate limit for anonymous user creation. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_EMAIL_SENT` | number | CLI | Rate limit for outgoing emails on `/signup`, `/invite`, `/magiclink`, `/recover`, `/otp`, `/user`. Accepts `n` or `n/duration` (burst). | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_HEADER` | string | | HTTP header used to rate-limit the `/token` endpoint (e.g. `X-Forwarded-For`). | | +| `GOTRUE_RATE_LIMIT_O_AUTH_DYNAMIC_CLIENT_REGISTER` | number | | Rate limit for OAuth dynamic client registration. | Default: `10` per hour | +| `GOTRUE_RATE_LIMIT_OTP` | number | CLI | Rate limit for OTP endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_PASSKEY` | number | | Rate limit for passkey endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_SMS_SENT` | number | CLI | Rate limit for outgoing SMS messages. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_SSO` | number | | Rate limit for SSO endpoints. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` | number | CLI | Rate limit for token refresh. | Default: `150` per hour | +| `GOTRUE_RATE_LIMIT_VERIFY` | number | CLI | Rate limit for the verify endpoint. | Default: `30` per hour | +| `GOTRUE_RATE_LIMIT_WEB3` | number | CLI | Rate limit for Web3 sign-in. | Default: `30` per hour | + +### Sessions + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SESSIONS_ALLOW_LOW_AAL` | string (duration) | | Time during which a low-AAL session is still accepted. | | +| `GOTRUE_SESSIONS_INACTIVITY_TIMEOUT` | string (duration) | | Session inactivity timeout. | | +| `GOTRUE_SESSIONS_SINGLE_PER_USER` | boolean | | Allow only one active session per user. | | +| `GOTRUE_SESSIONS_TAGS` | string (CSV) | | Tags attached to created sessions. | | +| `GOTRUE_SESSIONS_TIMEBOX` | string (duration) | | Absolute session lifetime. | | + +### Web3 + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED` | boolean | CLI | Enable Ethereum sign-in (Sign-in with Ethereum). | Default: `false` | +| `GOTRUE_EXTERNAL_WEB3_ETHEREUM_MAXIMUM_VALIDITY_DURATION` | string (duration) | | Max validity of an Ethereum signed message. | Default: `10m` | +| `GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED` | boolean | CLI | Enable Solana sign-in. | Default: `false` | +| `GOTRUE_EXTERNAL_WEB3_SOLANA_MAXIMUM_VALIDITY_DURATION` | string (duration) | | Max validity of a Solana signed message. | Default: `10m` | + +### Security + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS` | string | | Map of `key_id:base64-key` used to decrypt previously encrypted columns. | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT` | string | | Enable column-level encryption for new writes. | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY` | string | | Active encryption key (256-bit, base64-RawURL-encoded). | | +| `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID` | string | | ID of the active encryption key. | | +| `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED` | boolean | CLI | Allow admins to link identities manually. | Default: `false` | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ALGORITHM_VERSION` | integer (count) | | Refresh token algorithm version (0, 1 or 2). | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ALLOW_REUSE` | boolean | | Allow refresh-token reuse without rotation. | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` | integer (seconds) | CLI | Grace period (s) during which the immediately-previous refresh token can be reused (supports concurrency / offline retries). Only applies when rotation is enabled. | | +| `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` | boolean | CLI | Rotate refresh tokens on use; detects malicious reuse and revokes the offending token's descendants. | Default: `true` | +| `GOTRUE_SECURITY_REFRESH_TOKEN_UPGRADE_PERCENTAGE` | integer (percent) | | Percentage of users to upgrade to a newer refresh-token format (0-100). | | +| `GOTRUE_SECURITY_SB_FORWARDED_FOR_ENABLED` | boolean | | Trust the `Sb-Forwarded-For` header. Auth parses the leftmost value as an IP address and uses it for IP tracking and rate limiting. | Default: `false` | +| `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD` | boolean | | Require the current password to change a password. | | +| `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` | boolean | CLI | Require reauthentication before changing a password. | | + +### CAPTCHA + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_SECURITY_CAPTCHA_ENABLED` | boolean | | Enable CAPTCHA protection. | Default: `false` | +| `GOTRUE_SECURITY_CAPTCHA_PROVIDER` | string | | CAPTCHA provider (`hcaptcha` or `turnstile`). | Default: `hcaptcha` | +| `GOTRUE_SECURITY_CAPTCHA_SECRET` | string | | CAPTCHA provider secret. | | +| `GOTRUE_SECURITY_CAPTCHA_TIMEOUT` | string (duration) | | HTTP timeout for the CAPTCHA verify call. | Default: `10s` | + +### Password + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_PASSWORD_HIBP_BLOOM_ENABLED` | boolean | | Use a local bloom filter for HIBP lookups. | | +| `GOTRUE_PASSWORD_HIBP_BLOOM_FALSE_POSITIVES` | number (ratio) | | Target false positive rate for the HIBP bloom filter. | Default: `0.0000099` | +| `GOTRUE_PASSWORD_HIBP_BLOOM_ITEMS` | integer (count) | | Expected number of items in the HIBP bloom filter. | Default: `100000` | +| `GOTRUE_PASSWORD_HIBP_ENABLED` | boolean | | Reject pwned passwords using Have I Been Pwned. | | +| `GOTRUE_PASSWORD_HIBP_FAIL_CLOSED` | boolean | | Reject requests if the HIBP lookup fails. | | +| `GOTRUE_PASSWORD_HIBP_USER_AGENT` | string | | User-Agent sent to the HIBP API. | Default: `https://github.com/supabase/gotrue` | +| `GOTRUE_PASSWORD_MIN_LENGTH` | integer (count) | CLI | Minimum password length. | Default: `6` | +| `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` | string | CLI | Colon-separated character classes; a password must contain at least one character from each set. Escape a literal `:` with `\`. | | + +### CORS / Audit log + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_AUDIT_LOG_DISABLE_POSTGRES` | boolean | | Disable Postgres-backed audit log writes. | Default: `false` | +| `GOTRUE_CORS_ALLOWED_HEADERS` | string (CSV) | | Additional headers appended to the CORS allow-list. | | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_LOG_DISABLE_COLORS` | boolean | | Disable ANSI color in log output. | | +| `GOTRUE_LOG_FIELDS` | JSON | | Static log fields (JSON object) attached to every log line. | | +| `GOTRUE_LOG_FILE` | path | | Path to a file to write logs to. | | +| `GOTRUE_LOG_LEVEL` | string | | Logger level (`panic`, `fatal`, `error`, `warn`, `info`, `debug`). | | +| `GOTRUE_LOG_QUOTE_EMPTY_FIELDS` | boolean | | Quote empty log field values. | | +| `GOTRUE_LOG_SQL` | string | | SQL logger configuration. | | +| `GOTRUE_LOG_TSFORMAT` | string | | Timestamp format string for log output. | | + +### Profiler / Tracing / Metrics + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_METRICS_ENABLED` | boolean | | Enable metrics export. | | +| `GOTRUE_METRICS_EXPORTER` | string | | Metrics exporter (`opentelemetry` or `prometheus`). | Default: `opentelemetry` | +| `GOTRUE_METRICS_OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol for metrics. | Default: `http/protobuf`. Alias of `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `GOTRUE_METRICS_OTEL_EXPORTER_PROMETHEUS_HOST` | string | | Bind host for the Prometheus exporter. | Default: `0.0.0.0`. Alias of `OTEL_EXPORTER_PROMETHEUS_HOST` | +| `GOTRUE_METRICS_OTEL_EXPORTER_PROMETHEUS_PORT` | string | | Bind port for the Prometheus exporter. | Default: `9100`. Alias of `OTEL_EXPORTER_PROMETHEUS_PORT` | +| `GOTRUE_PROFILER_ENABLED` | boolean | | Expose the Go pprof HTTP endpoint. | Default: `false` | +| `GOTRUE_PROFILER_HOST` | string | | Bind host for the profiler endpoint. | Default: `localhost` | +| `GOTRUE_PROFILER_PORT` | string | | Bind port for the profiler endpoint. | Default: `9998` | +| `GOTRUE_TRACING_ENABLED` | boolean | | Enable distributed tracing. | | +| `GOTRUE_TRACING_EXPORTER` | string | | Tracing exporter (`opentelemetry`). | Default: `opentelemetry` | +| `GOTRUE_TRACING_HOST` | string | | OpenTelemetry collector host. | | +| `GOTRUE_TRACING_OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol for tracing. | Default: `http/protobuf`. Alias of `OTEL_EXPORTER_OTLP_PROTOCOL` | +| `GOTRUE_TRACING_PORT` | string | | OpenTelemetry collector port. | | +| `GOTRUE_TRACING_SERVICE_NAME` | string | | Service name reported in traces. | Default: `gotrue` | +| `GOTRUE_TRACING_TAGS` | JSON | | Comma-separated `k=v` pairs attached to all spans. | | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | string | | OTLP protocol (bare alias, applies to metrics and tracing). | Default: `http/protobuf` | +| `OTEL_EXPORTER_PROMETHEUS_HOST` | string | | Prometheus host (bare alias). | Default: `0.0.0.0` | +| `OTEL_EXPORTER_PROMETHEUS_PORT` | string | | Prometheus port (bare alias). | Default: `9100` | + +### Config reloading + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_RELOADING_GRACE_PERIOD_INTERVAL` | string (duration) | | Idle period before processing a config reload (debounce). | Default: `5s` | +| `GOTRUE_RELOADING_NOTIFY_ENABLED` | boolean | | Use filesystem notifications to detect config changes. | Default: `true` | +| `GOTRUE_RELOADING_POLLER_INTERVAL` | string (duration) | | Polling interval when notifications are disabled. | Default: `10s` | +| `GOTRUE_RELOADING_POLLERENABLED` | string | | Enable filesystem polling for config changes (name is intentionally unsplit). | Default: `false` | +| `GOTRUE_RELOADING_SIGNAL_ENABLED` | boolean | | Trigger a config reload on receiving a Unix signal. | Default: `false` | +| `GOTRUE_RELOADING_SIGNAL_NUMBER` | integer (count) | | Unix signal number to listen for. | Default: `10` (SIGUSR1) | + +### Other + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOTRUE_EXPERIMENTAL_PROVIDERS_WITH_OWN_LINKING_DOMAIN` | string | | Providers that do not participate in email-similarity identity linking. | Experimental | +| `GOTRUE_INDEX_WORKER_ENSURE_USER_SEARCH_INDEXES_EXIST` | boolean | | Always create user-search indexes on startup. | Default: `false` | +| `GOTRUE_INDEX_WORKER_MAX_USERS_THRESHOLD` | integer (count) | | Create user-search indexes only if user count is at or below this threshold. | Default: `0` (disabled) | +| `GOTRUE_INTERNAL_HTTP_TIMEOUT` | string (duration) | | HTTP client timeout used by external OAuth and SMS provider calls. | Read via `os.Getenv`, not envconfig | +| `GOTRUE_OPERATOR_TOKEN` | string | | Bearer token required for operator/admin endpoints. | | + +--- + + +## PostgREST + +> PostgREST's upstream documentation at [postgrest.org](https://postgrest.org/en/stable/references/configuration.html) covers each variable with prose context - security rationale, interaction notes, examples. The rows below stay reference-style; for backstory and detailed semantics, see the upstream docs. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGRST_ADMIN_SERVER_HOST` | string | Self-hosted | Hostname for the PostgREST admin server. | Defaults to `server-host` value | +| `PGRST_ADMIN_SERVER_PORT` | integer | Both | Port for the PostgREST admin server. The admin server is disabled unless a port is set, and it must differ from `PGRST_SERVER_PORT`. | No default (admin server disabled when unset) | +| `PGRST_APP_SETTINGS_*` | string | Self-hosted | Arbitrary settings exposed to PostgreSQL via `current_setting('app.settings.')`. The suffix after `PGRST_APP_SETTINGS_` becomes the setting name (case-insensitive). | Used for `PGRST_APP_SETTINGS_JWT_SECRET` and `PGRST_APP_SETTINGS_JWT_EXP` in self-hosted | +| `PGRST_CLIENT_ERROR_VERBOSITY` | enum | Self-hosted | Controls verbosity of client-facing error responses. | Default: `verbose` (other value: `minimal`) | +| `PGRST_DB_AGGREGATES_ENABLED` | boolean | Self-hosted | Allows the use of aggregate functions (`max`, `sum`, etc.) in queries. Disabled by default due to potential performance risks. | Default: `false` | +| `PGRST_DB_ANON_ROLE` | string | Both | Database role used for unauthenticated requests. When unset, anonymous access is blocked. | No default | +| `PGRST_DB_CHANNEL` | string | Self-hosted | Postgres `NOTIFY` channel name used for schema cache and config reloading. | Default: `pgrst` | +| `PGRST_DB_CHANNEL_ENABLED` | boolean | Self-hosted | Enables the Postgres `NOTIFY` listener channel. Disable when running behind a transaction-pooling connection pooler. | Default: `true` | +| `PGRST_DB_CONFIG` | boolean | Self-hosted | Enables loading in-database configuration via `db-pre-config` and role settings. | Default: `true` | +| `PGRST_DB_EXTRA_SEARCH_PATH` | string (CSV) | Both | Comma-separated list of extra schemas added to the `search_path` of every request. Schemas listed here do not get API endpoints. | Default: `public` | +| `PGRST_DB_HOISTED_TX_SETTINGS` | string (CSV) | Self-hosted | Comma-separated list of settings allowed to be applied as transaction-scoped function settings. | Default: `statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation` | +| `PGRST_DB_MAX_ROWS` | integer (count) | Both | Hard limit on the number of rows PostgREST returns for any table, view, or function; bounds payload size against accidental or malicious queries. | No default (unlimited); alias `PGRST_MAX_ROWS` | +| `PGRST_DB_PLAN_ENABLED` | boolean | Self-hosted | Allows clients to request the query execution plan with `Accept: application/vnd.pgrst.plan`. | Default: `false` | +| `PGRST_DB_POOL` | integer (count) | Self-hosted | Maximum number of database connections kept open in PostgREST's pool. | Default: `10` | +| `PGRST_DB_POOL_ACQUISITION_TIMEOUT` | integer (seconds) | Self-hosted | Time in seconds a request waits for a free connection from the pool. | Default: `10` | +| `PGRST_DB_POOL_AUTOMATIC_RECOVERY` | boolean | Self-hosted | Enables automatic retrying on connection loss. When disabled, PostgREST terminates after losing the database connection. | Default: `true` | +| `PGRST_DB_POOL_MAX_IDLETIME` | integer (seconds) | Self-hosted | Time in seconds after which idle pool connections are closed. | Default: `30`; alias `PGRST_DB_POOL_TIMEOUT` | +| `PGRST_DB_POOL_MAX_LIFETIME` | integer (seconds) | Self-hosted | Maximum lifetime in seconds of a connection in the pool before it is recycled. | Default: `1800` | +| `PGRST_DB_POOL_TIMEOUT` | integer (seconds) | Self-hosted | Deprecated alias for `PGRST_DB_POOL_MAX_IDLETIME`. | Deprecated; use `PGRST_DB_POOL_MAX_IDLETIME` | +| `PGRST_DB_PRE_CONFIG` | string | Self-hosted | Schema-qualified function name used for in-database configuration. | No default | +| `PGRST_DB_PRE_REQUEST` | string | Self-hosted | Schema-qualified function executed right after transaction settings are set, on every request. | No default; alias `PGRST_PRE_REQUEST` | +| `PGRST_DB_PREPARED_STATEMENTS` | boolean | Self-hosted | Enables prepared statements. Disable only when running behind an external connection pooler in transaction pooling mode. | Default: `true` | +| `PGRST_DB_ROOT_SPEC` | string | Self-hosted | Schema-qualified function used to override the OpenAPI response at the API root. | No default; alias `PGRST_ROOT_SPEC` | +| `PGRST_DB_SCHEMA` | string (CSV) | Self-hosted | Deprecated alias for `PGRST_DB_SCHEMAS`. | Deprecated; use `PGRST_DB_SCHEMAS` | +| `PGRST_DB_SCHEMAS` | string (CSV) | Both | Comma-separated list of database schemas exposed by the REST API. `pg_catalog` and `information_schema` are not allowed. | Default: `public` | +| `PGRST_DB_TIMEZONE_ENABLED` | boolean | Self-hosted | Enables the `Prefer: timezone` header for querying `pg_timezone_names`. | Default: `true` | +| `PGRST_DB_TX_END` | enum | Self-hosted | Controls how database transactions are terminated. Allowed values: `commit`, `commit-allow-override`, `rollback`, `rollback-allow-override`. | Default: `commit` | +| `PGRST_DB_URI` | URL | Both | PostgreSQL connection string (URI or key/value). Prefix with `@` to load from a file. Defaults read libpq env vars. | Default: `postgresql://`; required | +| `PGRST_DB_USE_LEGACY_GUCS` | boolean | Self-hosted | Toggles legacy text-based GUCs versus JSON GUCs for request context. | Deprecated; removed in PostgREST v12 (still set in self-hosted docker-compose) | +| `PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted while loading the schema cache. | Internal; no default | +| `PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted during schema cache query. | Internal; no default | +| `PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP` | integer (ms) | Self-hosted | Internal test hook: sleep (ms) inserted while loading schema cache relationships. | Internal; no default | +| `PGRST_JWT_AUD` | string | Self-hosted | Expected value of the `aud` claim in JWTs. Must be a string or valid URI. | No default | +| `PGRST_JWT_CACHE_MAX_ENTRIES` | integer (count) | Self-hosted | Maximum entries in the JWT validation cache. Set to `0` to disable caching. | Default: `1000` | +| `PGRST_JWT_ROLE_CLAIM_KEY` | string | Self-hosted | JSPath expression locating the role claim inside the JWT. | Default: `.role`; alias `PGRST_ROLE_CLAIM_KEY` | +| `PGRST_JWT_SECRET` | string | Both | Secret, JWK, or JWKS used to verify JWTs. Must be at least 32 characters for symmetric secrets. Prefix with `@` to load from a file. | No default | +| `PGRST_JWT_SECRET_IS_BASE64` | boolean | Self-hosted | Treats `PGRST_JWT_SECRET` as base64-encoded. | Default: `false`; alias `PGRST_SECRET_IS_BASE64` | +| `PGRST_LOG_LEVEL` | enum | Self-hosted | Logging level. Allowed values: `crit`, `error`, `warn`, `info`, `debug`. | Default: `error` | +| `PGRST_LOG_QUERY` | boolean | Self-hosted | Logs the SQL query for each request at the current log level. | Default: `false` | +| `PGRST_MAX_ROWS` | integer (count) | Self-hosted | Deprecated alias for `PGRST_DB_MAX_ROWS`. | Deprecated; use `PGRST_DB_MAX_ROWS` | +| `PGRST_OPENAPI_MODE` | enum | Self-hosted | Controls OpenAPI output. Allowed values: `follow-privileges`, `ignore-privileges`, `disabled`. | Default: `follow-privileges` | +| `PGRST_OPENAPI_SECURITY_ACTIVE` | boolean | Self-hosted | Includes security definitions in the OpenAPI output. | Default: `false` | +| `PGRST_OPENAPI_SERVER_PROXY_URI` | URL | Self-hosted | Overrides the base URL in the OpenAPI self-documentation (useful behind a proxy). | No default | +| `PGRST_PRE_REQUEST` | string | Self-hosted | Deprecated alias for `PGRST_DB_PRE_REQUEST`. | Deprecated; use `PGRST_DB_PRE_REQUEST` | +| `PGRST_ROLE_CLAIM_KEY` | string | Self-hosted | Deprecated alias for `PGRST_JWT_ROLE_CLAIM_KEY`. | Deprecated; use `PGRST_JWT_ROLE_CLAIM_KEY` | +| `PGRST_ROOT_SPEC` | string | Self-hosted | Deprecated alias for `PGRST_DB_ROOT_SPEC`. | Deprecated; use `PGRST_DB_ROOT_SPEC` | +| `PGRST_SECRET_IS_BASE64` | boolean | Self-hosted | Deprecated alias for `PGRST_JWT_SECRET_IS_BASE64`. | Deprecated; use `PGRST_JWT_SECRET_IS_BASE64` | +| `PGRST_SERVER_CORS_ALLOWED_ORIGINS` | string (CSV) | Self-hosted | Comma-separated list of allowed CORS origins. When empty or unset, all origins are accepted. | No default | +| `PGRST_SERVER_HOST` | string | Self-hosted | Address the PostgREST web server binds to. Special values: `*` (any), `*4` (IPv4-preferred), `!4` (IPv4-only), `*6` (IPv6-preferred), `!6` (IPv6-only). | Default: `!4` | +| `PGRST_SERVER_PORT` | integer | Self-hosted | TCP port the PostgREST web server binds to. Use `0` to auto-assign. | Default: `3000` | +| `PGRST_SERVER_TIMING_ENABLED` | boolean | Self-hosted | Enables the `Server-Timing` HTTP response header. | Default: `false` | +| `PGRST_SERVER_TRACE_HEADER` | string | Self-hosted | HTTP header name used to trace requests (e.g. `X-Request-Id`). | No default | +| `PGRST_SERVER_UNIX_SOCKET` | path | Self-hosted | Path to a Unix domain socket the server binds to. Takes precedence over `PGRST_SERVER_PORT` when set. | No default | +| `PGRST_SERVER_UNIX_SOCKET_MODE` | string | Self-hosted | Octal file mode applied to the Unix socket. Must be between `600` and `777`. | Default: `660` | + +--- + +## Realtime + +> Realtime's upstream env-var reference is at [supabase/realtime ENVS.md](https://github.com/supabase/realtime/blob/main/ENVS.md). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `API_JWT_JWKS` | JWKS | Both | JSON Web Key Set used to verify tenant JWTs during self-host seeding. Read by `priv/repo/seeds.exs` and `priv/repo/dev_seeds.exs`. | Used only by the seed script (`SEED_SELF_HOST=true`). Required when using the new API keys and new auth. | +| `API_JWT_SECRET` | string | Both | Symmetric HS256 secret used to sign tokens for the tenant management API and the default self-host tenant. | Required for the tenant management API in production. | +| `API_TOKEN_BLOCKLIST` | string (CSV) | Self-hosted | Comma-separated list of tokens blocked from tenant management API access. | Default: empty list. | +| `APP_NAME` | string | Both | Application/node name. Used to build the Phoenix endpoint URL host, libcluster DNS basename, and Erlang `RELEASE_NODE`. | Required - raises `APP_NAME not available` if empty. Default: empty (build) / `realtime` (Erlang release script). | +| `BROADCAST_POOL_SIZE` | integer (count) | Self-hosted | Number of processes used to relay Phoenix.PubSub messages across the cluster. | Default: `10`. | +| `CHANNEL_ERROR_BACKOFF_MS` | integer (ms) | Self-hosted | Delay (ms) before returning a channel join error to the client. Slows down reconnect storms. | Default: `5000` (5 seconds). | +| `CLIENT_PRESENCE_MAX_CALLS` | integer (count) | Self-hosted | Maximum presence calls allowed per client (per WebSocket) within the time window. | Default: `5`. | +| `CLIENT_PRESENCE_WINDOW_MS` | integer (ms) | Self-hosted | Time window (ms) for per-client presence rate limiting. | Default: `30000`. | +| `CLUSTER` | string | Self-hosted | Cluster name added to log metadata. | No default. Read by `Realtime.Application.start/2`. | +| `CLUSTER_SECRET_ID` | string | Self-hosted | AWS Secrets Manager secret ID holding the cluster CA cert/key. | Used by `run.sh` `generate_certs` when `GENERATE_CLUSTER_CERTS` is set. | +| `CLUSTER_SECRET_REGION` | string | Self-hosted | AWS region for `CLUSTER_SECRET_ID`. | Used by `run.sh` `generate_certs` when `GENERATE_CLUSTER_CERTS` is set. | +| `CLUSTER_STRATEGIES` | string (CSV) | Self-hosted | Comma-separated list of libcluster backends to enable. Supported: `EPMD`, `DNS`, `POSTGRES`. | Default: `EPMD` outside production, `POSTGRES` in production. | +| `CONNECT_ERROR_BACKOFF_MS` | integer (ms) | Self-hosted | Delay (ms) before returning a WebSocket connection error to the client. Slows down reconnect storms. | Default: `2000` (2 seconds). | +| `CONNECT_PARTITION_SLOTS` | integer (count) | Self-hosted | Number of dynamic supervisor partitions for the `Connect` / `ReplicationConnect` processes. | Default: `System.schedulers_online() * 2`. | +| `DASHBOARD_AUTH` | enum | Self-hosted | Authentication method for the admin dashboard (`/admin`). Accepted: `basic_auth` (requires `DASHBOARD_USER` and `DASHBOARD_PASSWORD`) or `zta` (requires `CF_TEAM_DOMAIN`). | Default: `basic_auth`. | +| `DASHBOARD_PASSWORD` | string | Self-hosted | Password for admin dashboard basic auth. | Default: random hex string generated at boot. | +| `DASHBOARD_USER` | string | Self-hosted | Username for admin dashboard basic auth. | Default: random hex string generated at boot. | +| `DB_AFTER_CONNECT_QUERY` | string | Both | SQL query executed after every Postgres connection is established. | No default. Self-host sets `SET search_path TO _realtime`. | +| `DB_ENC_KEY` | string | Both | Key used to encrypt sensitive fields in the `_realtime.tenants` and `_realtime.extensions` tables. | Recommended: 16 characters. Required (consumed as `db_enc_key` by the app config). | +| `DB_HOST` | string | Both | Primary Postgres host. | Default: `127.0.0.1`. | +| `DB_IP_VERSION` | enum | Self-hosted | Forces the IP version for Postgres connections. Accepted: `ipv4`, `ipv6`. | When unset, IP version is auto-detected from `DB_HOST`. | +| `DB_MASTER_REGION` | string | Self-hosted | Overrides the primary region for region-aware routing and tenant placement. | When unset, the current `REGION` is used. | +| `DB_NAME` | string | Both | Postgres database name. | Default: `postgres`. | +| `DB_PASSWORD` | string | Both | Postgres password. | Default: `postgres`. | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Number of connections in the primary Postgres pool. | Default: `5`. | +| `DB_PORT` | string | Both | Postgres port. | Default: `5432`. | +| `DB_QUEUE_INTERVAL` | integer (ms) | Self-hosted | Ecto pool queue interval in ms. | Default: `5000`. | +| `DB_QUEUE_TARGET` | integer (ms) | Self-hosted | Ecto pool queue target in ms. | Default: `5000`. | +| `DB_REPLICA_HOST` | string | Self-hosted | Hostname for the main replica Postgres pool. | When set, enables the `Realtime.Repo.Replica` connection pool. | +| `DB_REPLICA_POOL_SIZE` | integer (count) | Self-hosted | Number of connections in the replica pool(s). | Default: `5`. | +| `DB_SSL` | boolean | Self-hosted | Enable SSL for Postgres connections. | Default: `false`. Accepts `true`/`false`/`1`/`0`. | +| `DB_SSL_CA_CERT` | path | Self-hosted | Path to a CA trust store used when `DB_SSL=true`. Enables server certificate verification. | When unset and `DB_SSL=true`, falls back to `verify: :verify_none`. | +| `DB_USER` | string | Both | Postgres user. | Default: `supabase_admin`. | +| `DISABLE_HEALTHCHECK_LOGGING` | boolean | Self-hosted | Disables request logging for `/healthcheck` and `/api/tenants/:tenant_id/health`. | Default: `false`. | +| `DNS_NODES` | string | Both | DNS query used by the libcluster `DNS` strategy. | No default. Only consulted when `CLUSTER_STRATEGIES` contains `DNS`. | +| `HTTP_DYNAMIC_BUFFER_MAX` | integer (bytes) | Self-hosted | Maximum buffer size (bytes) for HTTP connections (Cowboy dynamic buffer). | Must be set together with `HTTP_DYNAMIC_BUFFER_MIN`. | +| `HTTP_DYNAMIC_BUFFER_MIN` | integer (bytes) | Self-hosted | Minimum buffer size (bytes) for HTTP connections (Cowboy dynamic buffer). | Must be set together with `HTTP_DYNAMIC_BUFFER_MAX`. | +| `JANITOR_CHILDREN_TIMEOUT` | integer (ms) | Self-hosted | Timeout (ms) for each janitor child task. | Default: `5000`. Only used when `RUN_JANITOR=true`. | +| `JANITOR_CHUNK_SIZE` | integer (count) | Self-hosted | Number of tenants processed per chunk per janitor task. | Default: `10`. | +| `JANITOR_MAX_CHILDREN` | integer (count) | Self-hosted | Maximum number of concurrent janitor task children. | Default: `5`. | +| `JANITOR_RUN_AFTER_IN_MS` | integer (ms) | Self-hosted | Delay (ms) before the janitor first runs after boot. | Default: 10 minutes. | +| `JANITOR_SCHEDULE_RANDOMIZE` | boolean | Self-hosted | Add a random offset to the janitor schedule. | Default: `true`. | +| `JANITOR_SCHEDULE_TIMER_IN_MS` | integer (ms) | Self-hosted | Interval (ms) between janitor runs. | Default: 4 hours. | +| `JWT_CLAIM_VALIDATORS` | JSON | Self-hosted | JSON object of claim validators applied to incoming JWTs (e.g. `{"iss":"Issuer"}`). | Default: `{}`. Must be valid JSON object or boot fails. | +| `LOG_LEVEL` | enum | Self-hosted | Logger level. One of `info`, `emergency`, `alert`, `critical`, `error`, `warning`, `notice`, `debug`. | Default: `info`. | +| `LOG_THROTTLE_JANITOR_INTERVAL_IN_MS` | integer (ms) | Self-hosted | Cachex expiration interval (ms) for the log-throttle cache. | Default: 10 minutes. | +| `LOGFLARE_API_KEY` | string | Self-hosted | Logflare API key. | Required when `LOGS_ENGINE=logflare`. | +| `LOGFLARE_LOGGER_BACKEND_URL` | URL | Self-hosted | Endpoint for the Logflare logger backend. | Default: `https://api.logflare.app`. | +| `LOGFLARE_SOURCE_ID` | string | Self-hosted | Logflare source ID. | Required when `LOGS_ENGINE=logflare`. | +| `LOGS_ENGINE` | string | Self-hosted | Log backend selector. Set to `logflare` to enable the Logflare HTTP backend. | When unset, standard logger output is used. | +| `MAX_CONNECTIONS` | integer (count) | Self-hosted | Soft maximum number of WebSocket connections. | Default: `16384`. | +| `MAX_HEADER_LENGTH` | integer (bytes) | CLI | Maximum HTTP header value length (bytes). | Default: `4096`. | +| `METRICS_CLEANER_SCHEDULE_TIMER_IN_MS` | integer (ms) | Self-hosted | Interval (ms) between metrics cleaner runs. | Default: 30 minutes. | +| `METRICS_JWT_SECRET` | string | Both | Secret used to sign JWTs for the metrics endpoints. | Required - the app raises an exception if unset. | +| `METRICS_PUSHER_AUTH` | string | Self-hosted | Password used for Basic auth on metrics pushes. Used together with `METRICS_PUSHER_USER`. | When unset, requests are sent without authorization. | +| `METRICS_PUSHER_COMPRESS` | boolean | Self-hosted | Enable gzip compression for metrics payloads. | Default: `true`. | +| `METRICS_PUSHER_ENABLED` | boolean | Self-hosted | Enable periodic push of Prometheus metrics. | Default: `false`. Requires `METRICS_PUSHER_URL`. | +| `METRICS_PUSHER_EXTRA_LABELS` | string (CSV) | Self-hosted | Comma-separated `key=value` pairs appended as `extra_label` query parameters on every push. | Default: empty. | +| `METRICS_PUSHER_INTERVAL_MS` | integer (ms) | Self-hosted | Interval (ms) between metrics pushes. | Default: 30 seconds. | +| `METRICS_PUSHER_TIMEOUT_MS` | integer (ms) | Self-hosted | HTTP timeout (ms) for metrics push requests. | Default: 15 seconds. | +| `METRICS_PUSHER_URL` | URL | Self-hosted | Full URL endpoint to push metrics in Prometheus exposition format. | Required when `METRICS_PUSHER_ENABLED=true`. | +| `METRICS_PUSHER_USER` | string | Self-hosted | Username used for Basic auth on metrics pushes. | Default: `realtime`. | +| `METRICS_RPC_TIMEOUT_IN_MS` | integer (ms) | Self-hosted | Timeout (ms) for RPC calls that fetch metrics from other nodes. | Default: 15 seconds. | +| `METRICS_TOKEN_BLOCKLIST` | string (CSV) | Self-hosted | Comma-separated list of tokens blocked from accessing the metrics endpoints. | Default: empty list. | +| `PORT` | integer | Both | HTTP listener port. | Default: `4000`. | +| `PROM_POLL_RATE` | integer (ms) | Self-hosted | Poll interval (ms) for PromEx metrics collection. | Default: `5000`. | +| `REALTIME_IP_VERSION` | enum | Self-hosted | Forces the HTTP listener IP version. Accepted: `ipv4`, `ipv6`. | When unset, IPv6 is preferred when available. | +| `REBALANCE_CHECK_INTERVAL_IN_MS` | integer (ms) | Self-hosted | Interval (ms) used to check whether a process is in the right region. | Default: 10 minutes. | +| `REGION` | string | Self-hosted | Region name for the current node. Used in logs, latency reporting, and region-aware routing. | No default. Also rendered in the admin dashboard layout. | +| `REGION_MAPPING` | JSON | Self-hosted | Custom mapping of platform regions to tenant regions, as a JSON object with string keys and values. | When unset, the hardcoded default mapping is used. Must be a JSON object or boot fails. | +| `REQUEST_ID_BAGGAGE_KEY` | string | Self-hosted | OTEL Baggage key used as the request ID. | Default: `request-id`. | +| `RPC_TIMEOUT` | integer (ms) | Self-hosted | Timeout (ms) for generic RPC calls. | Default: 30 seconds. | +| `RUN_JANITOR` | boolean | Both | Enable the tenant janitor and metrics cleaner tasks. | Default: `false`. | +| `SECRET_KEY_BASE` | string | Both | Secret used by Phoenix to sign cookies and tokens. | Required - recommended length: 64 characters. | +| `SEED_SELF_HOST` | boolean | Both | If `true`, `run.sh` runs `Realtime.Release.seeds/1` to create the default tenant. | Default: not set (no seeding). Self-host enables this on first boot. | +| `SELF_HOST_TENANT_NAME` | string | Self-hosted | Tenant external_id used by the self-host seed script. | Default: `realtime-dev`. Must be URL-safe. | +| `SLOT_NAME_SUFFIX` | string | CLI | Suffix appended to the default replication slot name `supabase_realtime_replication_slot`. | Allowed: lowercase letters, numbers, underscore. Combined name must be 64 characters or fewer. | +| `TENANT_CACHE_EXPIRATION_IN_MS` | integer (ms) | Self-hosted | TTL (ms) for the in-process tenant cache. | Default: 30 seconds. | +| `TENANT_MAX_BYTES_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum bytes per second (used when a tenant is first created). | Default: `100000`. | +| `TENANT_MAX_CHANNELS_PER_CLIENT` | integer (count) | Self-hosted | Default per-tenant maximum channels per client (used when a tenant is first created). | Default: `100`. | +| `TENANT_MAX_CONCURRENT_USERS` | integer (count) | Self-hosted | Default per-tenant maximum concurrent users per channel (used when a tenant is first created). | Default: `200`. | +| `TENANT_MAX_EVENTS_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum events per second (used when a tenant is first created). | Default: `100`. | +| `TENANT_MAX_JOINS_PER_SECOND` | integer (count) | Self-hosted | Default per-tenant maximum channel joins per second (used when a tenant is first created). | Default: `100`. | +| `USERS_SCOPE_SHARDS` | integer (count) | Self-hosted | Number of partitions used by the Beacon `users` scope. | Default: `5`. | +| `WEBSOCKET_MAX_HEAP_SIZE` | integer (bytes) | Self-hosted | Maximum heap (bytes) for each WebSocket transport process; the process is killed if exceeded. | Default: `50000000` (50 MB). | + +--- + +## Storage + +### Server + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADMIN_API_KEYS` | string | | Comma-separated API keys accepted on the admin port. Legacy alias for `SERVER_ADMIN_API_KEYS`. | Default: empty | +| `ADMIN_PORT` | integer | | Port the admin HTTP server listens on. Legacy alias for `SERVER_ADMIN_PORT`. | Default: `5001` | +| `EXPOSE_DOCS` | boolean | | Expose `/docs` Swagger UI. | Default: `true` | +| `HOST` | string | | Host the public server binds to. Legacy alias for `SERVER_HOST`. | Default: `0.0.0.0` | +| `NODE_ENV` | enum | | Node.js runtime mode. When `production`, sets `isProduction` and forces HTTPS in TUS link generation. | Default: unset | +| `PORT` | integer | | Port the public HTTP server listens on. Legacy alias for `SERVER_PORT`. | Default: `5000` | +| `PROJECT_REF` | string | | Single-tenant project reference; used as `tenantId` when set. | Optional (single-tenant) | +| `REGION` | string | Self-hosted | Region label exposed in responses and used as fallback for `STORAGE_S3_REGION` / `SERVER_REGION`. | Default: `not-specified` | +| `REQUEST_ADMIN_TRACE_HEADER` | string | | Header carrying the admin request trace id. Legacy fallback for `REQUEST_TRACE_HEADER`. | Optional | +| `REQUEST_ALLOW_X_FORWARDED_PATH` | boolean | Self-hosted | Honor the `X-Forwarded-Path` header when computing public URLs. | Default: `false` | +| `REQUEST_ETAG_HEADERS` | string (CSV) | | Comma-separated list of request headers that carry an ETag for conditional GETs. | Default: `if-none-match` | +| `REQUEST_ID_HEADER` | string | | Legacy alias for `REQUEST_TRACE_HEADER`. | Optional | +| `REQUEST_TRACE_HEADER` | string | | Header name used to propagate the request trace id. | Default: unset | +| `REQUEST_URL_LENGTH_LIMIT` | integer | | Maximum object key URL length. | Default: `7500` | +| `REQUEST_X_FORWARDED_HOST_REGEXP` | string (regex) | | Regex applied to `X-Forwarded-Host` to derive the tenant id. | Optional | +| `RESPONSE_S_MAXAGE` | integer (seconds) | | `s-maxage` (CDN) cache lifetime added to public responses (seconds). | Default: `0` | +| `SERVER_ADMIN_API_KEYS` | string | | Comma-separated API keys accepted on the admin port. | Default: empty | +| `SERVER_ADMIN_PORT` | integer | | Port the admin HTTP server listens on. | Default: `5001` | +| `SERVER_HEADERS_TIMEOUT` | integer (seconds) | | Node `headersTimeout` (seconds) for the HTTP server. | Default: `65` | +| `SERVER_HOST` | string | | Host the public server binds to. | Default: `0.0.0.0` | +| `SERVER_KEEP_ALIVE_TIMEOUT` | integer (seconds) | | Node `keepAliveTimeout` (seconds) for the HTTP server. | Default: `61` | +| `SERVER_PORT` | integer | | Port the public HTTP server listens on. | Default: `5000` | +| `SERVER_REGION` | string | | Region label exposed in responses; falls back to `REGION`. | Default: `not-specified` | +| `STORAGE_PUBLIC_URL` | URL | Self-hosted | Public base URL prepended to generated object URLs. | Optional | +| `TENANT_ID` | string | Self-hosted | Single-tenant tenant id (fallback after `PROJECT_REF`). | Default: `storage-single-tenant` | +| `URL_LENGTH_LIMIT` | integer | | Legacy alias for `REQUEST_URL_LENGTH_LIMIT`. | Default: `7500` | +| `VERSION` | string | | Build version reported in logs and the default DB application name. | Default: `0.0.0` | +| `WORKERS_NUM` | integer | | Number of cluster workers to spawn. | Default: `1` | +| `X_FORWARDED_HOST_REGEXP` | string (regex) | | Legacy alias for `REQUEST_X_FORWARDED_HOST_REGEXP`. | Optional | + +### Database + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DATABASE_APPLICATION_NAME` | string | | Postgres `application_name` for the API connection pool. | Default: `Supabase Storage API ${VERSION}` | +| `DATABASE_CONNECTION_TIMEOUT` | integer (ms) | | Postgres connection acquire timeout (ms). | Default: `3000` | +| `DATABASE_ENABLE_QUERY_CANCELLATION` | boolean | | Issue a Postgres cancel on request abort. | Default: `false` | +| `DATABASE_FREE_POOL_AFTER_INACTIVITY` | integer (ms) | | Time (ms) after which an idle tenant pool is released. | Default: `60000` | +| `DATABASE_MAX_CONNECTIONS` | integer | | Max connections per tenant pool. Ignored when `DATABASE_POOL_URL` is set. | Default: `20` | +| `DATABASE_POOL_MODE` | enum | | `single_use` or `recycle`. | Optional | +| `DATABASE_POOL_URL` | URL | | External pooler (Supavisor/PgBouncer) connection string. When set, `DATABASE_MAX_CONNECTIONS` is ignored. | Optional | +| `DATABASE_POSTGRES_VERSION` | string | | Override the detected Postgres version string. | Optional | +| `DATABASE_SEARCH_PATH` | string (CSV) | | Comma-separated `search_path` prepended to every session. | Default: empty | +| `DATABASE_SSL_ROOT_CERT` | path | | PEM bundle used to verify the Postgres server certificate. | Optional | +| `DATABASE_STATEMENT_TIMEOUT` | integer (ms) | | Postgres `statement_timeout` (ms) applied per session. | Default: `30000` | +| `DATABASE_URL` | URL | Both | Primary Postgres connection string used by the API. | Required (single-tenant) | +| `DB_ALLOW_MIGRATION_REFRESH` | boolean | | Allow refreshing migration hashes when the hash recorded in the DB diverges. | Default: `true` | +| `DB_ANON_ROLE` | string | | Postgres role used when authenticating as anonymous. | Default: `anon` | +| `DB_AUTHENTICATED_ROLE` | string | | Postgres role used for authenticated requests. | Default: `authenticated` | +| `DB_INSTALL_ROLES` | boolean | | Run role install migrations on boot. | Default: `false` | +| `DB_MIGRATIONS_FREEZE_AT` | string | CLI | Stop applying migrations after the named migration. | Optional | +| `DB_SEARCH_PATH` | string (CSV) | | Legacy alias for `DATABASE_SEARCH_PATH`. | Default: empty | +| `DB_SERVICE_ROLE` | string | | Postgres role used by the service-role key. | Default: `service_role` | +| `DB_SUPER_USER` | string | | Postgres superuser used for migrations. | Default: `postgres` | +| `TENANT_POOL_CACHE_HIT_LOG_SAMPLE_RATE` | number (ratio) | | Sample rate (0-1) for logging tenant-pool cache hits. | Default: `0` | +| `TENANT_POOL_CACHE_MISS_LOG_SAMPLE_RATE` | number (ratio) | | Sample rate (0-1) for logging tenant-pool cache misses. | Default: `0` | +| `TENANT_POOL_CACHE_TTL_MS` | integer (ms) | | TTL (ms) for the per-tenant connection-pool cache. | Default: `10000` | + +### JWT + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AUTH_JWT_ALGORITHM` | enum | | JWT algorithm used to verify tokens. | Default: `HS256` | +| `AUTH_JWT_SECRET` | string | Both | HS256 secret used to verify the legacy `ANON_KEY` / `SERVICE_KEY`. | Required (single-tenant) | +| `JWT_CACHING_ENABLED` | boolean | | Cache decoded JWTs in memory to reduce verification cost. | Default: `false` | +| `JWT_JWKS` | JWKS | Both | JSON Web Key Set used to verify asymmetric JWTs (e.g. ES256). | Required when using the new API keys and new auth. | +| `PGRST_JWT_ALGORITHM` | enum | | Legacy alias for `AUTH_JWT_ALGORITHM`. | Default: `HS256` | +| `PGRST_JWT_SECRET` | string | | JWT secret used by Storage to verify Postgres-issued tokens; legacy alias for `AUTH_JWT_SECRET`. | Required (single-tenant) | + +### Auth + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ANON_KEY` | JWT | Both | Anon JWT served to public clients. Auto-generated from `AUTH_JWT_SECRET` when blank in single-tenant mode. | Required for self-hosted single-tenant | +| `SERVICE_KEY` | JWT | Both | Service-role JWT (bypasses Row Level Security). Auto-generated from `AUTH_JWT_SECRET` when blank in single-tenant mode. | Required for self-hosted single-tenant | + +### S3 backend + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `AWS_ACCESS_KEY_ID` | string | Self-hosted | AWS access key id consumed by the AWS SDK to sign S3 requests. | Required when `STORAGE_BACKEND=s3` | +| `AWS_SECRET_ACCESS_KEY` | string | Self-hosted | AWS secret key consumed by the AWS SDK to sign S3 requests. | Required when `STORAGE_BACKEND=s3` | +| `GLOBAL_S3_BUCKET` | string | Both | S3 bucket name; legacy alias for `STORAGE_S3_BUCKET`. | Required when `STORAGE_BACKEND=s3` | +| `GLOBAL_S3_ENDPOINT` | URL | Self-hosted | Legacy alias for `STORAGE_S3_ENDPOINT`. | Optional | +| `GLOBAL_S3_FORCE_PATH_STYLE` | boolean | Self-hosted | Legacy alias for `STORAGE_S3_FORCE_PATH_STYLE`. | Default: `false` | +| `GLOBAL_S3_MAX_SOCKETS` | integer | | Legacy alias for `STORAGE_S3_MAX_SOCKETS`. | Default: `200` | +| `GLOBAL_S3_PRIVATE_ASSET_ENDPOINT` | URL | | Legacy alias for `STORAGE_S3_PRIVATE_ASSET_ENDPOINT`. | Optional | +| `S3_ALLOW_FORWARDED_HEADER` | boolean | | Honor the `Forwarded` header when reconstructing canonical request URLs for SigV4. | Default: `false` | +| `S3_PROTOCOL_ACCESS_KEY_ID` | string | Both | Static SigV4 access key id (single-tenant). | Optional | +| `S3_PROTOCOL_ACCESS_KEY_SECRET` | string | Both | Static SigV4 secret (single-tenant). | Optional | +| `S3_PROTOCOL_ENABLED` | boolean | CLI | Enable the S3-compatible API. | Default: `true` | +| `S3_PROTOCOL_ENFORCE_REGION` | boolean | | Reject SigV4 requests whose region does not match `STORAGE_S3_REGION`. | Default: `false` | +| `S3_PROTOCOL_NON_CANONICAL_HOST_HEADER` | string | | Override host used during SigV4 canonicalization. | Optional | +| `S3_PROTOCOL_PREFIX` | string | CLI | URL prefix mounted in front of the S3 protocol routes. | Default: empty | +| `STORAGE_BACKEND` | enum | Both | Object backend driver: `s3` or `file`. | Default: `file` (compose) / unset (code) | +| `STORAGE_EMPTY_BUCKET_MAX` | integer | | Max objects deletable in a single empty-bucket call. | Default: `200000` | +| `STORAGE_S3_BUCKET` | string | | Bucket name used by the S3 backend. | Required when `STORAGE_BACKEND=s3` | +| `STORAGE_S3_CLIENT_TIMEOUT` | integer (ms) | | Per-request timeout (ms) for S3 SDK calls; `0` disables. | Default: `0` | +| `STORAGE_S3_DISABLE_CHECKSUM` | boolean | | Disable S3 SDK request checksums. | Default: `false` | +| `STORAGE_S3_ENABLED_METRICS` | boolean | | Enable internal S3 client tracing/metrics. | Default: `false` | +| `STORAGE_S3_ENDPOINT` | URL | | Custom S3 endpoint (e.g. MinIO). | Optional | +| `STORAGE_S3_FORCE_PATH_STYLE` | boolean | | Use path-style S3 addressing. | Default: `false` | +| `STORAGE_S3_MAX_SOCKETS` | integer | | Max concurrent sockets for the S3 HTTP agent. | Default: `200` | +| `STORAGE_S3_PRIVATE_ASSET_ENDPOINT` | URL | | Endpoint used only when signing private source URLs for internal consumers (e.g. imgproxy). | Optional | +| `STORAGE_S3_REGION` | string | CLI | AWS region for the S3 backend; falls back to `REGION`. | Required when `STORAGE_BACKEND=s3` | +| `STORAGE_S3_UPLOAD_PART_SIZE` | integer (bytes) | | Multipart upload part size in bytes. Values below the 5 MiB S3 minimum are clamped up. | Default: `16777216` (16 MiB); minimum: `5242880` (5 MiB) | +| `STORAGE_S3_UPLOAD_QUEUE_SIZE` | integer | | Concurrent part uploads per multipart object. | Default: `2` | + +### File backend + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `FILE_STORAGE_BACKEND_PATH` | path | Both | Filesystem path for the `file` backend; legacy alias for `STORAGE_FILE_BACKEND_PATH`. | Required when `STORAGE_BACKEND=file` | +| `STORAGE_FILE_BACKEND_PATH` | path | | Filesystem directory used by the `file` backend. | Required when `STORAGE_BACKEND=file` | +| `STORAGE_FILE_ETAG_ALGORITHM` | enum | | ETag algorithm for the `file` backend: `md5` or `mtime`. | Default: `md5` | + +### Image transformation + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLE_IMAGE_TRANSFORMATION` | boolean | Both | Legacy alias for `IMAGE_TRANSFORMATION_ENABLED`. | Default: `false` | +| `IMAGE_TRANSFORMATION_ENABLED` | boolean | | Enable image rendering via imgproxy. | Default: `false` | +| `IMAGE_TRANSFORMATION_LIMIT_MAX_SIZE` | integer | | Max requested dimension (px) for transformations. | Default: `2000` | +| `IMAGE_TRANSFORMATION_LIMIT_MIN_SIZE` | integer | | Min requested dimension (px) for transformations. | Default: `1` | +| `IMGPROXY_HTTP_KEEP_ALIVE_TIMEOUT` | integer (seconds) | | Keep-alive timeout (seconds) for the imgproxy HTTP agent. | Default: `61` | +| `IMGPROXY_HTTP_MAX_SOCKETS` | integer | | Max concurrent sockets for the imgproxy HTTP agent. | Default: `5000` | +| `IMGPROXY_REQUEST_TIMEOUT` | integer (seconds) | | Request timeout (seconds) for imgproxy calls. | Default: `15` | +| `IMGPROXY_URL` | URL | Both | imgproxy base URL. | Required when image transformation is enabled | +| `IMG_LIMITS_MAX_SIZE` | integer | | Legacy alias for `IMAGE_TRANSFORMATION_LIMIT_MAX_SIZE`. | Default: `2000` | +| `IMG_LIMITS_MIN_SIZE` | integer | | Legacy alias for `IMAGE_TRANSFORMATION_LIMIT_MIN_SIZE`. | Default: `1` | + +### Upload limits + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `FILE_SIZE_LIMIT` | integer (bytes) | Both | Maximum upload file size; legacy alias for `UPLOAD_FILE_SIZE_LIMIT`. | Required | +| `FILE_SIZE_LIMIT_STANDARD_UPLOAD` | integer (bytes) | | Legacy alias for `UPLOAD_FILE_SIZE_LIMIT_STANDARD`. | Default: `0` (disabled) | +| `SIGNED_UPLOAD_URL_EXPIRATION_TIME` | integer (seconds) | CLI | Legacy alias for `UPLOAD_SIGNED_URL_EXPIRATION_TIME`. | Default: `60` | +| `TUS_ALLOW_S3_TAGS` | boolean | | Propagate user metadata as S3 tags during TUS uploads. | Default: `true` | +| `TUS_LOCK_TYPE` | enum | | TUS upload lock backend: `postgres` or `s3`. | Default: `postgres` | +| `TUS_MAX_CONCURRENT_UPLOADS` | integer | | Max concurrent TUS upload sessions. | Default: `500` | +| `TUS_PART_SIZE` | integer (MB) | | TUS multipart part size (MB). | Default: `50` | +| `TUS_URL_EXPIRY_MS` | integer (ms) | | TUS upload-URL expiry (ms). | Default: `3600000` (1h) | +| `TUS_URL_PATH` | path | CLI | Path mount for TUS resumable uploads. | Default: `/upload/resumable` | +| `TUS_USE_FILE_VERSION_SEPARATOR` | boolean | | Include the object version in TUS storage keys. | Default: `false` | +| `UPLOAD_FILE_SIZE_LIMIT` | integer (bytes) | CLI | Max upload size in bytes. | Required | +| `UPLOAD_FILE_SIZE_LIMIT_STANDARD` | integer (bytes) | CLI | Max size in bytes for non-resumable uploads. | Default: `0` (disabled) | +| `UPLOAD_SIGNED_URL_EXPIRATION_TIME` | integer (seconds) | | Default lifetime (seconds) of signed upload URLs. | Default: `60` | + +### Rate limiting + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ENABLE_RATE_LIMITER` | boolean | | Legacy alias for `RATE_LIMITER_ENABLED`. | Default: `false` | +| `RATE_LIMITER_DRIVER` | enum | | Rate limiter backend: `memory` or `redis`. | Default: `memory` | +| `RATE_LIMITER_ENABLED` | boolean | | Enable the image-transformation rate limiter. | Default: `false` | +| `RATE_LIMITER_REDIS_COMMAND_TIMEOUT` | integer (seconds) | | Per-command timeout (seconds) when using the Redis driver. | Default: `2` | +| `RATE_LIMITER_REDIS_CONNECT_TIMEOUT` | integer (seconds) | | Connect timeout (seconds) when using the Redis driver. | Default: `2` | +| `RATE_LIMITER_REDIS_URL` | URL | | Redis connection URL. | Required when `RATE_LIMITER_DRIVER=redis` | +| `RATE_LIMITER_RENDER_PATH_MAX_REQ_SEC` | integer | | Max requests per second on render paths. | Default: `5` | +| `RATE_LIMITER_SKIP_ON_ERROR` | boolean | | Allow requests through when the rate limiter errors. | Default: `false` | + +### Webhook + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `WEBHOOK_API_KEY` | string | | Bearer key sent with outbound webhooks. | Optional | +| `WEBHOOK_QUEUE_PULL_INTERVAL` | integer (ms) | | Polling interval (ms) for the webhook queue. | Default: `700` | +| `WEBHOOK_URL` | URL | | Endpoint that receives object events. | Optional | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_API_KEY` | string | | Logflare ingest API key. | Required when `LOGFLARE_ENABLED=true` | +| `LOGFLARE_BATCH_SIZE` | integer | | Max records per Logflare batch. | Default: `200` | +| `LOGFLARE_ENABLED` | boolean | | Forward logs to Logflare. | Default: `false` | +| `LOGFLARE_SOURCE_TOKEN` | string | | Logflare source identifier. | Required when `LOGFLARE_ENABLED=true` | +| `LOG_LEVEL` | enum | | pino log level. | Default: `info` | +| `METRICS_DISABLED` | string (CSV) | | Comma-separated list of metric names (or `all`) to drop. | Optional | +| `OTEL_EXPORTER_OTLP_COMPRESSION` | enum | | OTLP exporter compression algorithm (`gzip`, `none`). | Optional | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | URL | | OTLP endpoint used when a metrics-specific endpoint is not set. | Optional | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | URL | | OTLP endpoint for metrics export. | Optional | +| `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | string (CSV) | | Comma-separated `k=v` headers attached to OTLP metric requests. | Optional | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | URL | | OTLP endpoint for trace export. | Optional | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | string (CSV) | | Comma-separated `k=v` headers attached to OTLP trace requests. | Optional | +| `OTEL_METRICS_ENABLED` | boolean | | Enable the OpenTelemetry metrics SDK. | Default: `false` | +| `OTEL_METRICS_EXPORT_INTERVAL_MS` | integer (ms) | | OTLP metrics export interval (ms). | Default: `60000` | +| `OTEL_METRICS_TEMPORALITY` | enum | | OTLP metrics temporality: `DELTA` or `CUMULATIVE`. | Default: `CUMULATIVE` | +| `PROMETHEUS_METRICS_ENABLED` | boolean | | Expose Prometheus metrics on the admin port. | Default: `false` | +| `PROMETHEUS_METRICS_INCLUDE_TENANT` | boolean | | Include the tenant id label on Prometheus metrics. | Default: `false` | +| `TRACING_ENABLED` | boolean | | Enable OpenTelemetry tracing. | Default: `false` | +| `TRACING_FEATURE_UPLOAD` | boolean | | Emit detailed spans for the upload pipeline. | Default: `false` | +| `TRACING_MODE` | enum | | Tracing verbosity, e.g. `basic`, `debug`. | Default: `basic` | +| `TRACING_RETURN_SERVER_TIMINGS` | boolean | | Return `Server-Timing` response headers. | Default: `false` | +| `TRACING_SERVER_TIME_MIN_DURATION` | number | | Min span duration (ms) before it is reported in `Server-Timing`. | Default: `100.0` | + +### Tenant features (Vector) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `VECTOR_BUCKET_REGION` | string | | AWS region for vector buckets. | Optional | +| `VECTOR_ENABLED` | boolean | | Enable vector bucket support. | Default: `false` | +| `VECTOR_MAX_BUCKETS` | integer | | Max vector buckets per tenant. | Default: `10` | +| `VECTOR_MAX_INDEXES` | integer | | Max indexes per vector bucket. | Default: `20` | +| `VECTOR_S3_BUCKETS` | string (CSV) | | Comma-separated list of S3 buckets backing vector indexes. | Optional | + +### Other (tooling) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADMIN_API_KEY` | string | | API key used by the bundled `pprof-client` script. | Optional (tooling) | +| `ADMIN_URL` | URL | | Admin server URL used by the bundled `pprof-client` script. | Optional (tooling) | +| `FLAME_SOURCEMAPS_DIRS` | string | | Sourcemap directories used by the flamegraph tool. | Default: `dist` | +| `PPROF_FLAME_MD_FORMAT` | boolean | | Markdown format flag for the pprof flamegraph script. | Optional (tooling) | +| `PPROF_GENERATE_FLAME` | boolean | | Generate a flamegraph from a captured pprof profile. | Optional (tooling) | +| `PPROF_NODE_MODULES_SOURCE_MAPS` | boolean | | Include `node_modules` sourcemaps in flame output. | Optional (tooling) | +| `PPROF_OUTPUT` | path | | Output path for the pprof script. | Optional (tooling) | +| `PPROF_SECONDS` | integer | | Profile duration (seconds) for the pprof script. | Optional (tooling) | +| `PPROF_SOURCE_MAPS` | boolean | | Use sourcemaps when symbolicating pprof output. | Optional (tooling) | +| `PPROF_WORKER_ID` | string | | Worker id targeted by the pprof script. | Optional (tooling) | + +--- + +## Edge Functions + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ALL_PROXY` / `all_proxy` | URL | | Default outbound proxy for all schemes for `fetch()` from user functions. | Read by `vendor/deno_fetch/proxy.rs` | +| `DENO_AUTH_TOKENS` | string | | Authentication tokens used when fetching remote modules (`@` syntax). | Read by `deno/file_fetcher.rs` | +| `DENO_CERT` | path | | Path to a PEM file with extra CA certificates loaded into Deno's TLS store. | Read by `ext/runtime/cert.rs` | +| `DENO_DIR` | path | | Override location of Deno's module/transpile cache directory. | Defaults to OS cache dir + `/deno` | +| `DENO_DISABLE_PEDANTIC_NODE_WARNINGS` | boolean | | Suppress pedantic Node.js compatibility warnings. | Read by `deno/args/mod.rs` | +| `DENO_FETCH_TIMEOUT_SECS` | integer (seconds) | | Timeout (seconds) for HTTP fetches made by the runtime when resolving/downloading modules. | No default | +| `DENO_NO_DEPRECATION_WARNINGS` | boolean | | Disable Deno API deprecation warnings. | Read at startup via `cli/src/env.rs` | +| `DENO_NO_PACKAGE_JSON` | boolean | | Disable auto-discovery of `package.json`. | Read by `deno/lib.rs` (set to `1`) | +| `DENO_REPL_HISTORY` | path | | REPL history file path (REPL isn't exposed by edge-runtime, but the var is read by embedded Deno). | Read by `deno/cache/deno_dir.rs` | +| `DENO_TCP_KEEPALIVE_SECS` | integer (seconds) | | TCP keepalive duration (seconds) for outbound `fetch()` connections. | Default: `30` | +| `DENO_TLS_CA_STORE` | string (CSV) | | Comma-separated list of TLS root stores to use (`mozilla`, `system`). | Default: `mozilla` | +| `DENO_USE_WRITEV` | boolean | | Enable `writev` for HTTP responses (perf experiment). | Default: off | +| `DENO_VERBOSE_WARNINGS` | boolean | | Emit verbose stack traces on deprecation warnings. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_ALLOC_CHECK_INT` | integer (ms) | | Interval (ms) between memory allocation checks for user workers. | Default: `1000` | +| `EDGE_RUNTIME_BUNDLE_CHECKSUM` | enum | | Default hash kind for the `bundle` subcommand (`sha256`, `xxhash3`, or `nochecksum`). | Wired to `--checksum` flag | +| `EDGE_RUNTIME_EVENT_WORKER_INITIAL_HEAP_SIZE_MIB` | integer (MB) | | V8 initial heap size (MiB) for the event worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_EVENT_WORKER_MAX_HEAP_SIZE_MIB` | integer (MB) | | V8 max heap size (MiB) for the event worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_INCLUDE_MALLOCED_MEMORY_ON_MEMCHECK` | boolean | | If truthy, include `malloced_memory` in the per-worker memory limit check. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_MAIN_WORKER_INITIAL_HEAP_SIZE_MIB` | integer (MB) | | V8 initial heap size (MiB) for the main worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_MAIN_WORKER_MAX_HEAP_SIZE_MIB` | integer (MB) | | V8 max heap size (MiB) for the main worker. | Read at startup via `cli/src/env.rs` | +| `EDGE_RUNTIME_PORT` | integer | Both | Port to listen on. | Wired to `--port`/`-p` flag; default `9000` | +| `EDGE_RUNTIME_PRIMARY_WORKER_POOL_SIZE` | integer (count) | | Tokio LocalPool size for the main + event workers. | Default: `1` | +| `EDGE_RUNTIME_TLS` | integer | Both | TLS listening port (presence enables TLS). | Wired to `--tls` flag; default-missing-value `443` | +| `EDGE_RUNTIME_TLS_CERT_PATH` | path | Both | Path to PEM X.509 certificate (when TLS enabled). | Wired to `--cert` flag | +| `EDGE_RUNTIME_TLS_KEY_PATH` | path | Both | Path to PEM-encoded private key (when TLS enabled). | Wired to `--key` flag | +| `EDGE_RUNTIME_WORKER_POOL_SIZE` | integer (count) | | Tokio LocalPool size for the user worker pool. | Default: `available_parallelism()` in release | +| `EXT_AI_CACHE_DIR` | path | | Directory used to cache ONNX model files downloaded by `Supabase.ai`. | Defaults to OS cache dir | +| `HTTP_PROXY` / `http_proxy` | URL | | HTTP outbound proxy for `fetch()` from user functions. | Read by `vendor/deno_fetch/proxy.rs` | +| `HTTPS_PROXY` / `https_proxy` | URL | | HTTPS outbound proxy for `fetch()` and for the S3 filesystem backend. | Read by `vendor/deno_fetch/proxy.rs` and `crates/fs/impl/s3_fs.rs` | +| `JSR_URL` | URL | | Override JSR (`jsr.io`) registry base URL. | Default: `https://jsr.io/` | +| `JWT_SECRET` | JWT | Self-hosted | Legacy HS256 symmetric secret. Used by the bundled main service to verify legacy JWTs and injected into user functions. | Consumed by `docker/volumes/functions/main/index.ts` | +| `NO_PROXY` / `no_proxy` | string (CSV) | | Comma-separated bypass list for proxy variables. | Read by `vendor/deno_fetch/proxy.rs` | +| `NPM_CONFIG_REGISTRY` | URL | | Override the npm registry base URL used to resolve `npm:` specifiers. | Default: `https://registry.npmjs.org` | +| `OMP_NUM_THREADS` | integer (count) | | Number of intra-op threads for the ONNX runtime used by `Supabase.ai`. | Default: `1` | +| `OTEL_EXPORTER_OTLP_CERTIFICATE` | path | | Path to PEM CA file used to verify the OTLP collector's TLS certificate. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` | path | | Client cert for mTLS to the OTLP collector. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_CLIENT_KEY` | path | | Client key for mTLS to the OTLP collector. | Read by `vendor/deno_telemetry/lib.rs` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | URL | | OTLP collector endpoint. Setting it enables both runtime-level OTel and the OTLP exporter. | Presence required to enable telemetry | +| `OTEL_EXPORTER_OTLP_HEADERS` | string (CSV) | | Comma-separated headers attached to OTLP exports. | Picked up automatically by the OTLP SDK | +| `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | enum | | OTel metrics temporality (`cumulative`, `delta`, `lowmemory`). | Default: `cumulative` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | enum | | OTLP protocol (`http/protobuf` or `http/json`). | Default: `http/protobuf` | +| `OTEL_METRIC_EXPORT_INTERVAL` | integer (ms) | | Metric export interval in milliseconds. | Default: `60000` | +| `OTEL_RESOURCE_ATTRIBUTES` | string (CSV) | | Comma-separated `key=value` attributes added to every span/metric/log. | Picked up automatically by the OTLP SDK | +| `OTEL_SERVICE_NAME` | string | | `service.name` resource attribute used by the OTel exporter. | Picked up automatically by the OTLP SDK | +| `RUST_LOG` | string | | Filter directive for the Rust logger (e.g. `info`, `base=debug`, `trace`). | Read by `env_logger` / `tracing-subscriber` | +| `SUPABASE_ANON_KEY` | JWT | Both | Public ("anonymous") Supabase API key. Injected for user functions to call the public API. | Injected for user functions | +| `SUPABASE_DB_URL` | URL | Both | Postgres connection string. Injected for user functions that connect directly to Postgres. | Injected for user functions | +| `SUPABASE_INTERNAL_FUNCTIONS_CONFIG` | JSON | CLI | JSON map of per-function options (e.g. `verify_jwt`, `import_map_path`) consumed by the CLI's bundled main service. | Set by CLI; consumed by main service | +| `SUPABASE_INTERNAL_HOST_PORT` | integer | CLI | Local API port the CLI's bundled main service forwards requests to. | Set by CLI | +| `SUPABASE_INTERNAL_JWT_SECRET` | JWT | CLI | HS256 secret used by the CLI's bundled main service to verify JWTs from the local stack. | Set by CLI | +| `SUPABASE_INTERNAL_PUBLISHABLE_KEY` | string | CLI | Opaque API key (publishable) used internally by the CLI's bundled main service. | Set by CLI | +| `SUPABASE_INTERNAL_SECRET_KEY` | string | CLI | Opaque API key (secret) used internally by the CLI's bundled main service. | Set by CLI | +| `SUPABASE_JWKS` | JWKS | CLI | JSON Web Key Set (asymmetric + legacy symmetric) used by the bundled main service to verify user JWTs. | Self-hosted can derive this from `SUPABASE_URL`'s `/auth/v1/.well-known/jwks.json`. | +| `SUPABASE_PUBLIC_URL` | URL | Self-hosted | External/public URL of the Supabase project. Injected for user functions. | Injected for user functions | +| `SUPABASE_PUBLISHABLE_KEYS` | JSON | Self-hosted | JSON map of opaque publishable API keys (new asymmetric-key format). | Injected for user functions | +| `SUPABASE_SECRET_KEYS` | JSON | Self-hosted | JSON map of opaque secret API keys (new asymmetric-key format). Never expose to client code. | Injected for user functions | +| `SUPABASE_SERVICE_ROLE_KEY` | JWT | Both | `service_role` API key (full database access). Injected for user functions for privileged calls. | Injected for user functions | +| `SUPABASE_URL` | URL | Both | Internal Supabase API URL (Kong gateway hostname in self-hosted setups). Injected for user functions. | Injected for user functions | +| `V8_FLAGS` | string | | Space-separated V8 command-line flags applied at startup (e.g. `--max-old-space-size=256`). | Read by `crates/base/src/runtime/mod.rs` | +| `VERIFY_JWT` | boolean | Self-hosted | If `true`, the bundled main service rejects requests whose JWT does not verify against `JWT_SECRET`/`SUPABASE_JWKS`. Applies to all functions. | Read by `docker/volumes/functions/main/index.ts`; supplied via `FUNCTIONS_VERIFY_JWT` in `.env.example` | + +--- + +## Analytics + +> The `analytics` container runs [logflare/logflare](github.com/Logflare/logflare), an Elixir/Phoenix application. Almost all runtime env reads live in [config/runtime.exs](https://github.com/Logflare/logflare/blob/main/config/runtime.exs). Self-hosted Supabase runs it in single-tenant Supabase mode with the Postgres backend; BigQuery support is available but commented out in `docker-compose.yml`. The container is the consumer of `LOGFLARE_PUBLIC_ACCESS_TOKEN`/`LOGFLARE_PRIVATE_ACCESS_TOKEN`. + +> **Heads-up - always-on admin UI:** Logflare's admin pages under `/admin/*` (sources, accounts, cluster view) **are reachable by default**. `LOGFLARE_SUPABASE_MODE=true` provisions an auto-admin user, and the `/admin/*` routes are gated by an auth pipeline rather than an env var - there is no flag to disable them. +> +> If the `analytics` container is exposed beyond your private Docker network, **block** `/admin/*` at the reverse proxy or API gateway level. + +> Analytics (Logflare) upstream self-hosting docs: [docs.logflare.app/self-hosting](https://docs.logflare.app/self-hosting/). + +### Self-host mode + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_SINGLE_TENANT` | boolean | Both | Run Logflare in single-tenant mode (no per-tenant isolation, no signup flow). | Default: `true`. Self-hosted: `true` | +| `LOGFLARE_SUPABASE_MODE` | boolean | Both | Enable the Supabase preset: auto-creates the default source, wires the `analytics` container to the Supabase stack. | Default: `false`. Self-hosted: `true` | +| `LOGFLARE_PUBLIC_ACCESS_TOKEN` | string | Both | Public API token used by ingestion clients (e.g. the `vector` container) to push log events. Falls back to `LOGFLARE_API_KEY`. | Required in single-tenant mode | +| `LOGFLARE_PRIVATE_ACCESS_TOKEN` | string | Both | Private API token used by Studio server-side (and the management API) to query logs and run analytics endpoints. | Required in single-tenant mode | +| `LOGFLARE_FEATURE_FLAG_OVERRIDE` | string | Both | Comma-separated `key=value` pairs overriding feature flags at boot. Self-hosted sets `multibackend=true` so the Postgres backend is reachable. | E.g. `multibackend=true` | +| `LOGFLARE_NODE_HOST` | string | Both | Hostname used to form the Erlang `RELEASE_NODE` (`@`). The single-node default is fine for most self-hosted setups. | Default: `127.0.0.1`. | +| `LOGFLARE_API_KEY` | string | | Legacy fallback name for `LOGFLARE_PUBLIC_ACCESS_TOKEN`. | Deprecated; prefer `LOGFLARE_PUBLIC_ACCESS_TOKEN` | + +### Internal database (metadata) + +> These configure Logflare's own metadata Postgres connection (tenants, sources, endpoints). Self-hosted points them at the shared `supabase-db` container, schema `_analytics`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `DB_HOSTNAME` | string | Both | Hostname for Logflare's metadata Postgres. | Self-hosted: from `POSTGRES_HOST` | +| `DB_PORT` | integer | Both | Port for Logflare's metadata Postgres. | Self-hosted: from `POSTGRES_PORT` | +| `DB_DATABASE` | string | Both | Database name for Logflare's metadata Postgres. | Self-hosted: `_supabase` | +| `DB_SCHEMA` | string | Both | Postgres schema used for Logflare metadata tables (set as `search_path`). | Self-hosted: `_analytics` | +| `DB_USERNAME` | string | Both | Postgres user for Logflare's metadata connection. | Self-hosted: `supabase_admin` | +| `DB_PASSWORD` | string | Both | Postgres password for the metadata connection. | Self-hosted: from `POSTGRES_PASSWORD` | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Ecto connection pool size for the metadata Postgres. | Default: `10` | +| `DB_SSL` | boolean | Self-hosted | Enable SSL/TLS for the metadata Postgres connection (requires cert files). | Default: `false` | + +### Postgres backend (log storage) + +> When `LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true`, Logflare stores log events in a separate Postgres backend rather than BigQuery. Self-hosted points this at the same `db` container, schema `_analytics`. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `POSTGRES_BACKEND_URL` | URL | Both | Connection URL for the Postgres log-storage backend. | Required when `multibackend=true` | +| `POSTGRES_BACKEND_SCHEMA` | string | Both | Schema in the Postgres backend that holds log tables. | Self-hosted: `_analytics` | + +### BigQuery backend (log storage) + +> Disabled in the default self-hosted compose. To use BigQuery, comment out `POSTGRES_BACKEND_URL` / `POSTGRES_BACKEND_SCHEMA` / `LOGFLARE_FEATURE_FLAG_OVERRIDE` in `docker-compose.yml`, mount a `gcloud.json` service-account key, and set `GOOGLE_PROJECT_ID` and `GOOGLE_PROJECT_NUMBER` in the `.env` file. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `GOOGLE_PROJECT_ID` | string | Self-hosted | Google Cloud project ID hosting the BigQuery dataset. | Commented out in compose | +| `GOOGLE_PROJECT_NUMBER` | string | Self-hosted | Numeric Google Cloud project number. | Commented out in compose | +| `GOOGLE_DATASET_ID_APPEND` | string | Self-hosted | Suffix appended to BigQuery dataset IDs. | Default: `_default` | +| `GOOGLE_DATASET_LOCATION` | string | Self-hosted | BigQuery dataset location (e.g. `US`, `EU`). | Default: `US` | +| `GOOGLE_SERVICE_ACCOUNT` | string | Self-hosted | Service-account email used for BigQuery operations. | | +| `LOGFLARE_BIGQUERY_MANAGED_SA_POOL` | integer (count) | Self-hosted | Number of managed service accounts in the BigQuery SA pool. | Default: `0` | +| `LOGFLARE_BQ_WRITE_API_POOL_SIZE` | integer (count) | Self-hosted | Connection pool size for the BigQuery Write API. | Default: `10` | + +### Server / Phoenix endpoint + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PHX_HTTP_PORT` | integer | Self-hosted | HTTP port the Phoenix endpoint binds to. | Default: `4000` | +| `PHX_HTTP_IP` | string | Self-hosted | Bind IP for the HTTP endpoint. | | +| `PHX_URL_HOST` | string | Self-hosted | External host used to build absolute URLs. | | +| `PHX_URL_SCHEME` | string | Self-hosted | URL scheme (`http`/`https`). | | +| `PHX_URL_PORT` | integer | Self-hosted | External URL port. | | +| `PHX_SECRET_KEY_BASE` | string | Self-hosted | Phoenix session signing/encryption key. | Required in production; baked into the image for self-host | +| `PHX_CHECK_ORIGIN` | string (CSV) | Self-hosted | Comma-separated list of allowed origins for CSRF check. | | +| `PHX_LIVE_VIEW_SIGNING_SALT` | string | Self-hosted | Salt used for Phoenix LiveView token signing. | | +| `LOGFLARE_GRPC_PORT` | integer | Self-hosted | Port for the gRPC server (used for trace ingestion / OTLP). | Default: `50051` | +| `LOGFLARE_ENABLE_GRPC_SSL` | boolean | Self-hosted | Enable TLS for the gRPC server. | Default: `false` | +| `LOGFLARE_ENABLE_LIVE_DASHBOARD` | boolean | Self-hosted | Expose Phoenix LiveDashboard at `/admin`. | Default: `false` | +| `LOGFLARE_HTTP_CONNECTION_POOLS` | string (CSV) | Self-hosted | Comma-separated list of HTTP pool providers to enable. | Default: `all` | +| `LOGFLARE_PUBSUB_POOL_SIZE` | integer (count) | Self-hosted | PubSub connection pool size. | Default: `56` | +| `LOGFLARE_NODE_SHUTDOWN_CODE` | string | Self-hosted | Shutdown identifier code. | | + +### Logging + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_LOG_LEVEL` | enum | CLI | Logger level (`debug`, `info`, `warning`, `error`). | Default: `info` | +| `LOGFLARE_LOGGER_JSON` | boolean | Self-hosted | Emit JSON-formatted log lines instead of plain text. | Default: `false` | +| `LOGFLARE_LOGGER_BACKEND_URL` | URL | Self-hosted | URL of a remote Logflare logger backend (forwards Logflare's own logs there). | | +| `LOGFLARE_LOGGER_BACKEND_API_KEY` | string | Self-hosted | API key for the remote logger backend. | | +| `LOGFLARE_LOGGER_BACKEND_SOURCE_ID` | string | Self-hosted | Source ID for the remote logger backend. | | + +### Telemetry / Observability + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_OTEL_ENDPOINT` | URL | Self-hosted | OTLP collector endpoint. Presence enables tracing. | | +| `LOGFLARE_OTEL_SAMPLE_RATIO` | number (ratio) | Self-hosted | Default sampling ratio (0.0-1.0) for OTel traces. | Default: `1.0` | +| `LOGFLARE_OTEL_INGEST_SAMPLE_RATIO` | number (ratio) | Self-hosted | Sampling ratio for ingest-path traces (falls back to default). | | +| `LOGFLARE_OTEL_ENDPOINT_SAMPLE_RATIO` | number (ratio) | Self-hosted | Sampling ratio for endpoint-path traces (falls back to default). | | +| `LOGFLARE_OTEL_SOURCE_UUID` | string | Self-hosted | Source UUID header attached to OTel exports. | | +| `LOGFLARE_OTEL_ACCESS_TOKEN` | string | Self-hosted | Access token header attached to OTel exports. | | +| `LOGFLARE_HEALTH_MAX_MEMORY_UTILIZATION` | number (ratio) | Self-hosted | Memory utilization threshold (0.0-1.0) reported by the health check. | Default: `0.80` | +| `LOGFLARE_ALERTS_ENABLED` | boolean | Self-hosted | Enable the alerting subsystem. | Default: `true` | + +### Encryption + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `LOGFLARE_DB_ENCRYPTION_KEY` | string | Self-hosted | Primary base64 key used to encrypt sensitive columns. | Image fallback baked in | +| `LOGFLARE_DB_ENCRYPTION_KEY_RETIRED` | string | Self-hosted | Previously-active key, kept for decryption during rotation. | | + +--- + +## Postgres + +> The `db` container runs the `supabase/postgres` image, a fork of the official `postgres` image that adds Supabase-specific extensions (`pgsodium`, `pg_graphql`, `pgjwt`, etc.), default roles, and seed migrations. Most variables are inherited from the upstream `postgres` image and read by its `docker-entrypoint.sh` on first boot (initdb). A few are added by the Supabase fork or by init SQL that the self-hosted compose mounts into `/docker-entrypoint-initdb.d/init-scripts/`. + +### Core (inherited from upstream `postgres` image) + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `POSTGRES_PASSWORD` | string | Both | Password for the `POSTGRES_USER` superuser. Set on first boot during `initdb`. | Required unless `POSTGRES_HOST_AUTH_METHOD=trust` | +| `POSTGRES_USER` | string | CLI | Username for the initial superuser. The Supabase image overrides this. | Default: `supabase_admin` (Supabase image) | +| `POSTGRES_DB` | string | Both | Name of the first database to create. | Default: `postgres` | +| `POSTGRES_HOST` | string | Both | Unix socket directory or hostname Postgres listens on. The Supabase image hardcodes the socket path. | Default: `/var/run/postgresql` (Supabase image) | +| `POSTGRES_PORT` | integer | Self-hosted | TCP port Postgres listens on (Supabase migration scripts also read this). | Default: `5432` | +| `POSTGRES_INITDB_ARGS` | string | CLI | Extra arguments passed to `initdb` (locale, encoding, etc.). | Default in CLI: `--allow-group-access --locale-provider=icu --encoding=UTF-8 --icu-locale=en_US.UTF-8` | +| `POSTGRES_INITDB_WALDIR` | path | | Separate filesystem path used by `initdb` for the WAL directory. | When unset, WAL lives inside `PGDATA` | +| `POSTGRES_HOST_AUTH_METHOD` | enum | | Default `pg_hba.conf` authentication method (e.g. `trust`, `scram-sha-256`). | Defaults to `scram-sha-256` (Postgres 14+) | +| `PGDATA` | path | CLI | Data directory used by Postgres. | Default: `/var/lib/postgresql/data` | + +### libpq client variables (read by Supabase migration scripts on init) + +> The Supabase image's `migrations/db/migrate.sh` runs at first boot and reads the standard libpq env vars rather than the `POSTGRES_*` ones. The compose file sets both so the entrypoint and the migration runner both work. + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `PGPORT` | integer | Self-hosted | TCP port for the migration runner's psql connection. | Self-hosted: mirrors `POSTGRES_PORT` | +| `PGPASSWORD` | string | Self-hosted | Password for the migration runner's psql connection. | Self-hosted: mirrors `POSTGRES_PASSWORD` | +| `PGDATABASE` | string | Self-hosted | Database name used by the migration runner. | Self-hosted: mirrors `POSTGRES_DB` | +| `PGHOST` | string | | Host used by the migration runner (defaults to socket path inside the container). | Self-hosted relies on `POSTGRES_HOST` | + +### Supabase init SQL (mounted by docker-compose) + +> These are consumed by SQL scripts the self-hosted compose mounts into `/docker-entrypoint-initdb.d/init-scripts/`. They are *not* read by the `supabase/postgres` image itself - they are read by init SQL under `docker/volumes/db/` (the orchestration layer). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `JWT_SECRET` | string | Both | HS256 secret stored as `app.settings.jwt_secret` on the `postgres` database. Read by `volumes/db/jwt.sql`. PostgREST and pgjwt-using functions read it via `current_setting()`. | Required. Sourced from `JWT_SECRET` in `.env.example` | +| `JWT_EXP` | integer (seconds) | Both | Default JWT expiry (seconds) stored as `app.settings.jwt_exp` on the `postgres` database. Read by `volumes/db/jwt.sql`. | Sourced from `JWT_EXPIRY` in `.env.example` | + +--- + +## Supavisor + +> Supavisor's upstream env-var reference is at [supabase/supavisor/docs/configuration/env.md](https://github.com/supabase/supavisor/blob/main/docs/configuration/env.md). + +| Variable | Type | Set by | Description | Notes | +|---|---|---|---|---| +| `ADDR_TYPE` | enum | | Socket address family for the HTTP endpoint. Must be `inet` or `inet6`. | Default: `inet` | +| `API_JWT_SECRET` | string | Self-hosted | JWT secret used to authenticate requests to Supavisor's management API. | Self-hosted sets this to `JWT_SECRET` | +| `API_TOKEN_BLOCKLIST` | string (CSV) | | Comma-separated list of API JWTs to reject. | Default: empty | +| `CACHE_BYPASS_USERS` | string (CSV) | | Comma-separated list of DB users that bypass the auth-query cache. | Default: empty | +| `CLUSTER_ID` | string | | Region identifier used in the libcluster Postgres channel name. First of `CLUSTER_ID`, `LOCATION_ID`, `REGION` wins. | Only used when `CLUSTER_POSTGRES` is set | +| `CLUSTER_NODES` | string (CSV) | | Comma-separated list of Erlang node names for static EPMD clustering. | Optional | +| `CLUSTER_POSTGRES` | boolean | Self-hosted | Enables libcluster Postgres strategy (heartbeats via `pg_notify`). | Set to `true` to enable. Self-hosted: `true` | +| `DATABASE_URL` | URL | Self-hosted | Ecto URL for Supavisor's metadata database (tenants, users). Also used by the Postgres clustering strategy. | Default: `ecto://postgres:postgres@localhost:6432/postgres` | +| `DB_POOL_SIZE` | integer (count) | Self-hosted | Pool size for Supavisor's internal metadata Ecto repo. | Default: `25`. Self-hosted: from `POOLER_DB_POOL_SIZE` (default `5`) | +| `DEBUG_LOAD_RUNTIME_CONFIG` | boolean | | If set, hot-upgrade loads `config/runtime.exs` from CWD instead of the release dir. | Debug only | +| `DNS_POLL` | string | | DNS name to poll for libcluster `DNSPoll` strategy. | Optional | +| `DOWNSTREAM_SERVER_ECDSA_CERT` | path | | Path to ECDSA certificate file served to downstream clients. | Optional, file must exist | +| `DOWNSTREAM_SERVER_ECDSA_KEY` | path | | Path to ECDSA private key file served to downstream clients. | Optional, file must exist | +| `GLOBAL_DOWNSTREAM_CERT_PATH` | path | | Path to TLS certificate file served to downstream Postgres clients. | Optional, file must exist | +| `GLOBAL_DOWNSTREAM_KEY_PATH` | path | | Path to TLS private key file served to downstream Postgres clients. | Optional, file must exist | +| `GLOBAL_UPSTREAM_CA_PATH` | path | | Path to upstream CA bundle used to verify upstream Postgres TLS certificates. | Optional | +| `INSTANCE_ID` | string | | Instance identifier added to logger metadata. | Optional | +| `JWT_CLAIM_VALIDATORS` | JSON | | JSON object of additional JWT claims to validate (e.g. `{"iss":"supabase"}`). | Default: `{}` | +| `LOCATION_ID` | string | | Region identifier used in the libcluster Postgres channel name. Falls back to `REGION` if unset. | Only used when `CLUSTER_POSTGRES` is set | +| `LOCATION_KEY` | string | | Location label added to logger metadata. Falls back to `region` if unset. | Optional | +| `LOGFLARE_API_KEY` | string | | Logflare API key. Required when `LOGS_ENGINE=logflare`. | Required when `LOGS_ENGINE=logflare`; optional otherwise | +| `LOGFLARE_SOURCE_ID` | string | | Logflare source ID. Required when `LOGS_ENGINE=logflare`. | Required when `LOGS_ENGINE=logflare`; optional otherwise | +| `LOGS_ENGINE` | string | | Logging backend. Set to `logflare` to enable the Logflare HTTP logger backend. | Optional | +| `MAX_CONNECTIONS` | integer (count) | | Max concurrent connections accepted by the HTTP endpoint and Ranch proxy listeners. | Default: `1000` (HTTP), `:infinity` (proxy listeners) | +| `METRICS_DISABLED` | boolean | | If `true`, disables Prometheus metrics children (PromEx, TenantsMetrics, MetricsCleaner). | Default: `false` | +| `METRICS_JWT_SECRET` | string | Self-hosted | JWT secret used to authenticate requests to the metrics endpoint. | Self-hosted sets this to `JWT_SECRET` | +| `METRICS_TOKEN_BLOCKLIST` | string (CSV) | | Comma-separated list of metrics JWTs to reject. | Default: empty | +| `NAMED_PREPARED_STATEMENTS_ENABLED` | boolean | | Feature flag enabling named prepared statement support in transaction mode. | Default: `false`. Accepts `true`/`false`/`1`/`0` | +| `NODE_IP` | string | | IP address used to build the Erlang `RELEASE_NODE` (`@`). Also stored as `node_host` in app config. | Default: `127.0.0.1`. In Fly, falls back to the `fly-local-6pn` entry in `/etc/hosts` | +| `NODE_NAME` | string | | Erlang node basename. Used as `@` for the release node. | Falls back to `FLY_APP_NAME`, then `supavisor` | +| `NO_WARM_POOL_USERS` | string (CSV) | | Comma-separated list of DB users for which Supavisor should not pre-warm a pool. | Default: empty | +| `NUM_ACCEPTORS` | integer (count) | | Number of acceptor processes per Ranch listener (HTTP endpoint and proxy listeners). | Default: `100` | +| `PORT` | integer | Self-hosted | HTTP port for the Phoenix endpoint (health, metrics, management API). | Default: `4000`. Self-hosted: `4000` | +| `POOLER_DEFAULT_POOL_SIZE` | integer (count) | Self-hosted | Default upstream pool size for the bootstrapped tenant. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POOLER_MAX_CLIENT_CONN` | integer (count) | Self-hosted | Maximum client connections for the bootstrapped tenant. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POOLER_POOL_MODE` | enum | Self-hosted | Pool mode for the bootstrapped tenant's user (`transaction` or `session`). Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Self-hosted hard-codes `transaction` | +| `POOLER_TENANT_ID` | string | Self-hosted | External tenant ID created at first startup. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Required | +| `POSTGRES_DB` | string | Self-hosted | Tenant Postgres database name. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POSTGRES_HOST` | string | Self-hosted | Tenant Postgres host. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs. Default in script: `db` | +| `POSTGRES_PASSWORD` | string | Self-hosted | Tenant Postgres password for the `pgbouncer` auth user. Read by the self-hosted `pooler.exs` provisioning script. | Configured via pooler.exs | +| `POSTGRES_PORT` | integer | Self-hosted | Tenant Postgres port. Read by the self-hosted `pooler.exs` provisioning script and used to map the session-mode listener port. | Configured via pooler.exs | +| `PROM_POLL_RATE` | integer (ms) | | Prometheus metrics poll interval in milliseconds. | Default: `15000` | +| `PROXY_PORT` | integer | | Generic Postgres proxy listener port (mode: `proxy`). | Default: `5412` | +| `PROXY_PORT_SESSION` | integer | | Postgres session-mode proxy listener port. | Default: `5432` | +| `PROXY_PORT_TRANSACTION` | integer | | Postgres transaction-mode proxy listener port. | Default: `6543` | +| `REGION` | string | Self-hosted | Region label used in logger metadata and as the default for `LOCATION_KEY`. Also used in the libcluster Postgres channel name when `CLUSTER_ID`/`LOCATION_ID` are unset. | Falls back to `FLY_REGION`. Self-hosted: `local` | +| `RELEASE_COOKIE` | string | | Erlang distribution cookie. Read by the release scripts. | Optional | +| `RELEASE_DISTRIBUTION` | string | | Erlang release distribution mode. Set to `name` by `rel/env.sh.eex`. | Default: `name` | +| `RELEASE_NODE` | string | | Erlang release node name (`@`). Set by `rel/env.sh.eex`. | Computed at startup | +| `RELEASE_ROOT` | path | | Release root directory. Used to locate `runtime.exs` during hot upgrades. | Set by the release scripts | +| `RLIMIT_NOFILE` | integer (count) | | Open-file descriptor limit applied by the container entrypoint (`limits.sh`). | Default: `100000` (baked into image) | +| `SECRET_KEY_BASE` | string | Self-hosted | Phoenix endpoint secret used to sign/encrypt session and CSRF tokens. | Required in non-dev/test envs | +| `SESSION_PROXY_PORTS` | string (CSV) | | Comma-separated list of additional internal session-mode proxy listener ports. | Default: `12100,12101,12102,12103` | +| `SUBSCRIBE_RETRIES` | integer (count) | | Number of retries when subscribing to a tenant pool. | Default: `20` | +| `SUPAVISOR_DB_IP_VERSION` | enum | | Socket family for upstream Postgres connections. Set to `ipv6` to use `inet6`. | Default: `ipv4` (`inet`) | +| `SUPAVISOR_LOG_FILE_PATH` | path | | If set, the default logger writes logs to this file (rotated, 8 MiB each, 5 files). | Optional | +| `SUPAVISOR_LOG_FORMAT` | enum | | Set to `json` to emit logs in Logflare JSON format. | Optional | +| `SWITCH_ACTIVE_COUNT` | integer (count) | | Number of activity ticks before a pool is switched out. | Default: `100` | +| `TRANSACTION_PROXY_PORTS` | string (CSV) | | Comma-separated list of additional internal transaction-mode proxy listener ports. | Default: `12104,12105,12106,12107` | +| `VAULT_ENC_KEY` | string | Self-hosted | AES.GCM encryption key for the Cloak vault used to encrypt tenant credentials at rest. | Required | diff --git a/README.md b/README.md new file mode 100644 index 0000000..56ebaf0 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +
+ +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment) + +
+ +# Self-Hosted Supabase with Docker + +This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure. + +## Getting Started + +Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker) + +The guide covers: +- Prerequisites (Git and Docker) +- Initial setup and configuration +- Securing your installation +- Accessing services +- Updating your instance + +## What's Included + +This Docker Compose configuration includes the following services: + +- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project +- **[Kong](https://github.com/Kong/kong)** - Kong API gateway +- **[Auth](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management +- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API +- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets +- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions +- **[imgproxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server +- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries) +- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development +- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services +- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform +- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs +- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler + +## Documentation + +- **[Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides +- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services +- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference +- **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration +- **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables + +## Updates + +To update your self-hosted Supabase instance: + +1. Review [CHANGELOG.md](./CHANGELOG.md) for breaking changes +2. Check [versions.md](./versions.md) for new image versions +3. Update `docker-compose.yml` if there are configuration changes +4. Pull the latest images: `docker compose pull` +5. Stop services: `docker compose down` +6. Start services with new configuration: `docker compose up -d` + +**Note:** Consider to always backup your database before updating. + +## Community & Support + +For troubleshooting common issues, see: +- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds +- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues +- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides + +Self-hosted Supabase is community-supported. Get help and connect with other users: + +- [Discord](https://discord.supabase.com) - Real-time chat and community support +- [Reddit](https://www.reddit.com/r/Supabase/) - Official Supabase subreddit + +Share your self-hosting experience: + +- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - "Self-hosting: What's working (and what's not)?" + +## Important Notes + +### Security + +⚠️ **The default configuration is not secure for production use.** + +Before deploying to production, you must: +- [Update](https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase) all default passwords and secrets in the `.env` file +- Review and update CORS settings +- Consider setting up a secure proxy in front of self-hosted Supabase +- Review and adjust network security configuration (ACLs, etc.) +- Set up proper backup procedures + +See the [main installation guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos in the documentation. + +## License + +This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details. diff --git a/dev/data.sql b/dev/data.sql new file mode 100644 index 0000000..2328004 --- /dev/null +++ b/dev/data.sql @@ -0,0 +1,48 @@ +create table profiles ( + id uuid references auth.users not null, + updated_at timestamp with time zone, + username text unique, + avatar_url text, + website text, + + primary key (id), + unique(username), + constraint username_length check (char_length(username) >= 3) +); + +alter table profiles enable row level security; + +create policy "Public profiles are viewable by the owner." + on profiles for select + using ( auth.uid() = id ); + +create policy "Users can insert their own profile." + on profiles for insert + with check ( auth.uid() = id ); + +create policy "Users can update own profile." + on profiles for update + using ( auth.uid() = id ); + +-- Set up Realtime +begin; + drop publication if exists supabase_realtime; + create publication supabase_realtime; +commit; +alter publication supabase_realtime add table profiles; + +-- Set up Storage +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." + on storage.objects for select + using ( bucket_id = 'avatars' ); + +create policy "Anyone can upload an avatar." + on storage.objects for insert + with check ( bucket_id = 'avatars' ); + +create policy "Anyone can update an avatar." + on storage.objects for update + with check ( bucket_id = 'avatars' ); diff --git a/dev/docker-compose.dev.yml b/dev/docker-compose.dev.yml new file mode 100644 index 0000000..e6b2e76 --- /dev/null +++ b/dev/docker-compose.dev.yml @@ -0,0 +1,44 @@ +version: "3.8" + +services: + studio: + build: + context: .. + dockerfile: apps/studio/Dockerfile + target: dev + ports: + - 8082:8082 + develop: + watch: + - action: sync + path: ../apps/studio + target: /app/apps/studio + ignore: + - node_modules/ + - action: rebuild + path: package.json + + mail: + container_name: supabase-mail + image: inbucket/inbucket:3.0.3 + ports: + - '2500:2500' # SMTP + - '9000:9000' # web interface + - '1100:1100' # POP3 + auth: + environment: + - GOTRUE_SMTP_USER= + - GOTRUE_SMTP_PASS= + meta: + ports: + - 5555:8080 + db: + restart: 'no' + volumes: + # Always use a fresh database when developing + - /var/lib/postgresql/data + # Seed data should be inserted last (alphabetical order) + - ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql + storage: + volumes: + - /var/lib/storage diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml new file mode 100644 index 0000000..3551008 --- /dev/null +++ b/docker-compose.caddy.yml @@ -0,0 +1,53 @@ +services: + + # By default, Kong is used as the API gateway and its ports/env are reset + # below so Caddy can terminate TLS in front of it. + # + # When running Envoy instead, e.g.: + # docker compose -f docker-compose.yml -f docker-compose.envoy.yml \ + # -f docker-compose.caddy.yml up -d + # comment out the `kong:` block below and uncomment the `api-gw:` block + # (and the matching `depends_on` entry further down) so Caddy sits in front + # of Envoy rather than Kong. + + #api-gw: + # ports: !reset [] + + kong: + ports: !reset [] + environment: + KONG_PORT_MAPS: "443:8000,443:8443" + + caddy: + container_name: supabase-caddy + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + depends_on: + #api-gw: + # condition: service_healthy + kong: + condition: service_healthy + studio: + condition: service_healthy + environment: + PROXY_DOMAIN: ${PROXY_DOMAIN} + PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME} + PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD} + command: + - /bin/sh + - -c + - | + PROXY_AUTH_PASSWORD=$$(caddy hash-password --plaintext "$$PROXY_AUTH_PASSWORD") && \ + caddy run --config /etc/caddy/Caddyfile --adapter caddyfile + volumes: + - ./volumes/proxy/caddy:/etc/caddy + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.envoy.yml b/docker-compose.envoy.yml new file mode 100644 index 0000000..38391af --- /dev/null +++ b/docker-compose.envoy.yml @@ -0,0 +1,51 @@ +# Envoy override for Kong +# Usage: docker compose -f docker-compose.yml -f docker-compose.envoy.yml up + +services: + # Disable the original Kong service + kong: + profiles: + - disabled + + # Rewire dependencies that require Kong to Envoy + functions: + depends_on: !override + api-gw: + condition: service_healthy + + # Envoy API gateway + api-gw: + container_name: supabase-envoy + image: envoyproxy/envoy:v1.38.0 + restart: unless-stopped + ports: + - ${KONG_HTTP_PORT}:8000/tcp + volumes: + - ./volumes/api/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro + - ./volumes/api/envoy/cds.yaml:/etc/envoy/cds.yaml:ro + - ./volumes/api/envoy/lds.template.yaml:/etc/envoy/lds.template.yaml:ro + - ./volumes/api/envoy/docker-entrypoint.sh:/docker-entrypoint.sh:ro + depends_on: + studio: + condition: service_healthy + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: ["/bin/sh", "/docker-entrypoint.sh"] + healthcheck: + # Using a TCP port check because this image does not include curl or wget. + test: ["CMD-SHELL", "timeout 1 bash -c ' /etc/nginx/user_conf.d/dashboard-passwd && \ + envsubst '$${PROXY_DOMAIN}' < /etc/nginx/supabase-nginx.conf.tpl > /etc/nginx/user_conf.d/nginx.conf && \ + /scripts/start_nginx_certbot.sh + volumes: + - ./volumes/proxy/nginx/supabase-nginx.conf.tpl:/etc/nginx/supabase-nginx.conf.tpl:ro + - nginx_letsencrypt:/etc/letsencrypt + +volumes: + nginx_letsencrypt: diff --git a/docker-compose.pg17.yml b/docker-compose.pg17.yml new file mode 100644 index 0000000..d35f0b9 --- /dev/null +++ b/docker-compose.pg17.yml @@ -0,0 +1,15 @@ +# Postgres 17 override for self-hosted Supabase. +# +# Usage (new install or after upgrade): +# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d +# +# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first. +# +# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a +# leftover db-config volume from PG 15, you may see +# "FATAL: invalid secret key". Either remove the old volume or fix ownership. +# See: docs/guides/self-hosting/postgres-upgrade-17 + +services: + db: + image: supabase/postgres:17.6.1.084 diff --git a/docker-compose.rustfs.yml b/docker-compose.rustfs.yml new file mode 100644 index 0000000..147da67 --- /dev/null +++ b/docker-compose.rustfs.yml @@ -0,0 +1,47 @@ +services: + + rustfs: + image: rustfs/rustfs + environment: + RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER} + RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 + RUSTFS_CORS_ALLOWED_ORIGINS: "*" + RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*" + security_opt: + - "no-new-privileges:true" + healthcheck: + test: [ "CMD", "curl", "-f", "http://rustfs:9000/health" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - rustfs-data:/data + + rustfs-createbucket: + image: rustfs/rc + depends_on: + rustfs: + condition: service_healthy + entrypoint: > + /bin/sh -ec " + rc alias set supa-rustfs http://rustfs:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} && + rc mb --ignore-existing supa-rustfs/${GLOBAL_S3_BUCKET} + " + + storage: + depends_on: + rustfs-createbucket: + condition: service_completed_successfully + rustfs: + condition: service_healthy + environment: + STORAGE_BACKEND: s3 + GLOBAL_S3_ENDPOINT: http://rustfs:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER} + AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} + +volumes: + rustfs-data: diff --git a/docker-compose.s3.yml b/docker-compose.s3.yml new file mode 100644 index 0000000..165463f --- /dev/null +++ b/docker-compose.s3.yml @@ -0,0 +1,46 @@ +services: + + minio: + image: cgr.dev/chainguard/minio + #ports: + # - '9000:9000' + # - '9001:9001' + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "mc", "ready", "local" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - minio-data:/data + + minio-createbucket: + image: cgr.dev/chainguard/minio-client:latest-dev + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -ec " + mc alias set supa-minio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} && + mc mb --ignore-existing supa-minio/${GLOBAL_S3_BUCKET} + " + + storage: + depends_on: + minio-createbucket: + condition: service_completed_successfully + minio: + condition: service_healthy + environment: + STORAGE_BACKEND: s3 + GLOBAL_S3_ENDPOINT: http://minio:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER} + AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} + +volumes: + minio-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..65cc8b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,574 @@ +# Usage +# Start: docker compose up -d +# Stop: docker compose down +# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d +# Reset everything: sh reset.sh +# +# TODO: +# - Podman does not support nested variable interpolation (${A:-${B}}) +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.06.03-sha-0bca601 + restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\"" + ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 20s + environment: + # Listen on all IPv4 interfaces + HOSTNAME: "0.0.0.0" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + # See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access + #POSTGRES_USER_READ_WRITE: postgres + + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY} + + # See: docker-compose.logs.yml + ENABLED_FEATURES_LOGS_ALL: "false" + + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - ./volumes/snippets:/app/snippets:z + - ./volumes/functions:/app/edge-functions:ro,z + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + networks: + default: + aliases: + - api-gw + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + + depends_on: + studio: + condition: service_healthy + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z + #- ./volumes/api/server.crt:/home/kong/server.crt:ro + #- ./volumes/api/server.key:/home/kong/server.key:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + #KONG_SSL_CERT: /home/kong/server.crt + #KONG_SSL_CERT_KEY: /home/kong/server.key + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: /home/kong/kong-entrypoint.sh + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.189.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + # JSON array of signing JWKs (EC private + legacy symmetric) + # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} + GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true" + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true" + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + # Uncomment to enable OAuth / social login providers. + + GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + # GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + # GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + # GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + # GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Uncomment to configure SMS delivery (phone auth and phone MFA). + # GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER} + # GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP} + # GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH} + # GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY} + # GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE} + + # Twilio credentials (when SMS_PROVIDER=twilio) + # GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID} + # GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN} + # GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID} + + # Test OTP mappings for development + # GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP} + + # Uncomment to configure multi-factor authentication (MFA). + # GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED} + # GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED} + # GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED} + # GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED} + # GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS} + + # SAML SSO + # GOTRUE_SAML_ENABLED: ${SAML_ENABLED} + # GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY} + # GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS} + # GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD} + # GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL} + # GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION} + + # Uncomment to enable custom access token hook. + # See: https://supabase.com/docs/guides/auth/auth-hooks for + # full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.12 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + PGRST_DB_ANON_ROLE: anon + + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS. + # For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or + # PGRST_JWT_SECRET: ${JWT_JWKS} + PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}} + + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.102.3 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + + # Legacy symmetric HS256 key + API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: API_JWT_JWKS: ${JWT_JWKS} + API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + METRICS_JWT_SECRET: ${JWT_SECRET} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + DISABLE_HEALTHCHECK_LOGGING: "true" + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.60.4 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + + # Legacy symmetric HS256 key + AUTH_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: JWT_JWKS: ${JWT_JWKS} + JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: "true" + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: ${REGION} + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration + S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} + S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.96.6 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.74.0 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:z + - deno-cache:/root/.cache/deno + depends_on: + kong: + condition: service_healthy + environment: + # Legacy symmetric HS256 key + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - db-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.9.5 + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + CLUSTER_POSTGRES: "true" + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + VAULT_ENC_KEY: ${VAULT_ENC_KEY} + API_JWT_SECRET: ${JWT_SECRET} + METRICS_JWT_SECRET: ${JWT_SECRET} + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: ${POOLER_TENANT_ID} + POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + POOLER_POOL_MODE: transaction + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +volumes: + db-config: + deno-cache: diff --git a/docker-compose.yml.old b/docker-compose.yml.old new file mode 100644 index 0000000..fa92b26 --- /dev/null +++ b/docker-compose.yml.old @@ -0,0 +1,572 @@ +# Usage +# Start: docker compose up -d +# Stop: docker compose down +# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d +# Reset everything: sh reset.sh +# +# TODO: +# - Podman does not support nested variable interpolation (${A:-${B}}) +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.06.03-sha-0bca601 + restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\"" + ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 20s + environment: + # Listen on all IPv4 interfaces + HOSTNAME: "0.0.0.0" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + # See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access + #POSTGRES_USER_READ_WRITE: postgres + + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY} + + # See: docker-compose.logs.yml + ENABLED_FEATURES_LOGS_ALL: "false" + + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - ./volumes/snippets:/app/snippets:z + - ./volumes/functions:/app/edge-functions:ro,z + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + networks: + default: + aliases: + - api-gw + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + depends_on: + studio: + condition: service_healthy + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z + #- ./volumes/api/server.crt:/home/kong/server.crt:ro + #- ./volumes/api/server.key:/home/kong/server.key:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + #KONG_SSL_CERT: /home/kong/server.crt + #KONG_SSL_CERT_KEY: /home/kong/server.key + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: /home/kong/kong-entrypoint.sh + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.189.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + # JSON array of signing JWKs (EC private + legacy symmetric) + # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} + GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true" + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true" + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + # Uncomment to enable OAuth / social login providers. + # GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + # GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + # GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + # GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + # GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + # GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + # GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + # GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Uncomment to configure SMS delivery (phone auth and phone MFA). + # GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER} + # GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP} + # GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH} + # GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY} + # GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE} + + # Twilio credentials (when SMS_PROVIDER=twilio) + # GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID} + # GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN} + # GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID} + + # Test OTP mappings for development + # GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP} + + # Uncomment to configure multi-factor authentication (MFA). + # GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED} + # GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED} + # GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED} + # GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED} + # GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS} + + # SAML SSO + # GOTRUE_SAML_ENABLED: ${SAML_ENABLED} + # GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY} + # GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS} + # GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD} + # GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL} + # GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION} + + # Uncomment to enable custom access token hook. + # See: https://supabase.com/docs/guides/auth/auth-hooks for + # full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.12 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + PGRST_DB_ANON_ROLE: anon + + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS. + # For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or + # PGRST_JWT_SECRET: ${JWT_JWKS} + PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}} + + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.102.3 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + + # Legacy symmetric HS256 key + API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: API_JWT_JWKS: ${JWT_JWKS} + API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + METRICS_JWT_SECRET: ${JWT_SECRET} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + DISABLE_HEALTHCHECK_LOGGING: "true" + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.60.4 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + + # Legacy symmetric HS256 key + AUTH_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: JWT_JWKS: ${JWT_JWKS} + JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: "true" + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: ${REGION} + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration + S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} + S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.96.6 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.74.0 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:z + - deno-cache:/root/.cache/deno + depends_on: + kong: + condition: service_healthy + environment: + # Legacy symmetric HS256 key + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - db-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.9.5 + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + CLUSTER_POSTGRES: "true" + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + VAULT_ENC_KEY: ${VAULT_ENC_KEY} + API_JWT_SECRET: ${JWT_SECRET} + METRICS_JWT_SECRET: ${JWT_SECRET} + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: ${POOLER_TENANT_ID} + POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + POOLER_POOL_MODE: transaction + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +volumes: + db-config: + deno-cache: diff --git a/reset.sh b/reset.sh new file mode 100755 index 0000000..af0302a --- /dev/null +++ b/reset.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +set -e + +auto_confirm=0 + +confirm () { + if [ "$auto_confirm" = "1" ]; then + return 0 + fi + + printf "Are you sure you want to proceed? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) + ;; + *) + echo "Script canceled." + exit 1 + ;; + esac +} + +if [ "$1" = "-y" ]; then + auto_confirm=1 +fi + +echo "" +echo "*** WARNING: This will remove all containers and container data, and optionally reset .env ***" +echo "" + +confirm + +echo "===> Stopping and removing all containers..." + +if [ -f ".env" ]; then + docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans +elif [ -f ".env.example" ]; then + echo "No .env found, using .env.example for docker compose down..." + docker compose --env-file .env.example -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans +else + echo "Skipping 'docker compose down' because there's no env-file." +fi + +echo "===> Cleaning up bind-mounted directories..." +BIND_MOUNTS="./volumes/db/data ./volumes/storage" + +for dir in $BIND_MOUNTS; do + if [ -d "$dir" ]; then + echo "Removing $dir..." + confirm + rm -rf "$dir" + else + echo "$dir not found." + fi +done + +echo "===> Resetting .env file (will save backup to .env.old)..." +confirm +if [ -f ".env" ] || [ -L ".env" ]; then + echo "Renaming existing .env file to .env.old" + mv .env .env.old +else + echo "No .env file found." +fi + +if [ -f ".env.example" ]; then + echo "===> Copying .env.example to .env" + cp .env.example .env +else + echo "No .env.example found, can't restore .env to default values." +fi + +echo "Cleanup complete!" +echo "Re-run 'docker compose pull' to update images." +echo "" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..3978168 --- /dev/null +++ b/run.sh @@ -0,0 +1,297 @@ +#!/bin/sh +# +# Manage the self-hosted Supabase docker compose stack. +# +# Override files are layered via docker compose's native COMPOSE_FILE env +# var in .env. Format: colon-separated list with docker-compose.yml first. +# +# Examples in .env: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +# Manage with: sh run.sh config add | config remove +# (accepts either a short name like 'pg17' or 'docker-compose.pg17.yml') +# +# Usage: +# sh run.sh start # docker compose up -d --wait +# sh run.sh stop # docker compose down +# sh run.sh restart [service] # restart the stack (or named services) +# sh run.sh restart --except ... # restart all services except the named ones +# sh run.sh recreate [service] # stop then start (or force-recreate one service) +# sh run.sh recreate --except ... # force-recreate all services except the named ones +# sh run.sh status # docker compose ps +# sh run.sh logs [service] # follow logs (all or one service) +# sh run.sh inspect # docker inspect on a service's container +# sh run.sh printenv # print a service's environment variables +# sh run.sh pull # pull images +# sh run.sh config # show the active COMPOSE_FILE list +# sh run.sh config add # add an override to COMPOSE_FILE in .env +# sh run.sh config remove # remove an override from COMPOSE_FILE in .env +# sh run.sh compose-config # dump fully-resolved docker compose config +# sh run.sh secrets # print key passwords and API keys from .env +# + +set -e + +cd "$(dirname "$0")" + +if [ ! -f docker-compose.yml ]; then + echo "ERROR: docker-compose.yml not found in $(pwd)" >&2 + exit 1 +fi + +# Normalize an override argument: +# pg17 -> docker-compose.pg17.yml +# docker-compose.pg17.yml -> docker-compose.pg17.yml +# ./docker-compose.pg17.yml -> docker-compose.pg17.yml +# docker-compose.yml -> error (base file, always implicit) +normalize_override() { + arg="${1#./}" + case "$arg" in + docker-compose.yml) + echo "ERROR: docker-compose.yml is the base file, always included" >&2 + return 1 + ;; + docker-compose.*.yml) + echo "$arg" + ;; + *) + echo "docker-compose.${arg}.yml" + ;; + esac +} + +# Read COMPOSE_FILE from .env (stripping quotes and CR). +read_compose_file() { + [ -f .env ] || return 0 + grep '^COMPOSE_FILE=' .env | head -n1 | cut -d= -f2- | tr -d "\r\"'" +} + +# Pretty-print the effective compose file list. +print_config() { + val="$1" + [ -z "$val" ] && val="docker-compose.yml" + echo "COMPOSE_FILE=$val" + echo "compose files:" + OLD_IFS=$IFS + IFS=: + for f in $val; do + echo " $f" + done + IFS=$OLD_IFS + echo "" +} + +# Update or append COMPOSE_FILE in .env. +write_compose_file() { + new_value="$1" + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + new_line="COMPOSE_FILE=$new_value" + if grep -q '^COMPOSE_FILE=' .env; then + sed -i.old -e "s|^COMPOSE_FILE=.*$|$new_line|" .env + rm -f .env.old + else + cat >> .env <. +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +############ +$new_line +EOF + fi +} + +# Echoes the list of services (one per line) minus those passed as args. +# Warns on unknown names; returns 1 if no services remain. +services_except() { + all_services=$(docker compose config --services) + filtered="$all_services" + for ex in "$@"; do + echo "$all_services" | grep -qFx "$ex" \ + || echo "Warning: '$ex' is not a service in this project" >&2 + filtered=$(echo "$filtered" | grep -vFx "$ex" || true) + done + if [ -z "$filtered" ]; then + echo "No services left after applying --except" >&2 + return 1 + fi + printf '%s\n' "$filtered" +} + +CMD="${1:-help}" +[ "$#" -gt 0 ] && shift + +case "$CMD" in + start|up) + exec docker compose up -d --wait "$@" + ;; + stop|down) + exec docker compose down "$@" + ;; + restart) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") restart --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose restart $services + fi + exec docker compose restart "$@" + ;; + recreate) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") recreate --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose up -d --wait --force-recreate --no-deps $services + fi + if [ $# -eq 0 ]; then + docker compose down + exec docker compose up -d --wait + fi + # Single-service recreate: force-recreate the named services only, + # leave their dependencies running. + exec docker compose up -d --wait --force-recreate --no-deps "$@" + ;; + status|ps) + exec docker compose ps "$@" + ;; + logs) + exec docker compose logs -f "$@" + ;; + inspect) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") inspect [docker-inspect-args]" >&2; exit 1; } + svc="$1"; shift + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect "$cid" "$@" + ;; + printenv) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") printenv " >&2; exit 1; } + svc="$1" + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$cid" + ;; + pull) + exec docker compose pull "$@" + ;; + compose-config) + exec docker compose config "$@" + ;; + config) + sub="${1:-show}" + [ "$#" -gt 0 ] && shift + current=$(read_compose_file) + case "$sub" in + show) + print_config "$current" + ;; + add) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config add ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + if [ ! -f "$file" ]; then + echo "ERROR: $file not found" >&2 + exit 1 + fi + case ":$new_value:" in + *":$file:"*) echo "Already present: $file" ;; + *) new_value="$new_value:$file"; changed=true ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + remove|rm) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config remove ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + case ":$new_value:" in + *":$file:"*) + # Drop $file by rebuilding the colon list + tmp="" + OLD_IFS=$IFS + IFS=: + for tok in $new_value; do + [ "$tok" = "$file" ] || tmp="${tmp:+$tmp:}$tok" + done + IFS=$OLD_IFS + new_value="$tmp" + changed=true + ;; + *) echo "Not present: $file" ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + *) + echo "Unknown config subcommand: $sub" >&2 + echo "Use: config | config add ... | config remove ..." >&2 + exit 1 + ;; + esac + ;; + secrets) + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + for var in POSTGRES_PASSWORD DASHBOARD_PASSWORD \ + SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY \ + S3_PROTOCOL_ACCESS_KEY_ID S3_PROTOCOL_ACCESS_KEY_SECRET; do + line=$(grep "^${var}=" .env | head -n1) + if [ -n "$line" ]; then + echo "$line" + else + echo "${var}=" + fi + done + echo "" + ;; + help|-h|--help) + cat < + +Commands: + start Start the stack (docker compose up -d --wait) + stop Stop the stack (docker compose down) + restart [service] Restart the stack (or named services) + restart --except ... + Restart all services except the named ones + recreate [service] Stop then start, or force-recreate one service (--no-deps) + recreate --except ... + Force-recreate all services except the named ones (--no-deps) + status Show service status + logs [service] Follow logs (optionally for a single service) + inspect Inspect a service's container (forwards extra args to docker inspect) + printenv Print a service's environment variables (one per line) + pull Pull all images + config Show the active COMPOSE_FILE list + config add Add an override to COMPOSE_FILE in .env (short name or full filename) + config remove Remove an override from COMPOSE_FILE in .env + compose-config Dump the fully-resolved docker compose config + secrets Show key passwords and API keys from .env + +EOF + ;; + *) + echo "Unknown command: $CMD" >&2 + echo "Run '$0 help' for usage." >&2 + exit 1 + ;; +esac diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..9a22766 --- /dev/null +++ b/setup.sh @@ -0,0 +1,341 @@ +#!/bin/sh +# +# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora). +# +# What it does: +# 1. Installs prerequisites: git, openssl, jq, ca-certificates +# 2. Installs Docker Engine + Compose plugin (if missing) +# 3. Optionally installs the AWS CLI v2 (--with-aws) +# 4. Sparse-clones the repo to extract the contents of ./docker +# 5. Creates a project directory in CWD and copies docker/* into it +# 6. Prompts for the main URLs and writes them to .env +# 7. Generates secrets and asymmetric API keys via utils/*.sh +# +# Usage: +# sh setup.sh # interactive +# sh setup.sh -y # accept defaults, no prompts +# sh setup.sh --project-dir my-supabase # name the project directory +# sh setup.sh --skip-deps # skip system-package installation +# sh setup.sh --with-aws # also install the AWS CLI v2 +# +# curl -fsSL | sh # bootstrap from scratch in CWD +# + +set -e + +PROJECT_DIR="supabase-project" +SKIP_DEPS=0 +WITH_AWS=0 +ASSUME_YES=0 + +print_help() { + cat < Name of the project directory (default: supabase-project) + --skip-deps Skip installation of system packages + --with-aws Install the AWS CLI v2 + -y, --yes Non-interactive: accept defaults, no prompts + -h, --help Show this help and exit +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -p|--project-dir) PROJECT_DIR="$2"; shift 2 ;; + --skip-deps) SKIP_DEPS=1; shift ;; + --with-aws) WITH_AWS=1; shift ;; + -y|--yes) ASSUME_YES=1; shift ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; + esac +done + +if [ "$(id -u)" = "0" ]; then + SUDO="" +else + SUDO="sudo" +fi + +log() { printf "===> %s\n" "$*"; } +warn() { printf "WARNING: %s\n" "$*" >&2; } +die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } + +# Prompt with a default; echoes the chosen value on stdout. +# Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh). +# Falls back to the default with -y or when no controlling terminal exists. +ask() { + # ask -> echoes chosen value + if [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; then + printf '%s' "$2" + return + fi + printf "%s [%s]: " "$1" "$2" > /dev/tty + read -r reply < /dev/tty + [ -z "$reply" ] && reply="$2" + printf '%s' "$reply" +} + +OS_FAMILY="" +OS_ID="" +OS_CODENAME="" + +detect_os() { + [ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only." + # shellcheck disable=SC1091 + . /etc/os-release + OS_ID="$ID" + OS_CODENAME="${VERSION_CODENAME:-}" + case "$ID" in + ubuntu|debian) OS_FAMILY="debian" ;; + centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;; + *) + case "${ID_LIKE:-}" in + *debian*|*ubuntu*) OS_FAMILY="debian" ;; + *rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;; + *) die "Unsupported distribution: $ID" ;; + esac + ;; + esac + log "Detected OS: $ID ($OS_FAMILY)" +} + +pkg_update() { + if [ "$OS_FAMILY" = "debian" ]; then + $SUDO apt-get update -qq -y + else + $SUDO dnf makecache -q -y || true + fi +} + +pkg_install() { + if [ "$OS_FAMILY" = "debian" ]; then + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get install -qq -y "$@" + else + $SUDO dnf install -q -y "$@" + fi +} + +install_base_packages() { + log "Installing base packages: git, openssl, jq, ca-certificates" + pkg_update + if [ "$OS_FAMILY" = "debian" ]; then + pkg_install git openssl jq ca-certificates \ + apt-transport-https gnupg lsb-release + else + pkg_install git openssl jq ca-certificates dnf-plugins-core + fi +} + +docker_present() { + command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 +} + +install_docker() { + if docker_present; then + log "Docker already installed: $(docker --version)" + return 0 + fi + + log "Installing Docker Engine and Compose plugin" + if [ "$OS_FAMILY" = "debian" ]; then + $SUDO install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \ + | $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg + $SUDO chmod a+r /etc/apt/keyrings/docker.gpg + codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}" + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \ + | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null + $SUDO apt-get update -y + pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + else + # Amazon Linux + if [ "$OS_ID" = "amzn" ]; then + # Install Docker from the repo + pkg_install docker + # Install Docker Compose + $SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \ + $SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose && \ + $SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + else + repo_distro="centos" + case "$OS_ID" in + fedora) repo_distro="fedora" ;; + rhel) repo_distro="rhel" ;; + esac + $SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \ + || $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" + pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + fi + fi + + log "Enabling and starting docker service" + $SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually." + + docker_present || die "Docker installation finished but 'docker compose' is still unavailable." +} + +install_aws() { + if command -v aws >/dev/null 2>&1; then + log "AWS CLI already installed: $(aws --version 2>&1)" + return 0 + fi + + arch=$(uname -m) + case "$arch" in + x86_64|amd64) aws_arch="x86_64" ;; + aarch64|arm64) aws_arch="aarch64" ;; + *) die "Unsupported architecture for AWS CLI: $arch" ;; + esac + + log "Installing AWS CLI v2 (${aws_arch})" + command -v unzip >/dev/null 2>&1 || pkg_install unzip + tmp=$(mktemp -d) + ( + cd "$tmp" + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip + unzip -q -o awscliv2.zip + $SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update + ) + rm -rf "$tmp" +} + +SRC_DIR="" +SRC_TMP="" + +prepare_source() { + log "Sparse-cloning supabase repo" + SRC_TMP=$(mktemp -d) || return 1 + git clone --filter=blob:none --no-checkout --depth=1 --quiet \ + https://github.com/supabase/supabase "$SRC_TMP/supabase" 2>/dev/null || \ + { rm -rf "$SRC_TMP"; return 1; } + + cd "$SRC_TMP/supabase" || { rm -rf "$SRC_TMP"; return 1; } + git sparse-checkout init --cone && \ + git sparse-checkout set docker && \ + git checkout --quiet 2>/dev/null + SRC_DIR="$PWD/docker" + cd - > /dev/null +} + +cleanup_src_tmp() { + if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then + rm -rf "$SRC_TMP" + fi +} +trap cleanup_src_tmp EXIT + +read_env() { + grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2- +} + +# --- Main --- + +log "Setup starting in $(pwd)" +log "This may take several minutes..." + +if [ "$SKIP_DEPS" = "1" ]; then + log "Skipping system-package installation (--skip-deps)" +else + detect_os + install_base_packages + install_docker +fi + +if [ "$WITH_AWS" = "1" ]; then + install_aws +fi + +# Idempotent re-run: if CWD is already a set-up project, skip bootstrap. +# A clone has docker-compose.yml + utils/ but only .env.example; +# a set-up project also has a real .env. +if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then + log "Already in a Supabase project directory; skipping bootstrap." + exit 0 +fi + +prepare_source + +target="$(pwd)/$PROJECT_DIR" +if [ -e "$target" ]; then + die "Target $target already exists. Pick a different name with --project-dir" +fi + +log "Creating project at $target" +mkdir -p "$target" +cp -rf "$SRC_DIR/." "$target/" +if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then + cp "$target/.env.example" "$target/.env" +fi + +cd "$target" + +current_public_url=$(read_env SUPABASE_PUBLIC_URL) +current_api_url=$(read_env API_EXTERNAL_URL) +current_site_url=$(read_env SITE_URL) + +[ -z "$current_public_url" ] && current_public_url="http://localhost:8000" +[ -z "$current_api_url" ] && current_api_url="$current_public_url" +[ -z "$current_site_url" ] && current_site_url="http://localhost:3000" + +if [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; then + log "Non-interactive: keeping default URLs (edit .env to change)" +else + echo "" + echo "Configure the main URLs (press Enter to accept the default)." + echo "" +fi + +public_url=$(ask "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url") +api_url=$(ask "API_EXTERNAL_URL (Auth callbacks)" "$public_url") +site_url=$(ask "SITE_URL (default Auth redirect)" "$current_site_url") + +# Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish) +public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||') +case "$public_host" in + localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;; + *) current_proxy_domain="$public_host" ;; +esac +[ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com" + +proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain") + +# Derive CERTBOT_EMAIL = admin@. +# Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after. +domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }') +certbot_email="admin@${domain_root}" +log "Setting CERTBOT_EMAIL=${certbot_email}" + +sed -i.old \ + -e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \ + -e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \ + -e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \ + -e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \ + -e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \ + .env +rm -f .env.old + +log "Generating secrets and legacy API keys" +sh utils/generate-keys.sh --update-env + +log "Generating asymmetric key pair and opaque API keys" +sh utils/add-new-auth-keys.sh --update-env + +log "Pulling Docker images" +docker compose pull || warn "docker compose pull failed; you can retry later." + +echo "" +echo "Setup complete. Project ready at: $(pwd)" +echo "" +echo "Next steps:" +echo " cd $(pwd)" +echo " sh run.sh config" +echo " sh run.sh secrets" +echo " sh run.sh start" +echo "" +echo "To enable docker-compose overrides (pg17, envoy, caddy, nginx, rustfs, s3, logs):" +echo " sh run.sh config add pg17" +echo "" diff --git a/tests/docker-compose.rustfs.test.yml b/tests/docker-compose.rustfs.test.yml new file mode 100644 index 0000000..e7a7641 --- /dev/null +++ b/tests/docker-compose.rustfs.test.yml @@ -0,0 +1,11 @@ +# Test override: exposes the S3 backend port for direct testing. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.rustfs.yml \ +# -f ./tests/docker-compose.rustfs.test.yml up -d +# + +services: + rustfs: + ports: + - "${S3_BACKEND_TEST_PORT:-9100}:9000" diff --git a/tests/docker-compose.s3.test.yml b/tests/docker-compose.s3.test.yml new file mode 100644 index 0000000..c2e34f4 --- /dev/null +++ b/tests/docker-compose.s3.test.yml @@ -0,0 +1,13 @@ +# Test override: exposes the S3 backend port for direct testing. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml \ +# -f ./tests/docker-compose.s3.test.yml up -d +# +# When swapping to a different S3 backend (e.g. RustFS), update the +# service name and internal port to match the new backend. + +services: + minio: + ports: + - "${S3_BACKEND_TEST_PORT:-9100}:9000" diff --git a/tests/test-auth-keys.sh b/tests/test-auth-keys.sh new file mode 100644 index 0000000..03a90f9 --- /dev/null +++ b/tests/test-auth-keys.sh @@ -0,0 +1,369 @@ +#!/bin/sh +# +# Test API key types and asymmetric auth against a running self-hosted instance. +# +# Usage: +# sh test-auth-keys.sh # Uses http://localhost:8000 +# sh test-auth-keys.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with all keys configured +# - jq (for JSON parsing) +# - node >= 16 (for HS256 token minting test only) +# + +set -e + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in jq node; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-) +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name (HTTP $actual)" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +echo "" +echo "=== Testing against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Route tests with API key types +# --------------------------------------------- + +echo "--- REST API (/rest/v1/) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")" +check "Legacy SERVICE_ROLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" + check "New SECRET_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")" +else + echo " SKIP: Opaque keys not configured" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/")" +check "Invalid key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")" + +echo "" +echo "--- Auth (/auth/v1/settings) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/auth/v1/settings")" + +echo "" +echo "--- Storage (/storage/v1/bucket) ---" +# Storage has no key-auth - passes through, Storage returns its own errors +check "No key -> not 401 (Storage handles auth)" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # With opaque key, Kong translates to asymmetric JWT in Authorization + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- Storage S3 (/storage/v1/s3/) ---" +# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression +# passes the Authorization header through unchanged for non-sb_ values +check "S3 route accessible" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)" + +echo "" +echo "--- GraphQL (/graphql/v1) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +echo "" +echo "--- Realtime REST (/realtime/v1/api/) ---" +# Realtime REST API - expect 200 or other non-401 response with valid key +check "Legacy ANON_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" != "401" ] && echo true || echo false)" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/realtime/v1/api/tenants")" + +echo "" +echo "--- supabase-js style requests (apikey + Authorization) ---" +# supabase-js sends both apikey header AND Authorization: Bearer +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "apikey + Authorization: Bearer sb_ (replace path)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "Legacy apikey + Authorization: Bearer " "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ANON_KEY")" + +echo "" +echo "--- Edge cases ---" +# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "sb_ in Authorization only (no apikey) -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- JWKS endpoint ---" +check "JWKS public endpoint (no auth)" "200" \ + "$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")" + +# Verify JWKS content: should have EC key, should NOT have symmetric key +jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json") +jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null) +jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null) +check "JWKS contains EC public key" "true" "$jwks_has_ec" +check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct" + +#echo "" +#echo "--- OAuth metadata endpoint ---" +#check "well-known oauth (no auth)" "200" \ +# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")" + +echo "" +echo "--- Realtime WebSocket upgrade ---" +# Test that WebSocket upgrade request gets through (expect 101 or non-401) +# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open) +ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") +# 101 = upgrade success, 000 = timeout (connection stayed open = success) +check "WebSocket upgrade with legacy key -> not 401" "true" \ + "$([ "$ws_status" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") + check "WebSocket upgrade with opaque key -> not 401" "true" \ + "$([ "$ws_status_new" != "401" ] && echo true || echo false)" +fi + +# --------------------------------------------- +# 2. User session JWT tests +# --------------------------------------------- + +echo "" +echo "--- User session JWT ---" + +# Create user via admin API (works regardless of email autoconfirm setting) +test_email="test-keys-$$@example.com" +test_password="test-password-123456" + +create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +# Sign in to get session JWT +auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + +access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null) + +if [ -n "$access_token" ]; then + # Check the algorithm in the JWT header + jwt_alg=$(echo "$access_token" | cut -d. -f1 | \ + jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null) + + if [ -n "$jwt_alg" ]; then + echo " INFO: User session JWT signed with: $jwt_alg" + if [ "$jwt_alg" = "ES256" ]; then + check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg" + else + check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg" + fi + fi + + # Use the session JWT with PostgREST + check "Session JWT with PostgREST" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # Use the session JWT with Storage + check "Session JWT with Storage" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # CRITICAL: Authenticated user + opaque key (most common supabase-js flow) + # supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer + # The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT + if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + echo "" + echo "--- Authenticated user + opaque key (critical path) ---" + check "Opaque apikey + user JWT -> PostgREST uses user JWT" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + fi +else + check "Sign in test user" "true" "false" +fi + +# Clean up test user +if [ -n "$test_user_id" ]; then + curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" +fi + +# --------------------------------------------- +# 3. HS256 backward compatibility +# --------------------------------------------- + +echo "" +echo "--- HS256 backward compatibility ---" + +# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token) +hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e " +const crypto = require('crypto'); +const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url'); +const payload = Buffer.from(JSON.stringify({ + role:'anon',iss:'supabase', + iat:Math.floor(Date.now()/1000), + exp:Math.floor(Date.now()/1000)+3600 +})).toString('base64url'); +const sig = crypto.createHmac('sha256',process.env.JWT_SECRET) + .update(header+'.'+payload).digest('base64url'); +console.log(header+'.'+payload+'.'+sig); +" 2>/dev/null) + +if [ -n "$hs256_token" ]; then + check "HS256 token with PostgREST (backward compat)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $hs256_token")" +else + echo " SKIP: Could not mint HS256 token (node required)" +fi + +# --------------------------------------------- +# 4. JWT_KEYS format validation +# --------------------------------------------- + +echo "" +echo "--- JWT_KEYS format ---" + +JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-) +if [ -n "$JWT_KEYS_VAL" ]; then + # Auth expects a JSON array, not a JWKS object + jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array" + + jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign" +else + echo " SKIP: JWT_KEYS not configured" +fi + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-container-logs.sh b/tests/test-container-logs.sh new file mode 100644 index 0000000..75ecd10 --- /dev/null +++ b/tests/test-container-logs.sh @@ -0,0 +1,165 @@ +#!/bin/sh +# +# Verify all self-hosted Supabase services started correctly by checking log output. +# +# Usage: +# sh test-container-logs.sh +# +# Prerequisites: +# - Running self-hosted Supabase instance (docker compose up) +# + +set -e + +pass=0 +fail=0 +project_name="${COMPOSE_PROJECT_NAME:-supabase}" + +fail_msg() { + fail=$((fail + 1)) + echo " FAIL: $1" +} + +pass_msg() { + pass=$((pass + 1)) + echo " PASS: $1" +} + +# The `docker ps` fallback in the helpers below exists because the script +# doesn't know which compose `-f` flags the user ran `up` with. `docker compose +# ps` only sees services defined in the currently loaded compose files, but +# compose stamps `com.docker.compose.{project,service}` labels at `up` time - +# so a label-based lookup finds the container regardless of which override +# files are active in this shell. + +is_service_running() { + service="$1" + if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then + return 0 + fi + + docker ps --filter "label=com.docker.compose.project=$project_name" \ + --filter "label=com.docker.compose.service=$service" \ + --filter "status=running" \ + --quiet | grep -q '.' +} + +get_container_id() { + service="$1" + + container_id=$(docker compose ps -q "$service" 2>/dev/null || true) + if [ -n "$container_id" ]; then + printf '%s' "$container_id" + return + fi + + container_id=$(docker ps -a \ + --filter "label=com.docker.compose.project=$project_name" \ + --filter "label=com.docker.compose.service=$service" \ + --quiet) + + set -- $container_id + printf '%s' "$1" +} + +# Check that a service's logs contain all expected patterns +check_logs() { + service="$1" + shift + + logs=$(docker compose logs "$service" 2>/dev/null || true) + if [ -z "$logs" ]; then + container_id=$(get_container_id "$service") + if [ -n "$container_id" ]; then + logs=$(docker logs "$container_id" 2>&1 || true) + fi + fi + + if [ -z "$logs" ]; then + fail_msg "$service (no logs found)" + return + fi + + for pattern in "$@"; do + if ! echo "$logs" | grep -q -i -E "$pattern"; then + fail_msg "$service (missing: $pattern)" + return + fi + done + + pass_msg "$service" +} + +check_logs_if_running() { + service="$1" + shift + + if is_service_running "$service"; then + check_logs "$service" "$@" + else + pass_msg "$service (skipped: service not running)" + fi +} + +echo "" +echo "=== Checking service startup logs ===" +echo "" + +check_logs db \ + 'PostgreSQL init process complete; ready for start up.|Skipping initialization' + +check_logs auth \ + 'db worker started' + +check_logs_if_running kong \ + 'init.lua.*declarative config loaded' + +check_logs_if_running api-gw \ + 'Envoy configuration generated successfully' \ + 'Starting Envoy...' + +check_logs rest \ + 'Schema cache loaded in.*milliseconds' + +check_logs realtime \ + 'Starting Realtime' \ + 'Connected to Postgres database' \ + 'Janitor started' \ + 'Starting MetricsCleaner' + +check_logs storage \ + 'Started Successfully' + +check_logs studio \ + 'ready in.*s$' + +check_logs meta \ + 'Server listening at http' + +check_logs functions \ + 'main function started' + +check_logs_if_running analytics \ + 'Access LogflareWeb.Endpoint at http://localhost:4000' \ + 'Executing startup tasks' \ + 'Ensuring single tenant user is seeded' + +check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' + +check_logs_if_running vector \ + 'Vector has started' + +check_logs imgproxy \ + 'Starting server at :5001' + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + echo "Inspect logs: docker compose logs " + echo "" + exit 1 +fi diff --git a/tests/test-pg17-upgrade.sh b/tests/test-pg17-upgrade.sh new file mode 100644 index 0000000..0015b86 --- /dev/null +++ b/tests/test-pg17-upgrade.sh @@ -0,0 +1,239 @@ +#!/bin/sh +# +# Test Postgres 15 -> 17 upgrade for self-hosted Supabase. +# +# Seeds test data on a running Postgres 15 stack, runs the upgrade script, +# and verifies data integrity + service connectivity using pgTAP. +# +# Usage: +# cd docker/ +# sudo bash tests/test-pg17-upgrade.sh +# +# Prerequisites: +# - Running self-hosted Supabase with a clean, tests-only Postgres 15: +# docker compose up -d +# - .env file with POSTGRES_PASSWORD, ANON_KEY +# + +set -eu + +DB_CONTAINER="supabase-db" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the docker/ directory." + exit 1 +fi + +pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-) +anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true) + +if [ -z "$pg_password" ]; then + echo "Error: POSTGRES_PASSWORD not set in .env" + exit 1 +fi + +run_sql() { + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@" +} + +echo "" +echo "=== Postgres 15 -> 17 Upgrade Test ===" +echo "" + +# --- Verify we're starting from Postgres 15 -------------------------------- + +current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1) +case "$current_version" in + 15.*) echo "Starting version: PostgreSQL $current_version" ;; + 17.*) echo "Error: Already on Postgres 17. Start with a PG15 stack."; exit 1 ;; + *) echo "Error: Unexpected version: $current_version"; exit 1 ;; +esac + +# --- Seed test data -------------------------------------------------------- +# Note: this script is designed to run against a fresh docker-compose stack, +# not an existing database with user data. + +echo "" +echo "Seeding test data on Postgres 15..." + +run_sql <<'EOSQL' +-- Test table with various column types +CREATE TABLE IF NOT EXISTS public._upgrade_test ( + id serial PRIMARY KEY, + name text NOT NULL, + value numeric(10,2), + created_at timestamptz DEFAULT now(), + metadata jsonb +); + +TRUNCATE public._upgrade_test; +INSERT INTO public._upgrade_test (name, value, metadata) VALUES + ('alpha', 1.50, '{"tag": "a"}'), + ('bravo', 2.75, '{"tag": "b"}'), + ('charlie', 3.00, '{"tag": "c"}'), + ('delta', 4.25, '{"tag": "d"}'), + ('echo', 5.99, '{"tag": "e"}'); + +-- Index +CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name); + +-- Function +CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int) +RETURNS int LANGUAGE sql IMMUTABLE AS $$ + SELECT n * 2; +$$; + +-- Grant access so PostgREST can read it +GRANT SELECT ON public._upgrade_test TO anon, authenticated; +EOSQL + +pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]') +pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]') + +echo " Rows: $pre_count" +echo " Checksum: $pre_checksum" + +# --- Run upgrade ----------------------------------------------------------- + +echo "" +echo "Running upgrade script..." +echo "" + +bash utils/upgrade-pg17.sh --yes + +echo "" + +# --- Verify with pgTAP ---------------------------------------------------- + +echo "Running pgTAP verification..." +echo "" + +# Use a non-quoted heredoc so $pre_count and $pre_checksum are interpolated +run_sql </dev/null) || rest_status="000" + check "PostgREST connectivity" "200" "$rest_status" +fi + +# Auth health (needs apikey header through Kong) +if [ -n "$anon_key" ]; then + auth_status=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "apikey: $anon_key" \ + "http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000" + check "Auth service health" "200" "$auth_status" +fi + +echo "" +echo " Services: $pass passed, $fail failed" + +# --- Clean up test artifacts ---------------------------------------------- + +echo "" +echo "Cleaning up test artifacts..." + +run_sql <<'EOSQL' || true +DROP FUNCTION IF EXISTS public._upgrade_test_fn(int); +DROP TABLE IF EXISTS public._upgrade_test; +DROP EXTENSION IF EXISTS pgtap; +EOSQL + +# --- Summary -------------------------------------------------------------- + +echo "" +if [ "$fail" -gt 0 ]; then + echo "=== SOME TESTS FAILED ===" + exit 1 +fi + +echo "=== Upgrade test passed ===" +echo "" +echo "To reclaim disk space:" +echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz" +echo "" diff --git a/tests/test-s3-backend.sh b/tests/test-s3-backend.sh new file mode 100644 index 0000000..799994a --- /dev/null +++ b/tests/test-s3-backend.sh @@ -0,0 +1,358 @@ +#!/bin/sh +# +# Test S3 backend directly, bypassing the Storage service. +# +# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles +# all S3 operations that Storage relies on. Uses the aws cli so the test +# is backend-agnostic — no vendor-specific tools required. +# +# Usage: +# sh test-s3-backend.sh # Uses localhost:9100 +# sh test-s3-backend.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 backend + test override: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml \ +# -f ./tests/docker-compose.s3.test.yml up -d +# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET +# - aws cli v2 +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read backend credentials from .env +BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-) +BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-) +GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) +REGION="${REGION:-us-east-1}" + +if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then + echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws commands against the backend directly +s3() { + AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \ + aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1 +} + +bucket_name="backend-test-$$" + +echo "" +echo "=== S3 backend test against $BACKEND_URL ===" +echo "" + +# --------------------------------------------- +# 1. ListBuckets (backend reachable) +# --------------------------------------------- + +echo "--- Connectivity ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "Backend reachable (ListBuckets)" "true" "$list_ok" + +if [ "$list_ok" != "true" ]; then + echo " Cannot reach backend. Is the test override running?" + echo " Response: $list_output" + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# --------------------------------------------- +# 2. Verify GLOBAL_S3_BUCKET exists +# --------------------------------------------- + +echo "" +echo "--- Storage bucket ---" +if [ -n "$GLOBAL_S3_BUCKET" ]; then + storage_bucket_exists=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) + check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists" +else + echo " SKIP: GLOBAL_S3_BUCKET not set" +fi + +# --------------------------------------------- +# 3. CreateBucket +# --------------------------------------------- + +echo "" +echo "--- CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify in ListBuckets (separate call) +bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$bucket_found" + +# --------------------------------------------- +# 4. PutObject +# --------------------------------------------- + +echo "" +echo "--- PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from backend test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject" "true" "$put_ok" + +# --------------------------------------------- +# 5. ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 6. HeadObject +# --------------------------------------------- + +echo "" +echo "--- HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 7. GetObject + content verify +# --------------------------------------------- + +echo "" +echo "--- GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from backend test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 8. CopyObject +# --------------------------------------------- + +echo "" +echo "--- CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 9. DeleteObject +# --------------------------------------------- + +echo "" +echo "--- DeleteObject ---" +s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copy_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copy_gone" + +# --------------------------------------------- +# 10. Multipart upload (7MB) +# --------------------------------------------- + +echo "" +echo "--- Multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size" + +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 11. DeleteObjects (batch delete) +# --------------------------------------------- + +echo "" +echo "--- DeleteObjects (batch) ---" +batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file" +echo "batch-a" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null +echo "batch-b" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null +echo "batch-c" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null +rm -f "$batch_file" +delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \ + --delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \ + --output json) +deleted_count=$(echo "$delete_objects_output" | jq -r '.Deleted | length' 2>/dev/null) +check "DeleteObjects removed 3 objects" "3" "$deleted_count" + +# Verify all gone +batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json) +batch_remaining=$(echo "$batch_list" | jq -r '[.Contents[]?] | length' 2>/dev/null) +check "Batch-deleted objects gone" "0" "$batch_remaining" + +# --------------------------------------------- +# 12. Presigned URLs +# --------------------------------------------- + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 13. Conditional request (IfNoneMatch) +# --------------------------------------------- + +echo "" +echo "--- Conditional request (IfNoneMatch) ---" +# --if-none-match requires aws cli v2.22+ ; skip if not supported +if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then + cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file" + echo "conditional test" > "$cond_file" + + # First put should succeed (key doesn't exist) + first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \ + --body "$cond_file" --if-none-match '*' --output json 2>&1 || true) + first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true") + check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok" + + # Second put should fail with PreconditionFailed (key exists) + cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \ + --body "$cond_file" --if-none-match '*' --output json 2>&1 || true) + cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false") + check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected" + rm -f "$cond_file" +else + echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)" +fi + +# --------------------------------------------- +# 14. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 15. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$BACKEND_URL" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 16. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Test bucket deleted" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-s3.sh b/tests/test-s3.sh new file mode 100644 index 0000000..939547d --- /dev/null +++ b/tests/test-s3.sh @@ -0,0 +1,292 @@ +#!/bin/sh +# +# Test S3 protocol endpoint for self-hosted Supabase Storage. +# +# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with +# standard S3 clients — the same way end users interact with it via +# aws cli, rclone, or other S3-compatible tools. +# +# Usage: +# sh test-s3.sh # Uses http://localhost:8000 +# sh test-s3.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 enabled: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION +# - aws cli v2 (for S3 operations) +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" +S3_ENDPOINT="$BASE_URL/storage/v1/s3" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-) +S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) + +if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then + echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws s3/s3api commands with correct endpoint and credentials +s3() { + AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1 +} + +bucket_name="s3-test-$$" + +echo "" +echo "=== S3 protocol test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. S3 ListBuckets +# --------------------------------------------- + +echo "--- S3 ListBuckets ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "ListBuckets returns valid response" "true" "$list_ok" + +# --------------------------------------------- +# 2. S3 CreateBucket +# --------------------------------------------- + +echo "" +echo "--- S3 CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify bucket appears in ListBuckets (separate call) +s3_bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$s3_bucket_found" + +# --------------------------------------------- +# 3. S3 PutObject +# --------------------------------------------- + +echo "" +echo "--- S3 PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from s3 upload test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject upload" "true" "$put_ok" + +# --------------------------------------------- +# 4. S3 ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- S3 ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 5. S3 HeadObject +# --------------------------------------------- + +echo "" +echo "--- S3 HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 6. S3 GetObject (download) + content verify +# --------------------------------------------- + +echo "" +echo "--- S3 GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from s3 upload test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 7. S3 CopyObject (server-side copy) +# --------------------------------------------- + +echo "" +echo "--- S3 CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +# Verify copied content +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 8. S3 DeleteObject +# --------------------------------------------- + +echo "" +echo "--- S3 DeleteObject ---" +s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null +# Verify object is gone +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copied_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copied_gone" + +# --------------------------------------------- +# 9. Multipart upload (>5MB triggers multipart) +# --------------------------------------------- + +echo "" +echo "--- S3 multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +# Verify size via HeadObject +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size" + +# Download and verify size +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 10. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 11. Presigned URLs +# --------------------------------------------- +# Storage supports S3 presigned URLs (query-parameter auth), but Kong's +# request-transformer adds an empty Authorization header when the Lua +# expression evaluates to nil. Storage sees typeof "" === "string" and +# enters parseAuthorizationHeader instead of parseQuerySignature. +# This test will pass once the Kong config is fixed. + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 12. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$S3_ENDPOINT" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 13. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +# Verify bucket is gone +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Bucket deleted via S3" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-self-hosted.sh b/tests/test-self-hosted.sh new file mode 100644 index 0000000..87e3c5b --- /dev/null +++ b/tests/test-self-hosted.sh @@ -0,0 +1,351 @@ +#!/bin/sh +# +# Smoke test for self-hosted Supabase - verifies core functionality end-to-end. +# +# Usage: +# sh test-self-hosted.sh # Uses http://localhost:8000 +# sh test-self-hosted.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with keys configured +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/" + exit 1 +fi + +# Read keys from .env +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-) +DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +http_body() { + url="$1" + shift + curl -s "$@" "$url" +} + +echo "" +echo "=== Self-hosted smoke test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Container health (via docker compose) +# --------------------------------------------- + +echo "--- Container health ---" +if command -v docker >/dev/null 2>&1; then + container_status=$(docker compose ps --format json 2>/dev/null | jq -rs ' + [.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))] + | (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", ")) + ' 2>/dev/null || echo "?|") + unhealthy="${container_status%%|*}" + container_issues="${container_status#*|}" + if [ "$unhealthy" = "0" ]; then + check "All containers healthy" "0" "$unhealthy" + elif [ "$unhealthy" = "?" ]; then + echo " SKIP: Could not check container health" + else + check "All containers healthy ($container_issues)" "0" "$unhealthy" + fi +else + echo " SKIP: docker not available" +fi + +# --------------------------------------------- +# 2. Studio dashboard +# --------------------------------------------- + +echo "" +echo "--- Studio dashboard ---" +# Studio may redirect (307/302) after auth - follow redirects +check "Studio accessible with basic auth" "200" \ + "$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")" +check "Studio rejects without auth" "401" \ + "$(http_status "$BASE_URL/")" + +# --------------------------------------------- +# 3. Auth: create user, sign in, get user, public signup, delete +# --------------------------------------------- + +echo "" +echo "--- Auth: user lifecycle ---" + +test_email="smoke-test-$$@example.com" +test_password="smoke-test-password-123456" + +# Create user via admin API (works regardless of email autoconfirm setting) +create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +if [ -n "$user_id" ]; then + check "Create user (admin)" "true" "true" + + # Sign in via public endpoint + signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + + access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null) + + if [ -n "$access_token" ]; then + check "Sign in user" "true" "true" + + # Get user profile with session JWT + check "Get user profile" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + else + check "Sign in user" "true" "false" + fi + + # Delete user + delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete user (admin)" "200" "$delete_status" +else + check "Create user (admin)" "true" "false" +fi + +# Public signup (optional — depends on email autoconfirm setting) +signup_email="smoke-signup-$$@example.com" +signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}") + +signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null) +signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null) + +if [ -n "$signup_token" ]; then + check "Public signup (autoconfirm on)" "true" "true" +else + echo " SKIP: Public signup (autoconfirm is off)" +fi + +# Clean up signup user if created +if [ -n "$signup_user_id" ]; then + http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 +fi + +# --------------------------------------------- +# 4. PostgREST: query +# --------------------------------------------- + +echo "" +echo "--- PostgREST ---" +check "REST API query" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY")" + +# --------------------------------------------- +# 5. GraphQL +# --------------------------------------------- + +echo "" +echo "--- GraphQL ---" +gql_resp=$(http_body "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}') +gql_has_data=$(echo "$gql_resp" | jq -r 'if .data then "true" else "false" end' 2>/dev/null) +check "GraphQL introspection" "true" "$gql_has_data" + +# --------------------------------------------- +# 6. Storage: create bucket, upload >6MB file, download, cleanup +# --------------------------------------------- + +echo "" +echo "--- Storage: bucket + file lifecycle ---" + +bucket_name="smoke-test-$$" + +# Create bucket +create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}") +check "Create bucket" "200" "$create_bucket_status" + +if [ "$create_bucket_status" = "200" ]; then + # Generate a ~7MB file + tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" + dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null + + # Upload file + upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$tmpfile") + check "Upload 7MB file" "200" "$upload_status" + + # Download file and verify size + download_size=$(curl -s \ + "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" | wc -c | tr -d ' ') + original_size=$(wc -c < "$tmpfile" | tr -d ' ') + check "Download file (size matches)" "true" \ + "$([ "$download_size" = "$original_size" ] && echo true || echo false)" + + rm -f "$tmpfile" + + # Signed URL: upload a small file, create signed URL, fetch without auth + sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: text/plain" \ + --data-binary "signed url test content") + check "Upload file for signing" "200" "$sign_upload_status" + + if [ "$sign_upload_status" = "200" ]; then + sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"expiresIn": 600}') + signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null) + + if [ -n "$signed_path" ]; then + check "Create signed URL" "true" "true" + # Fetch signed URL without any auth headers (goes through Kong) + signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path") + check "Fetch signed URL (no auth)" "signed url test content" "$signed_content" + else + check "Create signed URL" "true" "false" + fi + fi + + # Delete file + delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete file" "200" "$delete_file_status" + + # Delete signed test file + http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 + + # Delete bucket + delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete bucket" "200" "$delete_bucket_status" +fi + +# --------------------------------------------- +# 7. Edge Functions +# --------------------------------------------- + +echo "" +echo "--- Edge Functions ---" +fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ + -X POST \ + -H "Authorization: Bearer $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{}') +check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp" + +# --------------------------------------------- +# 8. pg-meta (Studio backend) +# --------------------------------------------- + +echo "" +echo "--- pg-meta ---" +check "pg-meta with service_role key" "200" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $SERVICE_ROLE_KEY")" +check "pg-meta rejects anon key" "403" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $ANON_KEY")" +check "pg-meta rejects no key" "401" \ + "$(http_status "$BASE_URL/pg/schemas")" + +echo "" +echo "--- MCP (blocked by default) ---" +check "/api/mcp blocked" "403" \ + "$(http_status "$BASE_URL/api/mcp")" +check "/mcp blocked" "403" \ + "$(http_status "$BASE_URL/mcp")" + +# --------------------------------------------- +# 9. Realtime +# --------------------------------------------- + +echo "" +echo "--- Realtime ---" +check "Realtime health" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" \ + -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/utils/add-new-auth-keys.sh b/utils/add-new-auth-keys.sh new file mode 100644 index 0000000..b728237 --- /dev/null +++ b/utils/add-new-auth-keys.sh @@ -0,0 +1,223 @@ +#!/bin/sh +# +# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation. +# +# Reads JWT_SECRET from .env and generates: +# - EC P-256 key pair (JWT_KEYS, JWT_JWKS) +# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY) +# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC) +# +# Usage: +# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env +# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env +# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file with JWT_SECRET set (run generate-keys.sh first) +# - node (>= 16) or docker +# + +set -e + +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null +} + +# Resolve how to run node: local install (>= 16) preferred, docker fallback. +if node_ok; then + node_runner="node" +else + if command -v node >/dev/null 2>&1; then + echo "Local node $(node -v) is too old (need >= 16), falling back to docker." + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "Error: requires either node (>= 16) or docker." + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "Error: docker is installed but the daemon is not running." + exit 1 + fi + + if ! docker image inspect node:22-alpine >/dev/null 2>&1; then + echo "Pulling node:22-alpine (first-run only)..." + docker pull node:22-alpine + fi + + node_runner="docker run --rm node:22-alpine node" +fi + +# Read JWT_SECRET from .env +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2- | tr -d '\r') +if [ -z "$jwt_secret" ]; then + echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Node.js does the crypto-heavy work: +# - EC P-256 keypair generation +# - JWKS construction (with symmetric key included) +# - ES256 JWT signing +# - Opaque API key generation with checksum +$node_runner -e ' +const crypto = require("crypto"); + +const jwtSecret = process.argv[1]; + +// Generate EC P-256 keypair and export as JWK +const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); +const jwkPrivate = privateKey.export({ format: "jwk" }); + +const kid = crypto.randomUUID(); + +// Symmetric key as JWK (base64url-encoded) +const octKey = { + kty: "oct", + k: Buffer.from(jwtSecret).toString("base64url"), + alg: "HS256" +}; + +// JWKS with private key (for Auth to sign tokens) +const jwksKeypair = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d }, + octKey +]}; + +// JWKS with public key only (for PostgREST, Realtime, Storage to verify) +const jwksPublic = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y }, + octKey +]}; + +// Sign ES256 JWT +function signES256(payload) { + const header = { alg: "ES256", typ: "JWT", kid }; + const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url"); + const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const data = b64Header + "." + b64Payload; + const sig = crypto.sign("SHA256", Buffer.from(data), { + key: privateKey, + dsaEncoding: "ieee-p1363" + }).toString("base64url"); + return data + "." + sig; +} + +const iat = Math.floor(Date.now() / 1000); +const exp = iat + 5 * 365 * 24 * 3600; // 5 years + +const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp }); +const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp }); + +// Generate opaque API keys with checksum +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +// Output as KEY=value lines for shell to parse +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +console.log("ANON_KEY_ASYMMETRIC=" + anonJwt); +console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt); +console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys)); +console.log("JWT_JWKS=" + JSON.stringify(jwksPublic)); +' "$jwt_secret" > "$tmpdir/output" + +# Read generated values +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) +ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-) +JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" +echo "JWT_KEYS=${JWT_KEYS}" +echo "" +echo "JWT_JWKS=${JWT_JWKS}" +echo "" +echo "Ensure the following configuration is uncommented in docker-compose.yml for the asymmetric key pair to work:" +echo "" +echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}" +echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +# Append new variables if they don't exist, or update them if they do +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done + +# Uncomment new auth configuration in docker-compose.yml +echo "Updating docker-compose.yml..." +if [ ! -f docker-compose.yml ]; then + echo "Error: docker-compose.yml not found in $(pwd)" + exit 1 +fi + +# Always fall through to the grep check +sed -i.old \ + -e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \ + -e '/^[ ]*#API_JWT_JWKS:/ s/#//' \ + -e '/^[ ]*#JWT_JWKS:/ s/#//' \ + docker-compose.yml || true + +if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \ + grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \ + grep -q '^[ ]*JWT_JWKS:' docker-compose.yml; then + echo "Done." +else + echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually." +fi diff --git a/utils/db-passwd.sh b/utils/db-passwd.sh new file mode 100644 index 0000000..40c9122 --- /dev/null +++ b/utils/db-passwd.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# +# Portions of this code are derived from Inder Singh's update-db-pass.sh +# Copyright 2025 Inder Singh. Licensed under Apache License 2.0. +# Original source: +# https://github.com/singh-inder/supabase-automated-self-host/blob/main/docker/update-db-pass.sh +# +# GitHub discussion here: +# https://github.com/supabase/supabase/issues/22605#issuecomment-3323382144 +# +# Changed: +# - POSIX shell compatibility +# - No hardcoded values for database service and admin user +# - Use .env for the admin user and database service port +# - Does _not_ set password for supabase_read_only_user (this role is not +# supposed to have a password) +# - Print all values and confirm before updating +# - Stop on any errors +# +# Heads up: +# - Updating _analytics.source_backends is not needed after PR logflare#2069 +# - Newer Logflare versions use a different table and update connection string +# + +set -e + +if ! docker compose version > /dev/null 2>&1; then + echo "Docker Compose not found." + exit 1 +fi + +if [ ! -f .env ]; then + echo "Missing .env file. Exiting." + exit 1 +fi + +# Generate random hex-only password to avoid issues with SQL/shell +new_passwd="$(openssl rand -hex 16)" +# If replacing with a custom password, avoid using @/?#:& +# https://supabase.com/docs/guides/database/postgres/roles#passwords +# new_passwd="d0notUseSpecialSymbolsForPq123-" + +# Check Postgres service +db_image_prefix="supabase.postgres:" + +compose_output=$(docker compose ps \ + --format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | \ + grep -m1 "^$db_image_prefix" || true) + +if [ -z "$compose_output" ]; then + echo "Postgres container not found. Exiting." + exit 1 +fi + +db_image=$(echo "$compose_output" | cut -f1) +db_srv_name=$(echo "$compose_output" | cut -f2) +db_srv_status=$(echo "$compose_output" | cut -f3) + +case "$db_srv_status" in + Up*) + ;; + *) + echo "Postgres container status: $db_srv_status" + echo "Exiting." + exit 1 + ;; +esac + +db_srv_port=$(grep "^POSTGRES_PORT=" .env | cut -d '=' -f 2) +port_source=" (.env):" +if [ -z "$db_srv_port" ]; then + db_srv_port="5432" + port_source=" (default):" +fi + +db_admin_user="supabase_admin" + +echo "" +echo "*** Check configuration below before updating database passwords! ***" +echo "" +echo "Service name: $db_srv_name" +echo "Service status: $db_srv_status" +echo "Service port${port_source} $db_srv_port" +echo "Image: $db_image" +echo "" +echo "Admin user: $db_admin_user" + +if ! test -t 0; then + echo "" + echo "Running non-interactively. Not updating passwords." + exit 0 +fi + +echo "New database password: $new_passwd" +echo "" + +printf "Update database passwords? (y/N) " +read -r REPLY +case "$REPLY" in + [Yy]) + ;; + *) + echo "Canceled. Not updating passwords." + exit 0 + ;; +esac + +echo "Updating passwords..." +echo "Connecting to the database service container..." + +docker compose exec -T "$db_srv_name" psql -U "$db_admin_user" -d "_supabase" -v ON_ERROR_STOP=1 </dev/null 2>&1; then + echo "Error: openssl is required but not found." + exit 1 +fi + +jwt_secret="$(gen_base64 30)" + +# Used in gen_token() +header='{"alg":"HS256","typ":"JWT"}' +iat=$(date +%s) +exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years + +# Normalizes JSON formatting so that the token matches https://www.jwt.io/ results +anon_payload="{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}" +service_role_payload="{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}" + +#echo "anon_payload=$anon_payload" +#echo "service_role_payload=$service_role_payload" + +anon_key=$(gen_token "$anon_payload") +service_role_key=$(gen_token "$service_role_payload") + +secret_key_base=$(gen_base64 48) +vault_enc_key=$(gen_hex 16) +pg_meta_crypto_key=$(gen_base64 24) + +logflare_public_access_token=$(gen_base64 24) +logflare_private_access_token=$(gen_base64 24) + +s3_protocol_access_key_id=$(gen_hex 16) +s3_protocol_access_key_secret=$(gen_hex 32) + +minio_root_password=$(gen_hex 16) + +echo "" +echo "JWT_SECRET=${jwt_secret}" +echo "" +#echo "Issued at: $iat" +#echo "Expire: $exp" +echo "ANON_KEY=${anon_key}" +echo "SERVICE_ROLE_KEY=${service_role_key}" +echo "" +echo "SECRET_KEY_BASE=${secret_key_base}" +echo "VAULT_ENC_KEY=${vault_enc_key}" +echo "PG_META_CRYPTO_KEY=${pg_meta_crypto_key}" +echo "LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}" +echo "LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}" +echo "S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}" +echo "S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}" +echo "MINIO_ROOT_PASSWORD=${minio_root_password}" +echo "" + +postgres_password=$(gen_hex 16) +dashboard_password=$(gen_hex 16) + +echo "POSTGRES_PASSWORD=${postgres_password}" +echo "DASHBOARD_PASSWORD=${dashboard_password}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +sed \ + -i.old \ + -e "s|^JWT_SECRET=.*$|JWT_SECRET=${jwt_secret}|" \ + -e "s|^ANON_KEY=.*$|ANON_KEY=${anon_key}|" \ + -e "s|^SERVICE_ROLE_KEY=.*$|SERVICE_ROLE_KEY=${service_role_key}|" \ + -e "s|^SECRET_KEY_BASE=.*$|SECRET_KEY_BASE=${secret_key_base}|" \ + -e "s|^VAULT_ENC_KEY=.*$|VAULT_ENC_KEY=${vault_enc_key}|" \ + -e "s|^PG_META_CRYPTO_KEY=.*$|PG_META_CRYPTO_KEY=${pg_meta_crypto_key}|" \ + -e "s|^LOGFLARE_PUBLIC_ACCESS_TOKEN=.*$|LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}|" \ + -e "s|^LOGFLARE_PRIVATE_ACCESS_TOKEN=.*$|LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}|" \ + -e "s|^S3_PROTOCOL_ACCESS_KEY_ID=.*$|S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}|" \ + -e "s|^S3_PROTOCOL_ACCESS_KEY_SECRET=.*$|S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}|" \ + -e "s|^MINIO_ROOT_PASSWORD=.*$|MINIO_ROOT_PASSWORD=${minio_root_password}|" \ + -e "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=${postgres_password}|" \ + -e "s|^DASHBOARD_PASSWORD=.*$|DASHBOARD_PASSWORD=${dashboard_password}|" \ + .env diff --git a/utils/reassign-owner.sh b/utils/reassign-owner.sh new file mode 100644 index 0000000..d13b170 --- /dev/null +++ b/utils/reassign-owner.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# +# Reassign ownership of public schema objects from supabase_admin to postgres. +# +# Context and documentation: +# https://supabase.com/docs/guides/self-hosting/remove-superuser-access +# +# Credits: +# Original version by Inder Singh. +# +# Usage: +# sh utils/reassign-owner.sh +# + +set -e + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose not found." + exit 1 +fi + +# Check Postgres service +db_image_prefix="supabase.postgres:" + +compose_output=$(docker compose ps \ + --format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | + grep -m1 "^$db_image_prefix" || true) + +if [ -z "$compose_output" ]; then + echo "Postgres container not found. Exiting." + exit 1 +fi + +db_srv_name=$(echo "$compose_output" | cut -f2) +db_srv_status=$(echo "$compose_output" | cut -f3) + +case "$db_srv_status" in + Up*) + ;; + *) + echo "Postgres container status: $db_srv_status" + echo "Exiting." + exit 1 + ;; +esac + +if ! test -t 0; then + echo "" + echo "Running non-interactively. Not reassigning ownership." + exit 0 +fi + +printf "Reassign public schema objects to postgres user? (y/N) " +read -r REPLY +case "$REPLY" in + [Yy]) + ;; + *) + echo "Canceled. Not reassigning ownership." + exit 0 + ;; +esac + +docker compose exec -T "$db_srv_name" psql -v ON_ERROR_STOP=1 -U supabase_admin -d postgres <<'EOF' +\echo 'Current supabase_admin-owned objects in public schema:' +SELECT c.relname, c.relkind, c.relowner::regrole +FROM pg_class c +WHERE c.relnamespace = 'public'::regnamespace +AND c.relowner = 'supabase_admin'::regrole; + +-- Reassign user objects in public schema from supabase_admin to postgres. +-- (Only affects public schema; Supabase-managed schemas stay as-is. +-- Extension-owned objects are skipped.) +DO $$ +DECLARE + rec record; + rel_count int := 0; + fn_count int := 0; + type_count int := 0; +BEGIN + -- Tables, views, sequences, materialized views, partitioned tables + FOR rec IN + SELECT c.relname, c.relkind + FROM pg_class c + WHERE c.relnamespace = 'public'::regnamespace + AND c.relowner = 'supabase_admin'::regrole + AND c.relkind IN ('r', 'v', 'S', 'm', 'p') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass + AND d.objid = c.oid + AND d.deptype = 'e' + ) + ORDER BY CASE c.relkind + WHEN 'p' THEN 0 -- partitioned parents first; cascades ownership to partitions + WHEN 'm' THEN 1 + WHEN 'r' THEN 2 + WHEN 'v' THEN 3 + WHEN 'S' THEN 4 + END + LOOP + EXECUTE format('ALTER TABLE public.%I OWNER TO postgres', rec.relname); + rel_count := rel_count + 1; + END LOOP; + + -- Functions and procedures + FOR rec IN + SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + WHERE p.pronamespace = 'public'::regnamespace + AND p.proowner = 'supabase_admin'::regrole + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_proc'::regclass + AND d.objid = p.oid + AND d.deptype = 'e' + ) + LOOP + EXECUTE format('ALTER ROUTINE public.%I(%s) OWNER TO postgres', rec.proname, rec.args); + fn_count := fn_count + 1; + END LOOP; + + -- Types (excluding array types and table-bound composites) + FOR rec IN + SELECT t.typname + FROM pg_type t + WHERE t.typnamespace = 'public'::regnamespace + AND t.typowner = 'supabase_admin'::regrole + AND t.typrelid = 0 + AND NOT EXISTS ( + SELECT 1 FROM pg_type el + WHERE el.oid = t.typelem + AND el.typarray = t.oid + ) + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_type'::regclass + AND d.objid = t.oid + AND d.deptype = 'e' + ) + LOOP + EXECUTE format('ALTER TYPE public.%I OWNER TO postgres', rec.typname); + type_count := type_count + 1; + END LOOP; + + RAISE NOTICE 'Reassigned % relation(s), % routine(s), % type(s) from supabase_admin to postgres.', + rel_count, fn_count, type_count; +END +$$; +EOF + +echo "" +echo "Done." diff --git a/utils/rotate-new-api-keys.sh b/utils/rotate-new-api-keys.sh new file mode 100644 index 0000000..48fd9f0 --- /dev/null +++ b/utils/rotate-new-api-keys.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# +# Rotate opaque API keys for a self-hosted Supabase installation. +# +# Regenerates SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY +# without touching the asymmetric key pair (JWKS) or JWT tokens. +# +# Usage: +# sh rotate-new-api-keys.sh # Interactive: prints keys, prompts to update .env +# sh rotate-new-api-keys.sh --update-env # Prints keys and writes them to .env +# sh rotate-new-api-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file (run generate-keys.sh and add-new-auth-keys.sh first) +# - node (>= 16) or docker +# + +set -e + +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null +} + +# Resolve how to run node: local install (>= 16) preferred, docker fallback. +if node_ok; then + node_runner="node" +else + if command -v node >/dev/null 2>&1; then + echo "Local node $(node -v) is too old (need >= 16), falling back to docker." + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "Error: requires either node (>= 16) or docker." + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "Error: docker is installed but the daemon is not running." + exit 1 + fi + + if ! docker image inspect node:22-alpine >/dev/null 2>&1; then + echo "Pulling node:22-alpine (first-run only)..." + docker pull node:22-alpine + fi + + node_runner="docker run --rm node:22-alpine node" +fi + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +$node_runner -e ' +const crypto = require("crypto"); + +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +' > "$tmpdir/output" + +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done diff --git a/utils/upgrade-pg17.sh b/utils/upgrade-pg17.sh new file mode 100644 index 0000000..6bdc97b --- /dev/null +++ b/utils/upgrade-pg17.sh @@ -0,0 +1,754 @@ +#!/usr/bin/env bash +# +# Requires bash (not sh) for pipefail, which ensures failures in piped +# commands are caught during the upgrade. +# +# Upgrade self-hosted Supabase Postgres from 15 to 17. +# +# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a +# temporary PG15 container, then swaps data directories and starts Postgres 17. +# +# Usage (must be run as root or with sudo): +# cd docker/ +# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation) +# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts) +# +# Requirements: +# - Docker with Docker Compose (docker compose, not docker-compose) +# - Running Supabase self-hosted setup with Postgres 15 +# - At least 2x current database size + 5 GB free disk space +# +# Backup: +# The original Postgres 15 data directory is preserved as +# ./volumes/db/data.bak.pg15 during the upgrade. +# DO NOT DELETE it until you have verified the upgrade was successful. +# +# Rollback (if the upgrade fails or you want to revert): +# 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down +# 2. rm -rf ./volumes/db/data +# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data +# 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/ +# 5. docker compose up -d +# + +# Ensure we're running under bash (not sh/zsh/dash). +# Check that $BASH ends with /bash (not /sh, /zsh, etc.). +case "${BASH:-}" in + */bash) ;; + *) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;; +esac + +set -euo pipefail + +AUTO_CONFIRM=false +for arg in "$@"; do + case "$arg" in + --yes|-y) AUTO_CONFIRM=true ;; + esac +done + +# --- Configuration ---------------------------------------------------------- + +# Image used for the upgrade tarball + complete.sh container. +# Must share glibc with PG15 (the extracted ELF binaries run inside PG15). +PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063" +# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts) +PG17_SCRIPTS_REF="17.6.1.063" +DB_CONTAINER="supabase-db" +UPGRADE_CONTAINER="supabase-pg-upgrade" +COMPLETE_CONTAINER="supabase-pg-complete" + +DATA_DIR="./volumes/db/data" +BACKUP_DIR="./volumes/db/data.bak.pg15" +# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it +PG17_TAG="${PG17_UPGRADE_IMAGE##*:}" +TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz" +# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/ +MIGRATION_DIR="./volumes/db/data_migration" + +# --- Helpers ---------------------------------------------------------------- + +die() { printf 'Error: %s\n' "$*" >&2; exit 1; } +info() { printf '\n==> %s\n' "$*"; } +warn() { printf 'Warning: %s\n' "$*" >&2; } + +# Temp dir on host for tarball + scripts (mounted into containers) +staging_dir="" +pg_password="" +current_image="" +drop_extensions="" +db_config_vol="" + +# Remove leftover containers and staging dir on exit. +# Uses an alpine container for rm because the tarball build runs as root +# inside Docker - the resulting files are root-owned and can't be deleted +# by the host user on macOS. +cleanup() { + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true + if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then + docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true + rm -rf "$staging_dir" 2>/dev/null || true + fi +} +trap cleanup EXIT + +on_interrupt() { + echo "" + warn "Interrupted. Cleaning up..." + # If db-config was chowned to PG17, restore for PG15 rollback + if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then + docker run --rm -v "${db_config_vol}:/vol" "$current_image" \ + chown -R postgres:postgres /vol/ 2>/dev/null || true + fi + die "Interrupted." +} +trap on_interrupt INT + +confirm() { + if [ "$AUTO_CONFIRM" = true ]; then return 0; fi + if ! test -t 0; then + die "This script must be run interactively, or use --yes to skip prompts." + fi + printf '%s (y/N) ' "$1" + read -r reply + case "$reply" in + [Yy]*) return 0 ;; + *) echo "Aborted."; exit 0 ;; + esac +} + +run_sql_on() { + local container=$1; shift + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$container" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@" +} + +wait_for_healthy() { + local container=$1 retries=30 + while [ $retries -gt 0 ]; do + if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then + return 0 + fi + retries=$((retries - 1)) + sleep 1 + done + die "Postgres in '$container' did not become ready in 30 seconds." +} + +# --- Pre-flight checks ----------------------------------------------------- + +preflight() { + info "Running pre-flight checks" + + if [ "$(id -u)" -ne 0 ]; then + die "This script must be run as root (e.g. sudo bash $0)." + fi + + docker compose version >/dev/null 2>&1 || die "Docker Compose not found." + command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)." + [ -f docker-compose.yml ] || die "Run this script from the docker/ directory." + [ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml." + [ -f .env ] || die "Missing .env file." + + # Resolve db-config volume (exact match on _db-config suffix or bare db-config) + db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \ + | grep -E '^db-config$|_db-config$' | head -n 1) + [ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?" + + # Read the target PG17 image from the compose override (what the user will run) + PG17_TARGET_IMAGE=$(grep 'image:.*postgres' docker-compose.pg17.yml | awk '{print $2}' | head -n 1) + [ -n "$PG17_TARGET_IMAGE" ] || die "Could not read image from docker-compose.pg17.yml." + + pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1) + [ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env." + + docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \ + || die "Container '$DB_CONTAINER' not found. Is Supabase running?" + + current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}') + case "$current_image" in + supabase/postgres:15.*|supabase.postgres:15.*) ;; + supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;; + *) die "Unexpected database image: $current_image" ;; + esac + + local status + status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}') + [ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)." + [ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR" + + if [ -d "$BACKUP_DIR" ]; then + warn "Backup directory already exists: $BACKUP_DIR" + warn "This is likely from a previous upgrade attempt." + warn "If you haven't verified that previous upgrade, roll back first:" + warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down" + warn " 2. rm -rf $DATA_DIR" + warn " 3. mv $BACKUP_DIR $DATA_DIR" + warn " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + warn " 5. docker compose up -d" + echo "" + warn "Continuing will DELETE the existing backup permanently." + confirm "Delete $BACKUP_DIR and start a fresh upgrade?" + rm -rf "$BACKUP_DIR" + fi + if [ -d "$MIGRATION_DIR" ]; then + rm -rf "$MIGRATION_DIR" + fi + + # Disk space + local data_size_kb data_size_mb avail_kb avail_mb needed_mb + data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1) + [ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR" + data_size_mb=$((data_size_kb / 1024)) + avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }') + [ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")" + avail_mb=$((avail_kb / 1024)) + needed_mb=$((data_size_mb * 2 + 5000)) + echo " Data size: ${data_size_mb} MB" + echo " Available space: ${avail_mb} MB" + echo " Estimated need: ${needed_mb} MB" + if [ "$avail_mb" -lt "$needed_mb" ]; then + warn "Disk space may be insufficient." + warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball." + confirm "Continue anyway?" + fi + + # Incompatible extensions + info "Checking for incompatible extensions" + local incompatible + incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c " + SELECT string_agg(extname, ', ') + FROM pg_extension + WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls'); + " 2>/dev/null | tr -d '[:space:]') || true + + if [ -n "$incompatible" ]; then + warn "Incompatible extensions found: $incompatible" + warn "These do not exist in Postgres 17 and must be dropped before upgrading." + warn "If you proceed, they will be dropped automatically." + warn "The original data is preserved as a backup so you can roll back." + confirm "Drop these extensions and continue with the upgrade?" + drop_extensions="$incompatible" + fi + + echo "" + echo "This script will:" + echo " 1. Pull the Postgres 17 image" + echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)" + echo " 3. Stop all Supabase services" + echo " 4. Run pg_upgrade (Postgres 15 -> 17)" + echo " 5. Apply post-upgrade patches" + echo " 6. Start Supabase with Postgres 17" + echo " 7. Apply additional migrations" + echo "" + echo " Current image: $current_image" + echo " Target image: $PG17_TARGET_IMAGE" + echo " Upgrade image: $PG17_UPGRADE_IMAGE" + echo " Data directory: $DATA_DIR" + echo " Backup location: $BACKUP_DIR" + echo "" + confirm "Proceed with the upgrade?" +} + +# --- Step 1: Pull Postgres 17 image ---------------------------------------- + +pull_image() { + info "Pulling Postgres 17 images" + docker pull "$PG17_UPGRADE_IMAGE" + if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then + docker pull "$PG17_TARGET_IMAGE" + fi +} + +# --- Step 2: Build upgrade tarball ----------------------------------------- +# +# Extracts PG17 binaries, libraries, share data, and upgrade scripts from +# the PG17 Docker image into a tarball that initiate.sh can consume. +# +# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no +# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find +# the bundled libraries. + +build_tarball() { + local tmpbase="${TMPDIR:-/tmp}" + staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX") + # World-writable so Docker containers can write to bind mounts on macOS, + # where the VM's root user has no special access to host directories. + chmod 777 "$staging_dir" + echo " Staging directory: $staging_dir" + + # Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF). + # These are no longer bundled in the latest PG17 Docker images. + info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)" + local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts" + mkdir -p "$staging_dir/scripts" + for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do + curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \ + || die "Failed to download $script from GitHub" + done + + if [ -f "$TARBALL_CACHE" ]; then + info "Using cached upgrade tarball: $TARBALL_CACHE" + cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz" + return + fi + + info "Building upgrade tarball from Postgres 17 image (first run)" + docker run --rm --user root --entrypoint bash \ + -v "$staging_dir:/export" \ + "$PG17_UPGRADE_IMAGE" \ + -c ' + set -euo pipefail + mkdir -p /export/17/bin /export/17/lib /export/17/share + + echo " Copying binaries..." + # Binaries in the nix profile are either ELF binaries or shell + # wrappers that exec a .xxx-wrapped ELF from the nix store. + # Extract the actual ELF binaries so they work outside nix. + BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres)) + for f in "$BIN_DIR"/*; do + name=$(basename "$f") + + # Skip nix wrapper-internal files + case "$name" in .*-wrapped) continue ;; esac + + # Check for ELF + if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then + cp "$f" /export/17/bin/"$name" + else + # Shell wrapper - extract the real .xxx-wrapped ELF path + wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true) + if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then + cp "$wrapped" /export/17/bin/"$name" + else + cp "$f" /export/17/bin/"$name" + fi + fi + done + + echo " Copying libraries..." + PKGLIBDIR=$(pg_config --pkglibdir) + LIBDIR=$(pg_config --libdir) + + # These paths may overlap (PKGLIBDIR and LIBDIR often point to the + # same nix store path). Use cp -Lf to handle overwrites from + # read-only nix store source files. + cp -Lf "$PKGLIBDIR"/*.so /export/17/lib/ || echo " Warning: cp from $PKGLIBDIR failed" >&2 + cp -Lf "$LIBDIR"/*.so* /export/17/lib/ || echo " Warning: cp from $LIBDIR failed" >&2 + cp -Lf /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ || echo " Warning: cp from nix profile lib failed" >&2 + + echo " Copying share data..." + + # Nix-built binaries resolve share dir relative to their location: + # /../share/postgresql/ + # so we need share/postgresql/ not just share/ + mkdir -p /export/17/share/postgresql + + # Remove cyclic symlink (timezonesets/timezonesets -> timezonesets). + rm -f /usr/share/postgresql/timezonesets/timezonesets 2>/dev/null || true + + # Pre-create subdirectories so cp -rL does not need to mkdir them. + # On macOS with Docker Desktop bind mount rejects mkdir with the nix store + # read-only (dr-xr-xr-x) permissions; pre-creating with default + # writable permissions fixed this + mkdir -p /export/17/share/postgresql/{extension,timezonesets,tsearch_data} + mkdir -p /export/17/share/postgresql/extension/{functions,procedures,tables,types} + + cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ || echo " Warning: cp share data had errors" >&2 + + # initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/ + echo " Copying extension definitions to lib..." + SHAREDIR=$(pg_config --sharedir) + cp "$SHAREDIR"/extension/*.control /export/17/lib/ || echo " Warning: cp .control from $SHAREDIR/extension failed" >&2 + cp "$SHAREDIR"/extension/*.sql /export/17/lib/ || echo " Warning: cp .sql from $SHAREDIR/extension failed" >&2 + + # Verify critical files before creating tarball + echo " Checking for key files..." + [ -f /export/17/bin/postgres ] || { echo "Error: bin/postgres missing"; exit 1; } + [ -f /export/17/share/postgresql/timezonesets/Default ] || { echo "Error: timezonesets/Default missing"; exit 1; } + ls /export/17/share/postgresql/extension/*.control >/dev/null 2>&1 || { echo "Error: no .control files in extension/"; exit 1; } + ls /export/17/lib/*.so >/dev/null 2>&1 || { echo "Error: no .so files in lib/"; exit 1; } + + echo " Creating tarball (this may take several minutes)..." + cd /export && tar czf pg_upgrade_bin.tar.gz 17/ + + echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)" + ' + + # Cache for next run + cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE" + info "Tarball cached at $TARBALL_CACHE" +} + +# --- Step 3: Drop incompatible extensions ---------------------------------- + +drop_incompatible_extensions() { + if [ -z "$drop_extensions" ]; then + return + fi + info "Dropping incompatible extensions" + + local ext + echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do + ext=$(echo "$ext" | tr -d '[:space:]') + [ -z "$ext" ] && continue + echo " DROP EXTENSION $ext CASCADE" + run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;" + done +} + +# --- Step 4: Stop services and back up ------------------------------------- + +stop_and_backup() { + info "Backing up pgsodium root key" + local key_backup="./volumes/db/pgsodium_root.key.bak.pg15" + docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \ + alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \ + || die "Failed to back up pgsodium root key from db-config volume." + echo " Saved to: $key_backup" + + info "Stopping all Supabase services" + docker compose down + + echo " Original data will be preserved as: $BACKUP_DIR" +} + +# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------ +# +# Host directories are mounted at non-standard paths (/mnt/host-*) with +# symlinks at the paths the upgrade scripts expect. This lets complete.sh's +# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and +# /data_migration) operate on symlinks rather than bind mounts. + +run_upgrade() { + local abs_data_dir abs_migration_dir + + mkdir -p "$MIGRATION_DIR" + # World-writable for macOS Docker bind mount compatibility (see build_tarball) + chmod 777 "$MIGRATION_DIR" + abs_data_dir=$(cd "$DATA_DIR" && pwd) + abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd) + + info "Starting upgrade container" + docker run -d --name "$UPGRADE_CONTAINER" \ + --entrypoint sleep \ + -v "${abs_data_dir}:/mnt/host-pgdata" \ + -v "${abs_migration_dir}:/mnt/host-migration" \ + -v "${db_config_vol}:/etc/postgresql-custom" \ + -v "${staging_dir}:/tmp/staging:ro" \ + -e PGPASSWORD="$pg_password" \ + "$current_image" \ + infinity + + info "Preparing upgrade environment" + docker exec "$UPGRADE_CONTAINER" bash -c ' + # Symlink bind mounts to the paths the upgrade scripts expect + rm -rf /var/lib/postgresql/data + ln -s /mnt/host-pgdata /var/lib/postgresql/data + ln -s /mnt/host-migration /data_migration + + mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade + cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/ + cp /tmp/staging/scripts/*.sh /tmp/upgrade/ + chmod +x /tmp/upgrade/*.sh + + # Patch CI_start_postgres to use "restart" instead of "start" so it + # is idempotent (initiate.sh starts postgres for top-level queries, + # then handle_extensions calls CI_start_postgres again) + sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh + + # Patch PGSHARENEW to match nix binary expectations (share/postgresql/) + sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh + ' + + info "Starting Postgres 15 in upgrade container" + docker exec "$UPGRADE_CONTAINER" bash -c ' + su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log" + ' + wait_for_healthy "$UPGRADE_CONTAINER" + + # initiate.sh expects the PG17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz + # (hardcoded path - copied there during container setup above). + # + # Env vars for the unwrapped nix ELF binaries in the tarball: + # LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths) + # NIX_PGLIBDIR - postgres uses this to find extension .so files + + info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)" + echo " This may take several minutes depending on database size..." + echo "" + if ! docker exec \ + -e IS_CI=true \ + -e PG_MAJOR_VERSION=17 \ + -e PGPASSWORD="$pg_password" \ + -e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \ + -e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \ + "$UPGRADE_CONTAINER" \ + /tmp/upgrade/initiate.sh 17; then + echo "" + warn "initiate.sh failed. Its cleanup may have restored the original state" + warn "(re-enabled extensions, revoked superuser). Your data directory is" + warn "unchanged - no data was moved or deleted." + warn "" + warn "Check the output above for the root cause, fix it, and re-run." + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true + die "initiate.sh failed" + fi + + info "initiate.sh completed successfully" + docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true +} + +# --- Step 6: Run complete.sh in a native PG17 container ------------------- +# +# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption, +# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container +# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed. + +run_complete() { + local abs_migration_dir + + abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd) + + info "Starting PG17 container for complete.sh" + docker run -d --name "$COMPLETE_CONTAINER" \ + --entrypoint sleep \ + -v "${abs_migration_dir}:/mnt/host-migration" \ + -v "${db_config_vol}:/etc/postgresql-custom" \ + -v "${staging_dir}:/tmp/staging:ro" \ + -e PGPASSWORD="$pg_password" \ + "$PG17_UPGRADE_IMAGE" \ + infinity + + info "Preparing complete.sh environment" + # Save original db-config ownership so we can restore it if complete.sh fails. + # complete.sh needs PG17 ownership to start postgres, but if it fails the + # user needs to fall back to PG15 which uses a different uid. + docker exec "$COMPLETE_CONTAINER" bash -c ' + stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true + ' + + docker exec "$COMPLETE_CONTAINER" bash -c ' + # Symlink bind mount so complete.sh CI wrapper can mv/rm/ln + ln -s /mnt/host-migration /data_migration + + # Remove the image default data dir (complete.sh creates a symlink here) + rm -rf /var/lib/postgresql/data + + # Fix ownership on db-config volume (PG15 uid differs from PG17) + chown -R postgres:postgres /etc/postgresql-custom/ + + # PG17 config includes this directory; may not exist from PG15 + mkdir -p /etc/postgresql-custom/conf.d + + mkdir -p /tmp/upgrade + + # Copy upgrade scripts + cp /tmp/staging/scripts/*.sh /tmp/upgrade/ + chmod +x /tmp/upgrade/*.sh + + # Patch --new-bin to use native bindir (we are in a PG17 container, + # no need for /tmp/pg_upgrade_bin/ paths) + sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh + ' + + info "Running complete.sh (post-upgrade patches, vacuum analyze)" + docker exec \ + -e IS_CI=true \ + -e PG_MAJOR_VERSION=17 \ + -e PGPASSWORD="$pg_password" \ + "$COMPLETE_CONTAINER" \ + /tmp/upgrade/complete.sh || true + + # complete.sh's ERR trap exits with 0 in some cases; check status file + local status + status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown") + if [ "$status" != "complete" ]; then + warn "complete.sh failed. Postgres log:" + docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true + echo "" + # Restore db-config ownership so PG15 can start for rollback + warn "Restoring db-config ownership for PG15..." + local orig_owner + orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true) + if [ -n "$orig_owner" ]; then + docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true + fi + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true + echo "" + echo " Your Postgres 15 data is unchanged (data swap has not happened yet)." + echo " To restart Postgres 15:" + echo " rm -rf $MIGRATION_DIR" + echo " docker compose up -d" + echo "" + die "complete.sh failed (status: $status)" + fi + + info "complete.sh finished successfully" + docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true +} + +# --- Step 7: Swap data directories ----------------------------------------- + +swap_data() { + info "Swapping data directories" + + echo " $DATA_DIR -> $BACKUP_DIR" + mv "$DATA_DIR" "$BACKUP_DIR" + + echo " $MIGRATION_DIR/pgdata -> $DATA_DIR" + mv "$MIGRATION_DIR/pgdata" "$DATA_DIR" + rm -rf "$MIGRATION_DIR" +} + +# --- Step 8: Start Postgres 17 --------------------------------------------- + +start_pg17() { + info "Starting Supabase with Postgres 17" + + # Ensure db-config volume has correct ownership and structure for PG17. + # complete.sh does this too, but just in case of partial + # failures from previous runs. + docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c ' + mkdir -p /vol/conf.d + chown -R postgres:postgres /vol/ + ' + + docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d + + echo " Waiting for Postgres 17 to be ready..." + local retries=60 + while [ $retries -gt 0 ]; do + if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then + break + fi + retries=$((retries - 1)) + sleep 2 + done + [ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds." + + local new_version + new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1) + echo " Postgres version: $new_version" + case "$new_version" in + 17.*) ;; + *) die "Expected Postgres 17.x, got: $new_version" ;; + esac +} + +# --- Step 9: Apply migrations not covered by complete.sh ------------------- +# +# These PG17 migrations run on fresh installs via initdb but not after +# pg_upgrade (init scripts don't rerun when PG_VERSION already exists). +# complete.sh doesn't cover them either. +# +# Source: postgres/migrations/db/migrations/ +# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql +# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor) +# - 20251105172723_grant_pg_reload_conf_to_postgres.sql +# - 20251121132723_correct_search_path_pgbouncer.sql + +apply_role_migrations() { + info "Applying Postgres 17 migrations" + + # Fix collation version mismatch first (upgrade used glibc 2.39, target + # image may use glibc 2.40). Do this before any other SQL to suppress + # the noisy warnings on every subsequent command. + for db in postgres template1 _supabase; do + docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d "$db" \ + -c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true + done + + # Create supabase_etl_admin role (doesn't exist in PG15 images). + # Must be created before running predefined_role_grants.sql which + # assumes it exists. + run_sql_on "$DB_CONTAINER" -c " + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN + CREATE USER supabase_etl_admin WITH LOGIN REPLICATION; + GRANT pg_read_all_data TO supabase_etl_admin; + GRANT CREATE ON DATABASE postgres TO supabase_etl_admin; + END IF; + END + \$\$;" || true + + # Run the migration files directly from the PG17 container image. + # They're idempotent (IF EXISTS / IF NOT EXISTS guards). + local migration_dir="/docker-entrypoint-initdb.d/migrations" + local migrations=" + 20250710151649_supabase_read_only_user_default_transaction_read_only.sql + 20251001204436_predefined_role_grants.sql + 20251105172723_grant_pg_reload_conf_to_postgres.sql + 20251121132723_correct_search_path_pgbouncer.sql + " + + for m in $migrations; do + echo " Running: $m" + docker exec -i \ + -e PGPASSWORD="$pg_password" \ + "$DB_CONTAINER" \ + psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \ + -f "${migration_dir}/${m}" || warn " $m failed (non-fatal)" + done +} + +# --- Step 10: Verify ------------------------------------------------------ + +verify() { + info "Verification" + + local version + version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1) + echo " $version" + + echo "" + echo " Extensions:" + run_sql_on "$DB_CONTAINER" -c \ + "SELECT extname, extversion FROM pg_extension ORDER BY extname;" + + echo "" + info "Upgrade complete!" + echo "" + echo " To use Postgres 17 going forward, always include the override:" + echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d" + echo "" + echo " Postgres 15 backup: $BACKUP_DIR" + echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15" + echo " Once satisfied, you can reclaim space:" + echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz" + echo "" + echo " Rollback (if needed):" + echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down" + echo " 2. rm -rf $DATA_DIR" + echo " 3. mv $BACKUP_DIR $DATA_DIR" + echo " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/" + echo " 5. docker compose up -d" + echo "" +} + +# --- Main ------------------------------------------------------------------- + +main() { + echo "" + echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade" + echo "================================================" + + preflight + pull_image + build_tarball + drop_incompatible_extensions + stop_and_backup + run_upgrade + run_complete + swap_data + start_pg17 + apply_role_migrations + verify +} + +main "$@" diff --git a/versions.md b/versions.md new file mode 100644 index 0000000..ee52012 --- /dev/null +++ b/versions.md @@ -0,0 +1,135 @@ +# Docker Image Versions + +## 2026-06-03 +- supabase/studio:2026.06.03-sha-0bca601 (prev supabase/studio:2026.04.27-sha-5f60601) +- supabase/gotrue:v2.189.0 (prev supabase/gotrue:v2.186.0) +- postgrest/postgrest:v14.12 (prev postgrest/postgrest:v14.8) +- supabase/realtime:v2.102.3 (prev supabase/realtime:v2.76.5) +- supabase/storage-api:v1.60.4 (prev supabase/storage-api:v1.48.26) +- supabase/postgres-meta:v0.96.6 (prev supabase/postgres-meta:v0.96.3) +- supabase/edge-runtime:v1.74.0 (prev supabase/edge-runtime:v1.71.2) +- supabase/supavisor:2.9.5 (prev supabase/supavisor:2.7.4) +- supabase/logflare:1.43.1 (prev supabase/logflare:1.36.1) + +## 2026-04-27 +- supabase/studio:2026.04.27-sha-5f60601 (prev supabase/studio:2026.04.08-sha-205cbe7) + +## 2026-04-08 +- supabase/studio:2026.04.08-sha-205cbe7 (prev supabase/studio:2026.03.16-sha-5528817) +- postgrest/postgrest:v14.8 (prev postgrest/postgrest:v14.6) +- supabase/storage-api:v1.48.26 (prev supabase/storage-api:v1.44.2) +- supabase/postgres-meta:v0.96.3 (prev supabase/postgres-meta:v0.95.2) +- supabase/logflare:1.36.1 (prev supabase/logflare:1.31.2) + +## 2026-03-16 +- supabase/studio:2026.03.16-sha-5528817 (prev supabase/studio:2026.02.16-sha-26c615c) +- kong/kong:3.9.1 (prev kong:2.8.1) +- postgrest/postgrest:v14.6 (prev postgrest/postgrest:v14.5) +- supabase/storage-api:v1.44.2 (prev supabase/storage-api:v1.37.8) +- supabase/edge-runtime:v1.71.2 (prev supabase/edge-runtime:v1.70.3) + +## 2026-02-16 +- supabase/studio:2026.02.16-sha-26c615c (prev supabase/studio:2026.01.27-sha-6aa59ff) +- supabase/gotrue:v2.186.0 (prev supabase/gotrue:v2.185.0) +- postgrest/postgrest:v14.5 (prev postgrest/postgrest:v14.3) +- supabase/realtime:v2.76.5 (prev supabase/realtime:v2.72.0) +- supabase/storage-api:v1.37.8 (prev supabase/storage-api:v1.37.1) +- supabase/edge-runtime:v1.70.3 (prev supabase/edge-runtime:v1.70.0) +- supabase/logflare:1.31.2 (prev supabase/logflare:1.30.3) +- timberio/vector:0.53.0-alpine (prev timberio/vector:0.28.1-alpine) + +## 2026-02-05 +- supabase/storage-api:v1.37.1 (prev supabase/storage-api:v1.33.5) + +## 2026-01-27 +- supabase/studio:2026.01.27-sha-6aa59ff (prev supabase/studio:2025.12.17-sha-43f4f7f) +- supabase/gotrue:v2.185.0 (prev supabase/gotrue:v2.184.0) +- postgrest/postgrest:v14.3 (prev postgrest/postgrest:v14.1) +- supabase/realtime:v2.72.0 (prev supabase/realtime:v2.68.0) +- supabase/storage-api:v1.33.5 (prev supabase/storage-api:v1.33.0) +- darthsim/imgproxy:v3.30.1 (prev darthsim/imgproxy:v3.8.0) +- supabase/postgres-meta:v0.95.2 (prev supabase/postgres-meta:v0.95.1) +- supabase/edge-runtime:v1.70.0 (prev supabase/edge-runtime:v1.69.28) +- supabase/logflare:1.30.3 (prev supabase/logflare:1.27.0) + +## 2025-12-18 +- supabase/studio:2025.12.17-sha-43f4f7f (prev supabase/studio:2025.12.09-sha-434634f) +- supabase/gotrue:v2.184.0 (prev supabase/gotrue:v2.183.0) +- supabase/postgres-meta:v0.95.1 (prev supabase/postgres-meta:v0.93.1) +- supabase/logflare:1.27.0 (prev supabase/logflare:1.26.25) + +## 2025-12-10 +- supabase/studio:2025.12.09-sha-434634f (prev supabase/studio:2025.11.26-sha-8f096b5) +- postgrest/postgrest:v14.1 (prev postgrest/postgrest:v13.0.7) +- supabase/realtime:v2.68.0 (prev supabase/realtime:v2.65.3) +- supabase/storage-api:v1.33.0 (prev supabase/storage-api:v1.32.0) +- supabase/edge-runtime:v1.69.28 (prev supabase/edge-runtime:v1.69.25) +- supabase/logflare:1.26.25 (prev supabase/logflare:1.26.13) + +## 2025-11-26 +- supabase/studio:2025.11.26-sha-8f096b5 (prev supabase/studio:2025.11.24-sha-d990ae8) +- supabase/realtime:v2.65.3 (prev supabase/realtime:v2.65.2) +- supabase/logflare:1.26.13 (prev supabase/logflare:1.26.12) + +## 2025-11-25 +- supabase/studio:2025.11.24-sha-d990ae8 (prev supabase/studio:2025.11.10-sha-5291fe3) +- supabase/gotrue:v2.183.0 (prev supabase/gotrue:v2.182.1) +- supabase/realtime:v2.65.2 (prev supabase/realtime:v2.63.0) +- supabase/storage-api:v1.32.0 (prev supabase/storage-api:v1.29.0) +- supabase/edge-runtime:v1.69.25 (prev supabase/edge-runtime:v1.69.23) +- supabase/logflare:1.26.12 (prev supabase/logflare:1.22.6) + +## 2025-11-12 +- supabase/studio:2025.11.10-sha-5291fe3 (prev supabase/studio:2025.10.27-sha-85b84e0) +- supabase/gotrue:v2.182.1 (prev supabase/gotrue:v2.180.0) +- supabase/realtime:v2.63.0 (prev supabase/realtime:v2.57.2) +- supabase/storage-api:v1.29.0 (prev supabase/storage-api:v1.28.2) +- supabase/edge-runtime:v1.69.23 (prev supabase/edge-runtime:v1.69.15) +- supabase/supavisor:2.7.4 (prev supabase/supavisor:2.7.3) + +## 2025-10-28 +- supabase/studio:2025.10.27-sha-85b84e0 (prev supabase/studio:2025.10.20-sha-5005fc6) +- supabase/realtime:v2.57.2 (prev supabase/realtime:v2.56.0) +- supabase/storage-api:v1.28.2 (prev supabase/storage-api:v1.28.1) +- supabase/postgres-meta:v0.93.1 (prev supabase/postgres-meta:v0.93.0) +- supabase/edge-runtime:v1.69.15 (prev supabase/edge-runtime:v1.69.14) + +## 2025-10-21 +- supabase/studio:2025.10.20-sha-5005fc6 (prev supabase/studio:2025.10.01-sha-8460121) +- supabase/realtime:v2.56.0 (prev supabase/realtime:v2.51.11) +- supabase/storage-api:v1.28.1 (prev supabase/storage-api:v1.28.0) +- supabase/postgres-meta:v0.93.0 (prev supabase/postgres-meta:v0.91.6) +- supabase/edge-runtime:v1.69.14 (prev supabase/edge-runtime:v1.69.6) +- supabase/supavisor:2.7.3 (prev supabase/supavisor:2.7.0) + +## 2025-10-13 +- supabase/logflare:1.22.6 (prev supabase/logflare:1.22.4) + +## 2025-10-08 +- supabase/studio:2025.10.01-sha-8460121 (prev supabase/studio:2025.06.30-sha-6f5982d) +- supabase/gotrue:v2.180.0 (prev supabase/gotrue:v2.177.0) +- postgrest/postgrest:v13.0.7 (prev postgrest/postgrest:v12.2.12) +- supabase/realtime:v2.51.11 (prev supabase/realtime:v2.34.47) +- supabase/storage-api:v1.28.0 (prev supabase/storage-api:v1.25.7) +- supabase/postgres-meta:v0.91.6 (prev supabase/postgres-meta:v0.91.0) +- supabase/logflare:1.22.4 (prev supabase/logflare:1.14.2) +- supabase/postgres:15.8.1.085 (prev supabase/postgres:15.8.1.060) +- supabase/supavisor:2.7.0 (prev supabase/supavisor:2.5.7) + +## 2025-07-15 +- supabase/gotrue:v2.177.0 (prev supabase/gotrue:v2.176.1) +- supabase/storage-api:v1.25.7 (prev supabase/storage-api:v1.24.7) +- supabase/postgres-meta:v0.91.0 (prev supabase/postgres-meta:v0.89.3) +- supabase/supavisor:2.5.7 (prev supabase/supavisor:2.5.6) + +## 2025-07-02 +- supabase/studio:2025.06.30-sha-6f5982d (prev supabase/studio:2025.06.02-sha-8f2993d) +- supabase/gotrue:v2.176.1 (prev supabase/gotrue:v2.174.0) +- supabase/storage-api:v1.24.7 (prev supabase/storage-api:v1.23.0) +- supabase/supavisor:2.5.6 (prev supabase/supavisor:2.5.1) + +## 2025-06-03 +- supabase/studio:2025.06.02-sha-8f2993d (prev supabase/studio:2025.05.19-sha-3487831) +- supabase/gotrue:v2.174.0 (prev supabase/gotrue:v2.172.1) +- supabase/storage-api:v1.23.0 (prev supabase/storage-api:v1.22.17) +- supabase/postgres-meta:v0.89.3 (prev supabase/postgres-meta:v0.89.0) diff --git a/volumes/api/envoy/cds.yaml b/volumes/api/envoy/cds.yaml new file mode 100644 index 0000000..2d47842 --- /dev/null +++ b/volumes/api/envoy/cds.yaml @@ -0,0 +1,223 @@ +resources: + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: auth + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: auth + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: auth + port_value: 9999 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /health + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: rest + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: rest + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: rest + port_value: 3000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: / + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: realtime + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: realtime + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: realtime-dev.supabase-realtime + port_value: 4000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: / + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: storage + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: storage + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: storage + port_value: 5000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /status + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: functions + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: functions + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: functions + port_value: 9000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + tcp_health_check: {} + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: meta + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: meta + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: meta + port_value: 8080 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /health + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 + + - '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster + name: studio + connect_timeout: 5s + type: STRICT_DNS + dns_refresh_rate: 5s + dns_failure_refresh_rate: + base_interval: 1s + max_interval: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: studio + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: studio + port_value: 3000 + health_checks: + - timeout: 2s + interval: 5s + unhealthy_threshold: 3 + healthy_threshold: 2 + http_health_check: + path: /project/default + circuit_breakers: + thresholds: + - priority: DEFAULT + max_connections: 10000 + max_pending_requests: 10000 + max_requests: 10000 diff --git a/volumes/api/envoy/docker-entrypoint.sh b/volumes/api/envoy/docker-entrypoint.sh new file mode 100755 index 0000000..7836038 --- /dev/null +++ b/volumes/api/envoy/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +# Generate SHA1 base64 hash for Envoy basic auth user list +PASSWORD_HASH=$(printf '%s' "${DASHBOARD_PASSWORD}" | openssl sha1 -binary | openssl base64) +DASHBOARD_BASIC_AUTH="${DASHBOARD_USERNAME}:{SHA}${PASSWORD_HASH}" + +echo "Generating Envoy configuration..." + +# Process the lds.yaml template with environment variables using sed +# Using | as delimiter since JWT tokens contain / +sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \ + -e "s|\${ANON_KEY_ASYMMETRIC}|${ANON_KEY_ASYMMETRIC}|g" \ + -e "s|\${SERVICE_ROLE_KEY}|${SERVICE_ROLE_KEY}|g" \ + -e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \ + -e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \ + -e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \ + -e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \ + /etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml + +if [ -n "$SUPABASE_SECRET_KEY" ] && \ + [ -n "$SUPABASE_PUBLISHABLE_KEY" ] && \ + [ -n "$SERVICE_ROLE_KEY_ASYMMETRIC" ] && \ + [ -n "$ANON_KEY_ASYMMETRIC" ]; then + echo "Envoy sb_ key translation enabled" +else + echo "Envoy running in legacy API key mode (sb_ keys disabled)" +fi + +echo "Envoy configuration generated successfully" +echo "Starting Envoy..." + +# Start Envoy +exec envoy -c /etc/envoy/envoy.yaml "$@" diff --git a/volumes/api/envoy/envoy.yaml b/volumes/api/envoy/envoy.yaml new file mode 100644 index 0000000..bf3dd4e --- /dev/null +++ b/volumes/api/envoy/envoy.yaml @@ -0,0 +1,27 @@ +dynamic_resources: + cds_config: + path_config_source: + path: /etc/envoy/cds.yaml + resource_api_version: V3 + lds_config: + path_config_source: + path: /etc/envoy/lds.yaml + resource_api_version: V3 + +node: + cluster: supabase_cluster + id: supabase_node + +overload_manager: + resource_monitors: + - name: envoy.resource_monitors.global_downstream_max_connections + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig + max_active_downstream_connections: 30000 + +admin: + address: + socket_address: + address: 127.0.0.1 + port_value: 9901 diff --git a/volumes/api/envoy/lds.template.yaml b/volumes/api/envoy/lds.template.yaml new file mode 100644 index 0000000..62580b1 --- /dev/null +++ b/volumes/api/envoy/lds.template.yaml @@ -0,0 +1,994 @@ +resources: + - '@type': type.googleapis.com/envoy.config.listener.v3.Listener + name: supabase + per_connection_buffer_limit_bytes: 32768 # 32 KiB + + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + normalize_path: true + merge_slashes: true + path_with_escaped_slashes_action: REJECT_REQUEST + use_remote_address: true + common_http_protocol_options: + headers_with_underscores_action: REJECT_REQUEST + upgrade_configs: + - upgrade_type: websocket + access_log: + - name: envoy.access_loggers.stdout + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + log_format: + text_format_source: + inline_string: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT% - - [%START_TIME(%d/%b/%Y:%H:%M:%S %z)%] \"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%\" %RESPONSE_CODE% %BYTES_SENT% \"%REQ(REFERER)%\" \"%REQ(USER-AGENT)%\"\n" + + route_config: + name: supabase_route + virtual_hosts: + - name: supabase_host + domains: + - '*' + cors: + allow_origin_string_match: + - safe_regex: + regex: ".*" + allow_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD,CONNECT,TRACE" + allow_headers: "*" + expose_headers: "*" + max_age: "3600" + request_headers_to_add: + - header: + key: X-Forwarded-Host + value: "%REQ(:AUTHORITY)%" + append_action: ADD_IF_ABSENT + - header: + key: X-Forwarded-Port + value: "%DOWNSTREAM_LOCAL_PORT%" + append_action: ADD_IF_ABSENT + routes: + - match: + prefix: /auth/v1/verify + route: + cluster: auth + prefix_rewrite: /verify + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/verify + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/callback + route: + cluster: auth + prefix_rewrite: /callback + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/callback + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/authorize + route: + cluster: auth + prefix_rewrite: /authorize + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/authorize + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /auth/v1/.well-known/jwks.json + route: + cluster: auth + prefix_rewrite: /.well-known/jwks.json + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/.well-known/jwks.json + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /.well-known/oauth-authorization-server + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /.well-known/oauth-authorization-server + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /sso/saml/acs + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /sso/saml/acs + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /sso/saml/metadata + route: + cluster: auth + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /sso/saml/metadata + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - name: functions-v1-all + match: + prefix: /functions/v1/ + route: + cluster: functions + prefix_rewrite: / + timeout: 150s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /functions/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /storage/v1/ + route: + cluster: storage + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /storage/v1 + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + - name: auth-v1-protected + match: + prefix: /auth/v1/ + route: + cluster: auth + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /auth/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: rest-v1-protected + match: + prefix: /rest/v1/ + route: + cluster: rest + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /rest/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: graphql-v1-protected + match: + prefix: /graphql/v1 + route: + cluster: rest + prefix_rewrite: /rpc/graphql + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /graphql/v1 + append_action: ADD_IF_ABSENT + - header: + key: Content-Profile + value: graphql_public + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: realtime-v1-api-protected + match: + prefix: /realtime/v1/api + route: + cluster: realtime + prefix_rewrite: /api + timeout: 30s + host_rewrite_literal: realtime-dev.supabase-realtime + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/api + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: realtime-v1-ws-protected + match: + prefix: /realtime/v1/ + route: + cluster: realtime + prefix_rewrite: /socket/ + timeout: 30s + host_rewrite_literal: realtime-dev.supabase-realtime + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /realtime/v1/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - name: pg-protected + match: + prefix: /pg/ + route: + cluster: meta + prefix_rewrite: / + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /pg/ + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + + - match: + prefix: /api/mcp + route: + cluster: studio + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /api/mcp + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: DENY + policies: + deny_all: + permissions: + - any: true + principals: + - any: true + + - match: + prefix: /mcp + route: + cluster: studio + prefix_rewrite: /api/mcp + timeout: 30s + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: /mcp + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.basic_auth: + '@type': >- + type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + # Block access to /mcp by default + rbac: + rules: + action: DENY + policies: + deny_all: + permissions: + - any: true + principals: + - any: true + # Enable local access (danger zone!) + # 1. Comment out the 'rbac' block above. + # 2. Uncomment and adjust the 'rbac' block below. + # 3. Add or adjust your local IPs in 'principals'. + #rbac: + # rules: + # action: ALLOW + # policies: + # allow_local: + # permissions: + # - any: true + # principals: + # - direct_remote_ip: + # address_prefix: 127.0.0.1 + # prefix_len: 32 + # - direct_remote_ip: + # address_prefix: ::1 + # prefix_len: 128 + + - match: + prefix: / + route: + cluster: studio + timeout: 30s + request_headers_to_remove: + - authorization + request_headers_to_add: + - header: + key: X-Forwarded-Prefix + value: / + append_action: ADD_IF_ABSENT + typed_per_filter_config: + envoy.filters.http.rbac: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute + rbac: + rules: + action: ALLOW + policies: + allow_all: + permissions: + - any: true + principals: + - any: true + + http_filters: + - name: envoy.filters.http.cors + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + + - name: envoy.filters.http.basic_auth + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.basic_auth.v3.BasicAuth + users: + inline_string: '${DASHBOARD_BASIC_AUTH}' + + # Copies ?apikey=... from the URL into the apikey header when clients omit the header. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + if headers:get("apikey") ~= nil then + return + end + + local path = headers:get(":path") + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return + end + + local query = string.sub(path, query_start + 1) + for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do + if key == "apikey" and value ~= "" then + headers:add("apikey", value) + return + end + end + end + + # Returns 401 for missing/invalid API keys on protected API routes. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local ANON_KEY = "${ANON_KEY}" + local SERVICE_ROLE_KEY = "${SERVICE_ROLE_KEY}" + local SUPABASE_PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SUPABASE_SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local ANON_KEY_ASYMMETRIC = "${ANON_KEY_ASYMMETRIC}" + local SERVICE_ROLE_KEY_ASYMMETRIC = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SUPABASE_SECRET_KEY ~= "" and SUPABASE_PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_KEY_ASYMMETRIC ~= "" and ANON_KEY_ASYMMETRIC ~= "" + + local PROTECTED_ROUTES = { + ["auth-v1-protected"] = true, + ["rest-v1-protected"] = true, + ["graphql-v1-protected"] = true, + ["realtime-v1-api-protected"] = true, + ["realtime-v1-ws-protected"] = true, + ["pg-protected"] = true, + } + + local function is_protected_route(route_name) + if route_name == nil or route_name == "" then + return false + end + + return PROTECTED_ROUTES[route_name] == true + end + + local function is_valid_apikey(apikey) + if apikey == nil or apikey == "" then + return false + end + + if SERVICE_ROLE_KEY ~= "" and apikey == SERVICE_ROLE_KEY then + return true + end + + if ANON_KEY ~= "" and apikey == ANON_KEY then + return true + end + + if TRANSLATION_ENABLED and apikey == SUPABASE_SECRET_KEY then + return true + end + + if TRANSLATION_ENABLED and apikey == SUPABASE_PUBLISHABLE_KEY then + return true + end + + return false + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + local route_name = request_handle:streamInfo():routeName() + if not is_protected_route(route_name) then + return + end + + if is_valid_apikey(headers:get("apikey")) then + return + end + + request_handle:respond({ + [":status"] = "401", + ["content-type"] = "text/plain", + }, "Unauthorized") + end + + # Translates the query parameter apikey into the matching internal JWT and rewrites the URL so only JWTs propagate downstream. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local ANON_JWT = "${ANON_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= "" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function translate_apikey(apikey) + if apikey == nil or apikey == "" then + return nil + end + + if not TRANSLATION_ENABLED then + return nil + end + + if apikey == SECRET_KEY then + return SERVICE_ROLE_JWT + end + + if apikey == PUBLISHABLE_KEY then + return ANON_JWT + end + + return nil + end + + local function extract_query_apikey(path) + if path == nil or path == "" then + return nil + end + + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return nil + end + + local query = string.sub(path, query_start + 1) + for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do + if key == "apikey" and value ~= "" then + return value + end + end + + return nil + end + + local function replace_query_apikey(path, new_value) + if path == nil or path == "" or new_value == nil or new_value == "" then + return nil + end + + local query_start = string.find(path, "?", 1, true) + if query_start == nil then + return nil + end + + local base = string.sub(path, 1, query_start) + local query = string.sub(path, query_start + 1) + local updated = {} + local replaced = false + + for part in string.gmatch(query, "([^&]+)") do + local key, value = string.match(part, "([^=]+)=(.*)") + if key == "apikey" then + part = key .. "=" .. new_value + replaced = true + end + table.insert(updated, part) + end + + if not replaced then + return nil + end + + return base .. table.concat(updated, "&") + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + local path = headers:get(":path") + local apikey = extract_query_apikey(path) + local translated = translate_apikey(apikey) + + if translated == nil then + return + end + + headers:replace("apikey", translated) + + local rewritten_path = replace_query_apikey(path, translated) + if rewritten_path ~= nil then + headers:replace(":path", rewritten_path) + end + end + + # Translates an apikey header into the appropriate internal JWT for downstream RBAC checks. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local ANON_JWT = "${ANON_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= "" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function translate_apikey(apikey) + if apikey == nil or apikey == "" then + return nil + end + + if not TRANSLATION_ENABLED then + return nil + end + + if apikey == SECRET_KEY then + return SERVICE_ROLE_JWT + end + + if apikey == PUBLISHABLE_KEY then + return ANON_JWT + end + + return nil + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if is_functions_request(request_handle, headers) then + return + end + + local translated = translate_apikey(headers:get("apikey")) + if translated ~= nil and translated ~= "" then + headers:replace("apikey", translated) + end + end + + # Mirrors apikey into x-api-key for realtime WS compatibility. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local REALTIME_WS_ROUTE = "realtime-v1-ws-protected" + + function envoy_on_request(request_handle) + local route_name = request_handle:streamInfo():routeName() + if route_name ~= REALTIME_WS_ROUTE then + return + end + + local headers = request_handle:headers() + local apikey = headers:get("apikey") + if apikey == nil or apikey == "" then + return + end + + headers:replace("x-api-key", apikey) + end + + # Synthesizes an Authorization header (Bearer …) from apikey when callers don’t provide a real JWT header. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local REALTIME_WS_ROUTE = "realtime-v1-ws-protected" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function has_real_jwt(auth_header) + if auth_header == nil or auth_header == "" then + return false + end + + if string.sub(auth_header, 1, 7) ~= "Bearer " then + return false + end + + return string.sub(auth_header, 1, 10) ~= "Bearer sb_" + end + + local function format_authorization(value) + if value == nil or value == "" then + return nil + end + + if string.sub(value, 1, 7) == "Bearer " then + return value + end + + return "Bearer " .. value + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if request_handle:streamInfo():routeName() == REALTIME_WS_ROUTE then + return + end + + if is_functions_request(request_handle, headers) then + return + end + + if has_real_jwt(headers:get("authorization")) then + return + end + + local apikey = headers:get("apikey") + local authorization_value = format_authorization(apikey) + if authorization_value ~= nil then + headers:replace("authorization", authorization_value) + end + end + + - name: envoy.filters.http.rbac + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC + rules: + action: ALLOW + policies: + admin: + permissions: + - url_path: + path: + prefix: /pg/ + principals: + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY}' + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}' + apikey: + permissions: + - url_path: + path: + prefix: /auth/v1/ + - url_path: + path: + prefix: /rest/v1/ + - url_path: + path: + prefix: /realtime/v1/api + - url_path: + path: + prefix: /realtime/v1/ + - url_path: + path: + prefix: /graphql/v1 + principals: + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY}' + - header: + name: apikey + string_match: + exact: '${ANON_KEY}' + - header: + name: apikey + string_match: + exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}' + - header: + name: apikey + string_match: + exact: '${ANON_KEY_ASYMMETRIC}' + - name: envoy.filters.http.router + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/volumes/api/kong-entrypoint.sh b/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..d5eee93 --- /dev/null +++ b/volumes/api/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Custom entrypoint for Kong that builds Lua expressions for request-transformer +# and performs environment variable substitution in the declarative config. + +# Build Lua expressions for translating opaque API keys to asymmetric JWTs. +# When opaque keys are not configured (empty env vars), expressions fall through +# to legacy-only behavior - just passing apikey as-is. +# +# Full expression logic (when opaque keys are configured): +# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT) +# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key" +# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key" +# 4. Fallback: pass apikey as-is (legacy HS256 JWT) + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # Opaque keys configured -> full translation expressions + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" + + # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey + # via query string), outputs to x-api-key header which Realtime checks first. + export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" +else + # Legacy API keys, not sb_ API keys -> pass apikey through unchanged + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" + export LUA_RT_WS_EXPR="\$(query_params.apikey)" +fi + +# Substitute environment variables in the Kong declarative config. +# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double +# quotes, breaking "Header: value" patterns that YAML parses as mappings). +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +# Remove empty key-auth credentials (unconfigured opaque keys) +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/volumes/api/kong.yml b/volumes/api/kong.yml new file mode 100644 index 0000000..b89e868 --- /dev/null +++ b/volumes/api/kong.yml @@ -0,0 +1,408 @@ +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - key: $SUPABASE_PUBLISHABLE_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + - key: $SUPABASE_SECRET_KEY + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: '$DASHBOARD_USERNAME' + password: '$DASHBOARD_PASSWORD' + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + _comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*' + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + _comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*' + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + _comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*' + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + - name: auth-v1-open-jwks + _comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json' + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + - name: auth-v1-open-sso-acs + url: "http://auth:9999/sso/saml/acs" + routes: + - name: auth-v1-open-sso-acs + strip_path: true + paths: + - /sso/saml/acs + plugins: + - name: cors + + - name: auth-v1-open-sso-metadata + url: "http://auth:9999/sso/saml/metadata" + routes: + - name: auth-v1-open-sso-metadata + strip_path: true + paths: + - /sso/saml/metadata + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'Auth: /auth/v1/* -> http://auth:9999/*' + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure PostgREST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Content-Profile: graphql_public" + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "x-api-key:$LUA_RT_WS_EXPR" + replace: + querystring: + - "apikey:$LUA_RT_WS_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Storage API endpoint (with Authorization header transformation). + ## No key-auth — S3 protocol requests don't carry an apikey header. + ## + ## The request-transformer translates opaque API keys to asymmetric JWTs + ## and passes through existing Authorization headers (user JWTs, AWS SigV4). + ## When no Authorization or apikey header is present (S3 presigned URLs), + ## the Lua expression evaluates to nil which Kong renders as empty string. + ## The post-function strips this empty header so Storage's S3 signature + ## verification falls through to query-parameter parsing. + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://functions:9000/ + read_timeout: 150000 + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## OAuth 2.0 Authorization Server Metadata (RFC 8414) + - name: well-known-oauth + _comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server' + url: http://auth:9999/.well-known/oauth-authorization-server + routes: + - name: well-known-oauth + strip_path: true + paths: + - /.well-known/oauth-authorization-server + plugins: + - name: cors + + ## Analytics routes + ## Not used - Studio and Vector talk directly to analytics via Docker networking. + ## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth. + # - name: analytics-v1-api + # _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*' + # url: http://analytics:4000/api/endpoints + # routes: + # - name: analytics-v1-api + # strip_path: true + # paths: + # - /analytics/v1/api/endpoints/ + # - name: analytics-v1 + # _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + # url: http://analytics:4000/ + # routes: + # - name: dashboard-v1-all + # strip_path: true + # paths: + # - /analytics/v1 + # plugins: + # - name: cors + # - name: basic-auth + # config: + # hide_credentials: true + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Block access to /api/mcp + - name: mcp-blocker + _comment: 'Block direct access to /api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp-blocker-route + strip_path: true + paths: + - /api/mcp + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + + ## MCP endpoint - local access + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + # Block access to /mcp by default + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + # Enable local access (danger zone!) + # 1. Comment out the 'request-termination' section above + # 2. Uncomment the entire section below, including 'deny' + # 3. Add your local IPs to the 'allow' list + #- name: cors + #- name: ip-restriction + # config: + # allow: + # - 127.0.0.1 + # - ::1 + # deny: [] + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/volumes/db/_supabase.sql b/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/volumes/db/init/data.sql b/volumes/db/init/data.sql new file mode 100755 index 0000000..e69de29 diff --git a/volumes/db/jwt.sql b/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/volumes/db/logs.sql b/volumes/db/logs.sql new file mode 100644 index 0000000..255c0f4 --- /dev/null +++ b/volumes/db/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/volumes/db/pooler.sql b/volumes/db/pooler.sql new file mode 100644 index 0000000..162c5b9 --- /dev/null +++ b/volumes/db/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/volumes/db/realtime.sql b/volumes/db/realtime.sql new file mode 100644 index 0000000..4d4b9ff --- /dev/null +++ b/volumes/db/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/volumes/db/roles.sql b/volumes/db/roles.sql new file mode 100644 index 0000000..8f7161a --- /dev/null +++ b/volumes/db/roles.sql @@ -0,0 +1,8 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/volumes/db/webhooks.sql b/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/volumes/functions/hello/index.ts b/volumes/functions/hello/index.ts new file mode 100644 index 0000000..e3f138b --- /dev/null +++ b/volumes/functions/hello/index.ts @@ -0,0 +1,14 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +Deno.serve(async () => { + return new Response( + `"Hello from Edge Functions!"`, + { headers: { "Content-Type": "application/json" } }, + ) +}) + +// To invoke: +// curl 'http://localhost:/functions/v1/hello' \ +// --header 'Authorization: Bearer ' diff --git a/volumes/functions/main/index.ts b/volumes/functions/main/index.ts new file mode 100644 index 0000000..ebe2061 --- /dev/null +++ b/volumes/functions/main/index.ts @@ -0,0 +1,168 @@ +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// Create JWKS for ES256/RS256 tokens (newer tokens) +let SUPABASE_JWT_KEYS: ReturnType | null = null +if (SUPABASE_URL) { + try { + SUPABASE_JWT_KEYS = jose.createRemoteJWKSet( + new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL) + ) + } catch (e) { + console.error('Failed to fetch JWKS from SUPABASE_URL:', e) + } +} + +/** + * Extract JWT token from Authorization header + * + * Parses the Authorization header to extract the Bearer token. + * Expects format: "Bearer " + * + * @param req - The HTTP request object + * @returns The JWT token string + * @throws Error if Authorization header is missing or malformed + */ +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function isValidLegacyJWT(jwt: string): Promise { + if (!JWT_SECRET) { + console.error('JWT_SECRET not available for HS256 token verification') + return false + } + + const encoder = new TextEncoder(); + const secretKey = encoder.encode(JWT_SECRET) + + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error('Symmetric Legacy JWT verification error', e); + return false; + } + return true; +} + +async function isValidJWT(jwt: string): Promise { + if (!SUPABASE_JWT_KEYS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + } catch (e) { + console.error('Asymmetric JWT verification error', e); + return false + } + + return true; +} + +/** + * Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms + * + * This function automatically detects the algorithm used in the token and applies + * the appropriate verification method: + * - HS256: Uses JWT_SECRET (symmetric key) + * - ES256/RS256: Uses JWKS endpoint (asymmetric public keys) + * + * This fix ensures compatibility with both legacy tokens and newer asymmetric tokens, + * resolving the "Key for the ES256 algorithm must be of type CryptoKey" error. + * + * @param jwt - The JWT token string to verify + * @returns Promise resolving to true if verification succeeds, false otherwise + */ +async function isValidHybridJWT(jwt: string): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) + + if (jwtAlgorithm === 'HS256') { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) + + return await isValidLegacyJWT(jwt) + } + + if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { + return await isValidJWT(jwt) + } + + return false; +} + +Deno.serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await isValidHybridJWT(token); + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/volumes/logs/vector.yml b/volumes/logs/vector.yml new file mode 100644 index 0000000..f63bfd8 --- /dev/null +++ b/volumes/logs/vector.yml @@ -0,0 +1,268 @@ +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + exclude_containers: + - supabase-vector + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong" || .appname == "supabase-envoy"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.response.status_code = req.status + url, split_err = split(req.request, " ") + if split_err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P