padhle - post migration

This commit is contained in:
2026-09-15 04:08:55 -04:00
parent 534b51b41b
commit b2d8109019
509 changed files with 147378 additions and 303 deletions
+384
View File
@@ -0,0 +1,384 @@
############
# 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 <name>
#
# Examples:
# COMPOSE_FILE=docker-compose.yml
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
#
############
COMPOSE_FILE=docker-compose.yml
############
# Secrets
#
# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING
# THE CONTAINERS FOR THE FIRST TIME!
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase
#
# To generate secrets and API keys:
# 1. sh utils/generate-keys.sh
# 2. sh utils/add-new-auth-keys.sh
#
############
# Postgres
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
# Legacy symmetric HS256 key
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
# Legacy API keys (HS256-signed JWTs)
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
# Asymmetric key pair (ES256) and opaque API keys
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys
#
# To generate:
# sh ./utils/add-new-auth-keys.sh
#
# Opaque API key for client-side use (anon role).
SUPABASE_PUBLISHABLE_KEY=
# Opaque API key for server-side use (service_role). Never expose in client code.
SUPABASE_SECRET_KEY=
# JSON array of signing JWKs (EC private + legacy symmetric).
# Used by Auth.
JWT_KEYS=
# JWKS for token verification (EC public + legacy symmetric).
# Used by PostgREST, Realtime, Storage to verify tokens.
JWT_JWKS=
# Access to Dashboard
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
# Encryption key for securing Realtime and Supavisor communications.
# (Must be at least 64 characters; generate with: openssl rand -base64 48)
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
# Encryption key used by Realtime for sensitive fields in the `_realtime` schema.
# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`)
REALTIME_DB_ENC_KEY=supabaserealtime
# Encryption key used by Supavisor for storing encrypted configuration.
# (Must be exactly 32 characters; generate with: openssl rand -hex 16)
VAULT_ENC_KEY=your-32-character-encryption-key
# Encryption key for securing connection strings used by Studio against postgres-meta.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
# API token for log ingestion used by Logflare and Vector.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public
# API token used for Logflare management operations. Never expose client-side.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage.
# (Generate with: openssl rand -hex 16)
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID.
# (Generate with: openssl rand -hex 32)
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
############
# URLs - Configure hostnames below to reflect your actual domain name
############
# Access to Dashboard and REST API
SUPABASE_PUBLIC_URL=http://localhost:8000
# Full external URL of the Auth service, used to construct OAuth callbacks,
# SAML endpoints, and email links
API_EXTERNAL_URL=http://localhost:8000/auth/v1
# See also the Auth section below for Site URL and Redirect URLs configuration
############
# Database - Postgres configuration
############
# Using default user (postgres)
POSTGRES_HOST=db
POSTGRES_DB=postgres
# Default configuration includes Supavisor exposing POSTGRES_PORT
# Postgres uses POSTGRES_PORT inside the container
# Documentation:
# https://supabase.com/docs/guides/self-hosting/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=<your-base64-encoded-private-key>
# Optional: accept encrypted SAML assertions from IdPs (default: false)
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
# Optional: how long relay state tokens remain valid (default: 2m0s)
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
# Optional: override the SAML entity ID / ACS base URL
# Defaults to API_EXTERNAL_URL if not set
# SAML_EXTERNAL_URL=https://supabase.example.com:8000/auth/v1
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
# SAML_RATE_LIMIT_ASSERTION=15
############
# Storage - Configuration for Storage
############
# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and
# refer to the documentation at:
# https://supabase.com/docs/guides/self-hosting/self-hosted-s3
# to learn how to configure the S3 protocol endpoint
# S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET=stub
# Used for S3 protocol endpoint configuration
REGION=stub
# Used by MinIO when added via:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
MINIO_ROOT_USER=supa-storage
# Root administrator password for the RustFS or MinIO server.
# (Must be 8+ characters; generate with: openssl rand -hex 16)
MINIO_ROOT_PASSWORD=secret1234
# Equivalent to project_ref as described here:
# https://supabase.com/docs/guides/storage/s3/authentication#session-token
STORAGE_TENANT_ID=stub
############
# Functions - Configuration for Edge functions
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-functions
# NOTE: VERIFY_JWT applies to all functions
FUNCTIONS_VERIFY_JWT=false
############
# API - Configuration for PostgREST
############
# Postgres schemas exposed via the REST API
PGRST_DB_SCHEMAS=public,graphql_public
# Max number of rows returned by a request
PGRST_DB_MAX_ROWS=1000
# Extra schemas added to the search_path of every request
PGRST_DB_EXTRA_SEARCH_PATH=public
############
# Logs and Analytics
############
## Vector log collection and routing
# Docker socket location - required for proper Vector operation
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
# For Podman use the following:
# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock
## Analytics (Logflare)
# Check the LOGFLARE_* access token configuration _above_.
# If Logflare has to be externally exposed - configure securely!
# Google Cloud Project details
# Documentation:
# https://supabase.com/docs/reference/self-hosting-analytics/introduction
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
############
# API gateway
############
# Host port the API gateway (Envoy by default) listens on.
API_GW_HTTP_PORT=8000
# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is
# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for
# API_GW_HTTP_PORT so existing .env files continue to work.
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
# Used internally by the API gateway - DO NOT use in any client or server code.
# Pre-signed ES256 JWT "API key" for anon role.
ANON_KEY_ASYMMETRIC=
# Pre-signed ES256 JWT "API key" for service_role.
SERVICE_ROLE_KEY_ASYMMETRIC=
############
# imgproxy
############
# Enable webp support
IMGPROXY_AUTO_WEBP=true
############
# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
# Domain name for the proxy (must point to your server)
PROXY_DOMAIN=your-domain.example.com
# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN).
# This should be a valid email, not a placehoder (otherwise Certbot may fail to start).
CERTBOT_EMAIL=admin@example.com
+17
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
volumes/db/data
volumes/storage
volumes/snippets
volumes/functions/**
!volumes/functions/main/
volumes/functions/main/**
!volumes/functions/main/index.ts
.env
test.http
docker-compose.override.yml
.supabase-version
backups
+676
View File
@@ -0,0 +1,676 @@
# Changelog
All notable changes to the Supabase self-hosted Docker configuration.
Changes are grouped by service rather than by change type. See [versions.md](./versions.md) for complete image version history and rollback information.
See per-service updates below for details. Only the most important changes relevant to [self-hosted Supabase](https://supabase.com/docs/guides/self-hosting) are included here. For the full list of changes, refer to the release notes and changelogs of each individual service.
**Note:** Configuration updates marked with "requires [...] update" are already included in the latest version of the repository. Pull the latest changes or refer to the linked PR for manual updates. After updating `docker-compose.yml`, pull the latest images and recreate containers - use `docker compose pull && docker compose down && docker compose up -d`.
---
## [0.8.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.8.0) - 2026-08-11
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
- Envoy is now the default API gateway, replacing Kong. Kong stays available as an opt-in override with `sh run.sh config add kong`. See the [heads-up discussion](https://github.com/orgs/supabase/discussions/48048) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### Configuration
- ⚠️ Added `API_GW_HTTP_PORT` (falls back to `KONG_HTTP_PORT`; requires `.env` and `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### Documentation
- Updated several self-hosting how-to guides to reflect the current configuration - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Updated architecture diagram for self-hosted Supabase - PR [#48763](https://github.com/supabase/supabase/pull/48763)
### Utils and tests
- Updated `tests/` to match the API gateway switch to Envoy - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### API gateway
- ⚠️ Changed the default API gateway from Kong to Envoy; the `kong` service is now `api-gw` (requires `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Changed `docker-compose.envoy.yml` to a no-op shim now that Envoy is the default (requires `docker-compose.envoy.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Added an optional override for Kong (requires new `docker-compose.kong.yml`) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Updated the Caddy and nginx reverse proxies to forward to `api-gw` (requires `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/proxy/caddy/Caddyfile`, and `volumes/proxy/nginx/supabase-nginx.conf.tpl` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
---
## [0.7.2](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.2) - 2026-08-04
### Utils and tests
- Fixed `update.sh` overwriting itself during an update; the new `update.sh` is now staged as `update.sh.new` for review instead of replacing the running script - PR [#48690](https://github.com/supabase/supabase/pull/48690)
- `update.sh` now fetches only the `docker/` directory (partial clone), making updates substantially faster and lighter - PR [#48690](https://github.com/supabase/supabase/pull/48690)
---
## [0.7.1](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.1) - 2026-08-03
### Configuration
- Added `SUPABASE_JWKS` configuration for Edge Functions to `docker-compose.yml` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
- Added `upgrades.json` - a version-keyed manifest to gate breaking changes (required for `update.sh`) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
- Updated `.gitignore` (required for `update.sh`)
### Documentation
- Added new how-to guides ([Custom Postgres Extensions](https://supabase.com/docs/guides/self-hosting/custom-postgres-extensions) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating)) - PR [#48203](https://github.com/supabase/supabase/pull/48203), PR [#48535](https://github.com/supabase/supabase/pull/48535)
### Utils and tests
- Added base version stamp to `setup.sh` (saved in `.supabase-version`, required for `update.sh`) - PR [#47848](https://github.com/supabase/supabase/pull/47848)
- Added `update.sh`. Refer to [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
- Added `SUPABASE_JWKS` configuration for Edge Functions to `utils/add-new-auth-keys.sh` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
- Updated `tests/test-s3.sh` and `test-s3-backend.sh` - PR [#48500](https://github.com/supabase/supabase/pull/48500)
### API gateway
- Updated Kong to `3.9.3`
- Added `KONG_DNS_VALID_TTL` configuration environment variable (requires `docker-compose.yml` update) - PR [#47846](https://github.com/supabase/supabase/pull/47846)
- Updated Envoy to `1.39.0` (requires `docker-compose.envoy.yml` update)
- Updated [nginx-certbot](https://github.com/JonasAlfredsson/docker-nginx-certbot) to `6.2.0-nginx1.31.3` (requires `docker-compose.nginx.yml` update)
### Studio
- Updated to `2026.08.03-sha-022b374`
- Fixed URL generation for Edge Functions - PR [#47861](https://github.com/supabase/supabase/pull/47861) (via [@7ttp](https://github.com/7ttp))
- Fixed the Logs tab visibility in **Auth > Users** - PR [#48122](https://github.com/supabase/supabase/pull/48122) (via [@luizfelmach](https://github.com/luizfelmach/))
### Storage
- Changed RustFS image to `1.0.0-beta.11` temporarily (requires `docker-compose.rustfs.yml` update) - PR [#48500](https://github.com/supabase/supabase/pull/48500)
### Edge Runtime
- Changed JWKS configuration mechanism for main worker (requires `docker-compose.yml` and `volumes/functions/main/index.ts` update) - PR [#45635](https://github.com/supabase/supabase/pull/45635)
---
## [0.7.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.0) - 2026-07-07
⚠️ **Note:** This update contains **breaking changes**:
- Access to the OpenAPI spec at `/rest/v1/` via the anon (publishable) key has been removed. Requests using the service role or new secret API key are unaffected, and data access via `/rest/v1/your_table` or any client library continues to work as-is. See discussion [#42949](https://github.com/orgs/supabase/discussions/42949)
- `API_EXTERNAL_URL` has been updated to include the `/auth/v1` path prefix (e.g. `http://localhost:8000/auth/v1`), aligning self-hosted with the platform and CLI. This makes custom OAuth providers work out of the box and moves SAML SSO endpoints to `/auth/v1/sso/saml/*`. See discussion [#47093](https://github.com/orgs/supabase/discussions/47093) and PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Configuration
- ⚠️ Added `KONG_ROUTER_FLAVOR` to the compose configuration for Kong (requires `docker-compose.yml` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462)
- ⚠️ Changed the default `API_EXTERNAL_URL` in `.env.example` to contain `/auth/v1` - PR [#47640](https://github.com/supabase/supabase/pull/47640)
- ⚠️ Changed the default `PGRST_DB_SCHEMAS` to `public,graphql_public` in `.env.example` to avoid exposing `storage` (a protected schema)
### Documentation
- Minor updates to the how-to guides following the configuration changes
### Utils and tests
- Updated `setup.sh` to match the new `API_EXTERNAL_URL` configuration
- Updated `utils/generate-keys.sh` to also generate a unique `REALTIME_DB_ENC_KEY`
- Updated `tests/test-self-hosted.sh` and `tests/test-auth-keys.sh` to reflect the changes in the API gateway configuration
### API gateway
- ⚠️ Updated Kong and Envoy configuration to restrict access to PostgREST `/rest/v1/` (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462) (via [@luizfelmach](https://github.com/luizfelmach/))
- ⚠️ Updated Kong and Envoy configuration to match the new `/auth/v1/sso` routing for SAML SSO (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Studio
- Updated to `2026.07.07-sha-a6a04f2`
- Fixed the local SQL snippets not being shown in the SQL Editor - PR [#47403](https://github.com/supabase/supabase/pull/47403), PR [#47409](https://github.com/supabase/supabase/pull/47409)
- Fixed the exposed schemas and tables UI to properly reflect non-platform configuration (**Data API > Settings**) - PR [#47511](https://github.com/supabase/supabase/pull/47511)
- Fixed the behavior of the type generator (**Data API > Docs**) - PR [#47577](https://github.com/supabase/supabase/pull/47577)
### Auth
- ⚠️ Changed Auth configuration placeholders to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
- ⚠️ Changed `GOTRUE_JWT_ISSUER` to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Realtime
- ⚠️ Added a new configuration variable `REALTIME_DB_ENC_KEY` for Realtime with a fallback to the default value (requires `docker-compose.yml` update) - PR [#46021](https://github.com/supabase/supabase/pull/46021)
---
## [0.6.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.6.0) - 2026-06-17
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
- **Postgres 17 is now the default**. Do not start Postgres 17 on an existing Postgres 15 data directory. See the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) guide. Check the **Configuration** and **Postgres** sections for additional information
- API gateway configuration includes a **security fix** for Realtime routes - it is **strongly recommended** to add this update to any self-hosted Supabase instance running Realtime
- Studio and Postgres Meta configuration now use `postgres` and not `supabase_admin` to connect to Postgres
### Configuration
- ⚠️ Changed the default Postgres image to `supabase/postgres:17.6.1.136` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- ⚠️ Added `docker-compose.pg15.yml` - for deployments not yet upgraded, and as the rollback target for `utils/upgrade-pg17.sh` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `docker-compose.pg17.yml` to match the new default - PR [#46981](https://github.com/supabase/supabase/pull/46981)
### Documentation
- Updated the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) how-to - PR [#46989](https://github.com/supabase/supabase/pull/46989)
- Updated the [New API Keys](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) and [Envoy API Gateway](https://supabase.com/docs/guides/self-hosting/self-hosted-envoy) how-to guides - PR [#46856](https://github.com/supabase/supabase/pull/46856)
- Updated [CONFIG.md](CONFIG.md) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### Utils and tests
- Updated `utils/upgrade-pg17.sh` (bumped Postgres image, added additional migrations), and `tests/test-pg17-upgrade.sh` (added tests for pg_cron) - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `tests/test-self-hosted.sh` (added tests for resumable upload, modified tests for Realtime and GraphQL) - PR [#46731](https://github.com/supabase/supabase/pull/46731), PR [#46856](https://github.com/supabase/supabase/pull/46856), PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `tests/test-auth-keys.sh` (modified tests for Realtime) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
### API gateway
- ⚠️ Updated Kong and Envoy configuration to block access to Realtime `/api/tenants` and `/api/openapi` endpoints. This is a **security fix** (requires `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
- Updated entrypoint for Kong to use `/bin/sh` (requires `docker-compose.yml` update) - PR [#46873](https://github.com/supabase/supabase/pull/46873)
### Studio
- ⚠️ Updated `studio` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update). See discussion [#46081](https://github.com/orgs/supabase/discussions/46081) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) for important information - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### PostgREST
- Added healthcheck for `rest` (requires `docker-compose.yml` update) - PR [#46658](https://github.com/supabase/supabase/pull/46658)
### Postgres Meta
- ⚠️ Updated `meta` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### Edge Runtime
- Added healthcheck for `functions` (requires `docker-compose.yml` update) - PR [#46655](https://github.com/supabase/supabase/pull/46655)
### Postgres
- ⚠️ Updated the default image to `17.6.1.136` (from `15.8.1.085`). `pg_graphql` is now **disabled by default** on fresh installs. Databases that already use GraphQL keep it after an upgrade. See discussion [#46080](https://github.com/orgs/supabase/discussions/46080) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) for more information - PR [#46981](https://github.com/supabase/supabase/pull/46981)
---
## [0.5.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.5.0) - 2026-06-03
⚠️ **Note:** This update includes **important changes**. Please check the details below.
### Configuration
- ⚠️ Logs and analytics are now [optional](https://github.com/orgs/supabase/discussions/46084) and were removed from the default `docker-compose.yml`. A new `docker-compose.logs.yml` override has been added. Check the main [configuration guide](https://supabase.com/docs/guides/self-hosting/docker#enabling-analytics) and the changes to Studio below for more information - PR [#45327](https://github.com/supabase/supabase/pull/45327) (via [@luizfelmach](https://github.com/luizfelmach/))
- ⚠️ Added `COMPOSE_FILE` to `.env.example` for configuring compose overrides (also used by `run.sh`) - PR [#45603](https://github.com/supabase/supabase/pull/45603)
### Documentation
- Added a new [reference list](https://github.com/supabase/supabase/blob/master/docker/CONFIG.md) of all configuration environment variables - PR [#46124](https://github.com/supabase/supabase/pull/46124)
- Updated the main installation and configuration [guide](https://supabase.com/docs/guides/self-hosting/docker) (added "quick start" path and opt-in for logs and analytics; removed the legacy JWT secrets generator) - PR [#46416](https://github.com/supabase/supabase/pull/46416), PR [#45359](https://github.com/supabase/supabase/pull/45359)
- Updated the logs and analytics [how-to guide](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#46452](https://github.com/supabase/supabase/pull/46452)
### Utils
- Added `setup.sh` and `run.sh` to support quick start and easier management of the compose configuration - PR [#45603](https://github.com/supabase/supabase/pull/45603)
- Updated `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-key.sh` to remove the dependency on OpenSSL and Node.js - PR [#45941](https://github.com/supabase/supabase/pull/45941)
- Updated `tests/test-container-logs.sh` to skip checks for `kong`, `analytics` and `vector` when the services are not running - PR [#46099](https://github.com/supabase/supabase/pull/46099)
### API gateway
- Updated Envoy version to `1.38.0` (see `docker-compose.envoy.yml`) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
- Updated Envoy configuration to address a discrepancy in API key checking (requires `volumes/api/envoy` update) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
### Studio
- Updated to `2026.06.03-sha-0bca601`
- ⚠️ Added `ENABLED_FEATURES_LOGS_ALL` to Studio service configuration (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` to Studio service configuration (requires `docker-compose.yml` update) - PR [#46173](https://github.com/supabase/supabase/pull/46173)
- ⚠️ Added `start_period` to Studio healthcheck for more reliable cold-boot on slower hosts (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
- Fixed incorrect connection strings in the connect sheet for self-hosted environments - PR [#46217](https://github.com/supabase/supabase/pull/46217)
- Updated project home and functions page, and added a minimal project settings implementation - PR [#46544](https://github.com/supabase/supabase/pull/46544), PR [#46550](https://github.com/supabase/supabase/pull/46550), PR [#46554](https://github.com/supabase/supabase/pull/46554)
### Auth
- Updated to `v2.189.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.189.0)
- ⚠️ Added `GOTRUE_JWT_ISSUER` to Auth service configuration (requires `docker-compose.yml` update) - PR [#46020](https://github.com/supabase/supabase/pull/46020)
### PostgREST
- Updated to `v14.12` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.12)
### Realtime
- Updated to `v2.102.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.102.3)
### Storage
- Updated to `v1.60.4` - [Release](https://github.com/supabase/storage/releases/tag/v1.60.4)
### Postgres Meta
- Updated to `v0.96.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.6)
### Edge Runtime
- Updated to `v1.74.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.74.0)
### Supavisor
- Updated to `2.9.5` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.9.5)
- Added `POSTGRES_HOST` to Supavisor service configuration (requires `docker-compose.yml` and `volumes/pooler/pooler.exs` update) - PR [#41273](https://github.com/supabase/supabase/pull/41273)
### Analytics (Logflare)
- Updated to `1.43.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.43.1)
- ⚠️ Changed default `docker-compose.yml` to no longer include logs & analytics. Read more in Supabase's [changelog](https://github.com/orgs/supabase/discussions/46084) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
---
## 2026-04-27
### Configuration
- ⚠️ Added `docker-compose.envoy.yml` and `volumes/api/envoy`. See also the API gateway updates below - PR [#43838](https://github.com/supabase/supabase/pull/43838)
- ⚠️ Changed Studio healthcheck and some other configuration for better compatibility with Podman (requires `docker-compose.yml` update) - PR [#44754](https://github.com/supabase/supabase/pull/44754)
- ⚠️ Changed Studio configuration to bind to all IPv4 interfaces only (requires `docker-compose.yml` update) - PR [#44772](https://github.com/supabase/supabase/pull/44772)
### Documentation
- Added a new [how-to](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) describing how to switch from `supabase_admin` to `postgres` role for Studio - PR [#42975](https://github.com/supabase/supabase/pull/42975) (via [@singh-inder](https://github.com/singh-inder/))
- Added a new [how-to](https://github.com/supabase/supabase/pull/45152) for configuring Envoy as the new API gateway - PR [#45152](https://github.com/supabase/supabase/pull/45152)
- Updated the main [setup guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos to reflect the state of the self-hosted Supabase configuration - PR [#45011](https://github.com/supabase/supabase/pull/45011)
### Utils
- ⚠️ Added `utils/reassign-owner.sh` to update database objects. Read more in the "[Remove superuser access](https://supabase.com/docs/guides/self-hosting/remove-superuser-access)" how-to guide - PR [#42975](https://github.com/supabase/supabase/pull/42975)
- ⚠️ Changed `utils/add-new-auth-keys.sh` to also update `docker-compose.yml` - PR [#45056](https://github.com/supabase/supabase/pull/45056)
### API gateway
- ⚠️ Added Envoy as the new optional API gateway (requires `docker-compose.envoy.yml`, `volumes/api/envoy`, and `volumes/logs/vector.yml` update) - PR [#43838](https://github.com/supabase/supabase/pull/43838) (via [@luizfelmach](https://github.com/luizfelmach/))
### Studio
- Updated to `2026.04.27-sha-5f60601`
- ⚠️ Added 4 new lints to the Security Advisor. Read more about lint rules 0026 - 0029 in the [Performance and Security Advisors](https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0026_pg_graphql_anon_table_exposed) section of the Supabase documentation - PR [#45253](https://github.com/supabase/supabase/pull/45253), PR [#45260](https://github.com/supabase/supabase/pull/45260)
---
## 2026-04-08
### Documentation
- Added new how-to guides for configuring [custom email templates](https://supabase.com/docs/guides/self-hosting/custom-email-templates), setting up [SAML SSO](https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso), and [using Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) - PR [#42832](https://github.com/supabase/supabase/pull/42832), PR [#43386](https://github.com/supabase/supabase/pull/43386), PR [#44147](https://github.com/supabase/supabase/pull/44147)
### Utils
- ⚠️ Added `utils/upgrade-pg17.sh`. Read more in the "[Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17)" how-to guide - PR [#44147](https://github.com/supabase/supabase/pull/44147)
### API gateway
- ⚠️ Added configuration for SAML SSO (requires `.env`, `docker-compose.yml` and `volumes/api/kong.yml` update) - PR [#43385](https://github.com/supabase/supabase/pull/43385) (via [@luizfelmach](https://github.com/luizfelmach/))
### Studio
- Updated to `2026.04.08-sha-205cbe7`
### PostgREST
- Updated to `v14.8` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.8)
### Storage
- Updated to `v1.48.26` - [Release](https://github.com/supabase/storage/releases/tag/v1.48.26)
### imgproxy
- Changed `IMGPROXY_ENABLE_WEBP_DETECTION` environment variable to `IMGPROXY_AUTO_WEBP` (requires `.env` and `docker-compose.yml` update) - PR [#43919](https://github.com/supabase/supabase/pull/43919)
### Postgres Meta
- Updated to `v0.96.3` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.3)
### Analytics (Logflare)
- Updated to `1.36.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.36.1)
### Postgres
- ⚠️ Added `docker-compose.pg17.yml` override - PR [#44147](https://github.com/supabase/supabase/pull/44147)
- ⚠️ Added `utils/upgrade-pg17.sh` - PR [#44147](https://github.com/supabase/supabase/pull/44147)
- ⚠️ Added [documentation](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) explaining the upgrade to Postgres 17
---
## 2026-03-16
⚠️ **Note:** This update includes **important changes**. Please check the details below. The following configuration files have been added/updated: `utils/add-new-auth-keys.sh`, `utils/rotate-new-api-keys.sh`, `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `docker-compose.rustfs.yml`, `volumes/api/kong.yml`, `volumes/api/kong-entrypoint.sh`, `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/functions/main/index.ts`, and `volumes/proxy`.
### Configuration
- ⚠️ Added scripts and templates to support the new API key format (`sb_` API keys) and the new asymmetric authentication. Check the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) for detailed instructions - PR [#43554](https://github.com/supabase/supabase/pull/43554)
- Added optional proxy configuration for Caddy and nginx. Read the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) to learn more - PR [#43291](https://github.com/supabase/supabase/pull/43291)
### Documentation
- Added several new how-to guides to the self-hosted Supabase [documentation](https://supabase.com/docs/guides/self-hosting) - PR [#42745](https://github.com/supabase/supabase/pull/42745), PR [#42953](https://github.com/supabase/supabase/pull/42953), PR [#43177](https://github.com/supabase/supabase/pull/43177), PR [#43286](https://github.com/supabase/supabase/pull/43286), PR [#43293](https://github.com/supabase/supabase/pull/43293)
### Utils and tests
- Added `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-keys.sh` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
- Added `tests/` with 100+ test cases - PR [#43573](https://github.com/supabase/supabase/pull/43573)
### Studio
- Updated to `2026.03.16-sha-5528817`
- ⚠️ Added the link to the Data API page in Integrations - PR [#43268](https://github.com/supabase/supabase/pull/43268)
- ⚠️ Added `PGRST_DB_SCHEMAS`, `PGRST_DB_EXTRA_SEARCH_PATH`, and `PGRST_DB_MAX_ROWS` to Studio configuration (requires `docker-compose.yml` update) - PR [#43268](https://github.com/supabase/supabase/pull/43268)
### MCP Server
- Updated to `v0.7.0` - [Release](https://github.com/supabase/mcp/releases/tag/v0.7.0)
### API gateway
- ⚠️ Updated Kong to `3.9.1` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
### PostgREST
- Updated to `v14.6` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.6)
### Realtime
- ⚠️ Added **mandatory** `METRICS_JWT_SECRET` environment variable (requires `docker-compose.s3.yml` update) - PR [realtime#1729](https://github.com/supabase/realtime/pull/1729)
### Storage
- Updated to `v1.44.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.44.2)
- ⚠️ Added `STORAGE_PUBLIC_URL` environment variable to simplify proxy configuration (requires `docker-compose.s3.yml` update) - PR [storage#900](https://github.com/supabase/storage/pull/900)
- ⚠️ Added RustFS as an optional S3 backend - PR [#42935](https://github.com/supabase/supabase/pull/42935)
- ⚠️ Changed Docker Compose configuration for S3 backends to use named volumes - PR [#43815](https://github.com/supabase/supabase/pull/43815)
### Edge Runtime
- Updated to `v1.71.2` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.71.2)
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_PUBLIC_URL` environment variables (requires `docker-compose.yml` update)
- ⚠️ Added an option for a "hybrid" JWT verification following the addition of the new API keys and the new asymmetric authentication (requires `volumes/functions/main/index.ts` update) - PR [#42130](https://github.com/supabase/supabase/pull/42130)
- ⚠️ Added optional rate limiter - PR [edge-runtime#670](https://github.com/supabase/edge-runtime/pull/670)
---
## 2026-02-18
### Storage
- Changed MinIO image to use Chainguard [minio](https://images.chainguard.dev/directory/image/minio/overview) and [minio-client](https://images.chainguard.dev/directory/image/minio-client/overview) (requires `docker-compose.s3.yml` update) - PR [#42942](https://github.com/supabase/supabase/pull/42942)
- Updated Storage image version to `v1.37.8` in `docker-compose.s3.yml`
- Removed `imgproxy` service from `docker-compose.s3.yml` to minimize redundancy - PR [#42942](https://github.com/supabase/supabase/pull/42942)
- Fixed inconsistent `storage` service entry ordering in `docker-compose.yml` and `docker-compose.s3.yml` to improve diff readability - PR [#42942](https://github.com/supabase/supabase/pull/42942)
### Edge Runtime
- Added a `deno-cache` named volume to avoid re-downloading dependencies (requires `docker-compose.yml` and `volumes/functions/*` update) - PR [#40822](https://github.com/supabase/supabase/pull/40822)
---
## 2026-02-16
⚠️ **Note:** This update includes several breaking changes, including a security fix for Analytics. Please check the details below. The following configuration files have been updated: `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `volumes/api/kong.yml`, and `volumes/logs/vector.yml`.
### Studio
- Updated to `2026.02.16-sha-26c615c`
- Added Edge Functions management UI (requires `docker-compose.yml` update) - PR [#40690](https://github.com/supabase/supabase/pull/40690), PR [#42322](https://github.com/supabase/supabase/pull/42322), PR [#42349](https://github.com/supabase/supabase/pull/42349), PR [#42350](https://github.com/supabase/supabase/pull/42350)
### MCP Server
- Updated to `v0.6.3` - [Release](https://github.com/supabase/mcp/releases/tag/v0.6.3)
### Auth
- Updated to `v2.186.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.186.0)
### PostgREST
- Updated to `v14.5` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.5)
### Realtime
- Updated to `v2.76.5` - [Release](https://github.com/supabase/realtime/releases/tag/v2.76.5)
### Storage
- Updated to `v1.37.8` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.8)
- ⚠️ Changed environment variable configuration for Storage (requires `docker-compose.yml`, `.env.example` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185), PR [#42862](https://github.com/supabase/supabase/pull/42862)
- ⚠️ Added **default** configuration to access buckets via `/storage/v1/s3` endpoint (requires `docker-compose.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
- ⚠️ Changed MinIO configuration for the S3 backend (requires `docker-compose.s3.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
### Edge Runtime
- Updated to `v1.70.3` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.3)
### Analytics (Logflare)
- Updated to `1.31.2` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.31.2)
- ⚠️ Changed default configuration to disable Logflare on `0.0.0.0:4000` to prevent access to `/dashboard` (requires `docker-compose.yml` update). Read more in the "Production Recommendations" section of Logflare [documentation](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
- ⚠️ Changed Kong routes to not include `/analytics/v1` by default (requires `/volumes/api/kong.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
### Vector
- Updated to `0.53.0-alpine` - [Changelog](https://vector.dev/releases/0.53.0/) | [Release](https://github.com/vectordotdev/vector/releases/tag/v0.53.0)
- ⚠️ Major version jump from `0.28.1` (requires `volumes/logs/vector.yml` update) - PR [#42525](https://github.com/supabase/supabase/pull/42525)
- ⚠️ Changed Postgres sink configuration to bypass Kong (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
- ⚠️ Changed retry settings for all sinks to increase timeouts (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
---
## 2026-02-05
### Storage
- Updated to `v1.37.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.1)
- Fixed an issue with Storage not starting because of an issue with migrations - PR [storage#845](https://github.com/supabase/storage/pull/845)
---
## 2026-01-27
### Studio
- Updated to `2026.01.27-sha-6aa59ff`
- Added SQL snippets (requires `docker-compose.yml` update) - PR [#41112](https://github.com/supabase/supabase/pull/41112), PR [#41557](https://github.com/supabase/supabase/pull/41557), discussion [#42031](https://github.com/orgs/supabase/discussions/42031)
- Fixed type generator - PR [#40481](https://github.com/supabase/supabase/pull/40481)
- Fixed minor UI discrepancies - PR [#40579](https://github.com/supabase/supabase/pull/40579), PR [#41936](https://github.com/supabase/supabase/pull/41936), PR [#41970](https://github.com/supabase/supabase/pull/41970), PR [#41971](https://github.com/supabase/supabase/pull/41971), PR [#41972](https://github.com/supabase/supabase/pull/41972), PR [#42015](https://github.com/supabase/supabase/pull/42015)
### Auth
- Updated to `v2.185.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.185.0)
- ⚠️ Fixed security-related issues
### PostgREST
- Updated to `v14.3` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.3)
### Realtime
- Updated to `v2.72.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.72.0)
- Changed healthchecks logging to off by default (requires `docker-compose.yml` update) - PR [realtime#1677](https://github.com/supabase/realtime/pull/1677), PR [#42156](https://github.com/supabase/supabase/pull/42156)
- Changed logging configuration and healthcheck frequency to reduce log volume (requires `docker-compose.yml` update) - PR [#42112](https://github.com/supabase/supabase/pull/42112)
### Storage
- Updated to `v1.33.5` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.5)
### imgproxy
- Updated to `v3.30.1` - [Changelog](https://github.com/imgproxy/imgproxy/blob/master/CHANGELOG.md) | [Release](https://github.com/imgproxy/imgproxy/releases/tag/v3.30.1)
### Postgres Meta
- Updated to `v0.95.2` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.2)
### Edge Runtime
- Updated to `v1.70.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.0)
### Analytics (Logflare)
- Updated to `1.30.3` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.30.3)
### Postgres
- No image update
- Fixed Postgres logging configuration (requires `volumes/logs/vector.yml` update) - PR [#41800](https://github.com/supabase/supabase/pull/41800)
---
## 2025-12-18
### Documentation
- Updated self-hosting installation and configuration guide - PR [#40901](https://github.com/supabase/supabase/pull/40901), PR [#41438](https://github.com/supabase/supabase/pull/41438)
### Utils
- Added `utils/generate-keys.sh` - PR [#41363](https://github.com/supabase/supabase/pull/41363)
- Added `utils/db-passwd.sh` - PR [#41432](https://github.com/supabase/supabase/pull/41432)
- Changed `reset.sh` to POSIX and added more checks - PR [#41361](https://github.com/supabase/supabase/pull/41361)
### Studio
- Updated to `2025.12.17-sha-43f4f7f`
- ⚠️ Fixed additional issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
- Fixed an issue with the Users page not being updated on changes - PR [#41254](https://github.com/supabase/supabase/pull/41254)
### MCP Server
- Updated to `v0.5.10` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.10)
### Auth
- Updated to `v2.184.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.184.0)
### Postgres Meta
- Updated to `v0.95.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.1)
### Analytics (Logflare)
- Updated to `1.27.0` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.27.0)
- Fixed multiple issues, including a race condition
---
## 2025-12-10
### Studio
- Updated to `2025.12.09-sha-434634f`
- ⚠️ Fixed security issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
### MCP Server
- Updated to `v0.5.9` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.9)
- ⚠️ Changed MCP tool `get_anon_key` to `get_publishable_keys`
### PostgREST
- Updated to `v14.1` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.1)
- ⚠️ **Major upgrade from v13.x to v14.x** - please report any unexpected behavior
### Realtime
- Updated to `v2.68.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.68.0)
### Storage
- Updated to `v1.33.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.0)
### Edge Runtime
- Updated to `v1.69.28` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.28)
### Analytics (Logflare)
- Updated to `1.26.25` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.25)
---
## 2025-12-08
### Realtime
- No image update
- Changed boolean values to strings in Docker Compose for better compatibility with Podman - PR [#40994](https://github.com/supabase/supabase/pull/40994), also PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
- Changed healthcheck in Docker Compose for better compatibility with Podman - PR [#41159](https://github.com/supabase/supabase/pull/41159)
---
## 2025-11-26
### Studio
- Updated to `2025.11.26-sha-8f096b5`
- Fixed MCP `get_advisors` tool - PR [#40783](https://github.com/supabase/supabase/pull/40783)
- Fixed AI Assistant request schema - PR [#40830](https://github.com/supabase/supabase/pull/40830)
- Fixed log drains page - PR [#40835](https://github.com/supabase/supabase/pull/40835)
### Realtime
- Updated to `v2.65.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.3)
### Analytics (Logflare)
- Updated to `1.26.13` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.13)
- Fixed crashdump when `POSTGRES_BACKEND_URL` is malformed - PR [logflare#2954](https://github.com/Logflare/logflare/pull/2954)
---
## 2025-11-25
### Studio
- Updated to `2025.11.24-sha-d990ae8` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40734)
- Fixed Queues configuration UI and added [documentation for exposed queue schema](https://supabase.com/docs/guides/queues/expose-self-hosted-queues) - PR [#40078](https://github.com/supabase/supabase/pull/40078)
- Fixed parameterized SQL queries in MCP tools - PR [#40499](https://github.com/supabase/supabase/pull/40499)
- Fixed Studio showing paid options for log drains - PR [#40510](https://github.com/supabase/supabase/pull/40510)
- Fixed AI Assistant authentication - PR [#40654](https://github.com/supabase/supabase/pull/40654)
### Auth
- Updated to `v2.183.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.183.0)
### Realtime
- Updated to `v2.65.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.2)
- Fixed handling of boolean configuration options - PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
### Storage
- Updated to `v1.32.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.32.0)
### Edge Runtime
- Updated to `v1.69.25` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.25)
### Analytics (Logflare)
- Updated to `1.26.12` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.12)
- Fixed Auth logs query - PR [logflare#2936](https://github.com/Logflare/logflare/pull/2936)
- Fixed build configuration to prevent crashes with "Illegal instruction (core dumped)" - PR [logflare#2942](https://github.com/Logflare/logflare/pull/2942)
---
## 2025-11-17
### Storage
- No image update
- Fixed resumable uploads for files larger than 6MB (requires `docker-compose.yml` update) - PR [#40500](https://github.com/supabase/supabase/pull/40500)
---
## 2025-11-12
### Studio
- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
- Added log drains - PR [#28297](https://github.com/supabase/supabase/pull/28297)
- Fixed Studio using `postgres` role instead of `supabase_admin` - PR [#39946](https://github.com/supabase/supabase/pull/39946)
### Auth
- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1)
### Realtime
- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0)
### Storage
- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0)
### Edge Runtime
- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23)
### Supavisor
- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4)
---
## 2025-11-05
### Studio
- No image update
- Fixed Studio failing to connect to Postgres with non-default settings (requires `docker-compose.yml` update) - PR [#40169](https://github.com/supabase/supabase/pull/40169)
### Realtime
- No image update
- Fixed realtime logs not showing in Studio (requires `volumes/logs/vector.yml` update) - PR [#39963](https://github.com/supabase/supabase/pull/39963)
---
## 2025-10-28
### Studio
- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
- Fixed broken authentication when uploading files to Storage - PR [#39829](https://github.com/supabase/supabase/pull/39829)
### Realtime
- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2)
### Storage
- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2)
### Postgres Meta
- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1)
### Edge Runtime
- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15)
---
## 2025-10-27
### Studio
- No image update
- Added Kong configuration for MCP server routes (requires `volumes/api/kong.yml` update) - PR [#39849](https://github.com/supabase/supabase/pull/39849)
- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) for MCP server configuration - PR [#39952](https://github.com/supabase/supabase/pull/39952)
---
## 2025-10-21
### Studio
- Updated to `2025.10.20-sha-5005fc6` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
- Fixed issues with Edge Functions and cron logs not being visible in Studio - PR [#39388](https://github.com/supabase/supabase/pull/39388), PR [#39704](https://github.com/supabase/supabase/pull/39704), PR [#39711](https://github.com/supabase/supabase/pull/39711)
### Realtime
- Updated to `v2.56.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.56.0)
### Storage
- Updated to `v1.28.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.1)
### Postgres Meta
- Updated to `v0.93.0` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.0)
### Edge Runtime
- Updated to `v1.69.14` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.14)
### Supavisor
- Updated to `2.7.3` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.3)
---
## 2025-10-13
### Analytics (Logflare)
- Updated to `1.22.6` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.6)
---
## 2025-10-08
### Studio
- Updated to `2025.10.01-sha-8460121` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
- Added "local" remote MCP server - PR [#38797](https://github.com/supabase/supabase/pull/38797), PR [#39041](https://github.com/supabase/supabase/pull/39041)
- ⚠️ Changed Studio connection method to `postgres-meta` - affects non-standard database port configurations
### Auth
- Updated to `v2.180.0` - [Release](https://github.com/supabase/auth/releases/tag/v2.180.0)
### PostgREST
- Updated to `v13.0.7` - [Release](https://github.com/PostgREST/postgrest/releases/tag/v13.0.7) | [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md)
### Realtime
- Updated to `v2.51.11` - [Release](https://github.com/supabase/realtime/releases/tag/v2.51.11)
### Storage
- Updated to `v1.28.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.0)
### Postgres Meta
- Updated to `v0.91.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.91.6)
### Analytics (Logflare)
- Updated to `1.22.4` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.4)
### Postgres
- Updated to `15.8.1.085` - [Release](https://github.com/supabase/postgres/releases/tag/15.8.1.085)
### Supavisor
- Updated to `2.7.0` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.0)
---
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
<div align="center">
[![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)
</div>
# Self-Hosted Supabase with Docker
This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure.
## Getting Started
Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)
The guide covers:
- Prerequisites (Git and Docker)
- Initial setup and configuration
- Securing your installation
- Accessing services
- Updating your instance
## What's Included
This Docker Compose configuration includes the following services:
- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project
- **[Envoy](https://www.envoyproxy.io/)** - API gateway (default; Kong is available as an optional override via `sh run.sh config add kong`)
- **[Auth](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management
- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API
- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets
- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions
- **[imgproxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server
- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries)
- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development
- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services
- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform
- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs
- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler
## Documentation
- **[Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides
- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services
- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference
- **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration
- **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables
- **[Update your deployment](https://supabase.com/docs/guides/self-hosting/updating)** - Update an existing deployment with `update.sh`
## Updates
Back up your database, then:
```sh
sh update.sh --dry-run # optional preview
sh update.sh
sh run.sh pull && sh run.sh recreate
```
See the **[update guide](https://supabase.com/docs/guides/self-hosting/updating)** for conflicts,
breaking changes, pinning a release, and older installs without `.supabase-version`.
## Community & Support
For troubleshooting common issues, see:
- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds
- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues
- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides
Self-hosted Supabase is community-supported. Get help and connect with other users:
- [Discord](https://discord.supabase.com) - Real-time chat and community support
- [Reddit](https://www.reddit.com/r/Supabase/) - Official Supabase subreddit
Share your self-hosting experience:
- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - "Self-hosting: What's working (and what's not)?"
## Important Notes
### Security
⚠️ **The default configuration is not secure for production use.**
Before deploying to production, you must:
- [Update](https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase) all default passwords and secrets in the `.env` file
- Review and update CORS settings
- Consider setting up a secure proxy in front of self-hosted Supabase
- Review and adjust network security configuration (ACLs, etc.)
- Set up proper backup procedures
See the [main installation guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos in the documentation.
## License
This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details.
+48
View File
@@ -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' );
@@ -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
+42
View File
@@ -0,0 +1,42 @@
services:
# Caddy terminates TLS and forwards to the API gateway (api-gw) on port 8000,
# so the gateway's own host port binding is removed here. This works for
# either gateway, since the service is named api-gw in both cases.
api-gw:
ports: !reset []
# When using the Kong override uncomment the following:
#environment:
# KONG_PORT_MAPS: "443:8000,443:8443"
caddy:
container_name: supabase-caddy
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
depends_on:
api-gw:
condition: service_healthy
studio:
condition: service_healthy
environment:
PROXY_DOMAIN: ${PROXY_DOMAIN}
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
command:
- /bin/sh
- -c
- |
PROXY_AUTH_PASSWORD=$$(caddy hash-password --plaintext "$$PROXY_AUTH_PASSWORD") && \
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
volumes:
- ./volumes/proxy/caddy:/etc/caddy
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
+15
View File
@@ -0,0 +1,15 @@
# DEPRECATED: Envoy is now the default API gateway defined directly in
# docker-compose.yml, so this override is no longer needed and does nothing.
#
# This no-op shim is kept for one release cycle so existing COMPOSE_FILE
# entries referencing it do not break. Remove it from your configuration:
#
# sh run.sh config remove envoy
#
# To run Kong instead of Envoy, use the Kong override:
#
# sh run.sh config add kong
#
# This file will be removed in a future release.
services: {}
+49
View File
@@ -0,0 +1,49 @@
# Kong API gateway override.
#
# Replaces the default Envoy gateway with Kong. Enable with:
# sh run.sh config add kong
# sh run.sh start
#
# This overrides the `api-gw` service in place, so its dependents (functions),
# the reverse-proxy overrides (caddy/nginx), and the `kong`/`envoy` network
# aliases keep working unchanged. Kong re-adds an HTTPS listener on 8443.
services:
api-gw:
container_name: supabase-kong
image: kong/kong:3.9.3
healthcheck:
test: !override ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
ports: !override
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp
- ${KONG_HTTPS_PORT:-8443}:8443/tcp
volumes: !override
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
- ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
#- ./volumes/api/server.crt:/home/kong/server.crt:ro
#- ./volumes/api/server.key:/home/kong/server.key:ro
environment: !override
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_ROUTER_FLAVOR: expressions
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_DNS_VALID_TTL: 5
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
#KONG_SSL_CERT: /home/kong/server.crt
#KONG_SSL_CERT_KEY: /home/kong/server.key
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: !override ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
+99
View File
@@ -0,0 +1,99 @@
# This override adds the following to the self-hosted Supabase configuration:
# - Logflare: Log management and event analytics platform
# - Vector: High-performance observability data pipeline for logs
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.logs.yml up -d
services:
studio:
depends_on:
analytics:
condition: service_healthy
environment:
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_URL: http://analytics:4000
ENABLED_FEATURES_LOGS_ALL: "true"
analytics:
container_name: supabase-analytics
image: supabase/logflare:1.43.1
restart: unless-stopped
#ports:
# - 4000:4000
healthcheck:
test:
[
"CMD-SHELL",
"curl -sSfL -o /dev/null http://localhost:4000/health"
]
timeout: 5s
interval: 5s
retries: 10
start_period: 30s
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
environment:
LOGFLARE_NODE_HOST: 127.0.0.1
DB_USERNAME: supabase_admin
DB_DATABASE: _supabase
DB_HOSTNAME: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_SCHEMA: _analytics
# Enable single-tenant mode for Logflare
LOGFLARE_SINGLE_TENANT: "true"
# Seed Supabase-related metadata
LOGFLARE_SUPABASE_MODE: "true"
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
# Comment out the following two variables when switching to
# the BigQuery backend for logs
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POSTGRES_BACKEND_SCHEMA: _analytics
# Uncomment to use the BigQuery backend for logs
#GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
#GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
# Uncomment to use the BigQuery backend for logs (requires gcloud.json
# service account key from Google Cloud Console)
#volumes:
# - ./gcloud.json:/opt/app/rel/logflare/bin/gcloud.json:ro,z
vector:
container_name: supabase-vector
image: timberio/vector:0.53.0-alpine
restart: unless-stopped
volumes:
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://vector:9001/health"
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
analytics:
condition: service_healthy
environment:
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
command:
[
"--config",
"/etc/vector/vector.yml"
]
security_opt:
- "label=disable"
+41
View File
@@ -0,0 +1,41 @@
services:
# nginx terminates TLS and forwards to the API gateway (api-gw) on port 8000,
# so the gateway's own host port binding is removed here. This works for
# either gateway, since the service is named api-gw in both cases.
api-gw:
ports: !reset []
# When using the Kong override, uncomment the following:
#environment:
# KONG_PORT_MAPS: "443:8000,443:8443"
nginx:
container_name: supabase-nginx
image: jonasal/nginx-certbot:6.2.0-nginx1.31.3
restart: unless-stopped
ports:
- "80:80"
- "443:443"
depends_on:
api-gw:
condition: service_healthy
studio:
condition: service_healthy
environment:
PROXY_DOMAIN: ${PROXY_DOMAIN}
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
command:
- /bin/bash
- -c
- |
printf '%s:%s\n' "$${PROXY_AUTH_USERNAME}" "$$(openssl passwd -apr1 "$${PROXY_AUTH_PASSWORD}")" > /etc/nginx/user_conf.d/dashboard-passwd && \
envsubst '$${PROXY_DOMAIN}' < /etc/nginx/supabase-nginx.conf.tpl > /etc/nginx/user_conf.d/nginx.conf && \
/scripts/start_nginx_certbot.sh
volumes:
- ./volumes/proxy/nginx/supabase-nginx.conf.tpl:/etc/nginx/supabase-nginx.conf.tpl:ro
- nginx_letsencrypt:/etc/letsencrypt
volumes:
nginx_letsencrypt:
+18
View File
@@ -0,0 +1,18 @@
# Postgres 15 override for self-hosted Supabase.
#
# Postgres 17 is now the default in docker-compose.yml. Use this override to pin
# Postgres 15, for two cases:
# 1. An existing Postgres 15 deployment that has not been upgraded yet.
# 2. As the rollback target for utils/upgrade-pg17.sh (PG 15 and PG 17 use
# different postgres UIDs, so rollback must start with the PG 15 image).
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
#
# To upgrade an existing Postgres 15 database to Postgres 17 in place, see:
# - https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17
# - utils/upgrade-pg17.sh
services:
db:
image: supabase/postgres:15.8.1.085
+19
View File
@@ -0,0 +1,19 @@
# Postgres 17 override for self-hosted Supabase.
#
# NOTE: Postgres 17 is now the default in docker-compose.yml, so this override
# is redundant for new installs (kept for explicitness / backwards compatibility).
# Keep the image tag here in sync with the db image in docker-compose.yml.
#
# Usage (new install or after upgrade):
# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
#
# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first.
#
# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a
# leftover db-config volume from PG 15, you may see
# "FATAL: invalid secret key". Either remove the old volume or fix ownership.
# See: docs/guides/self-hosting/postgres-upgrade-17
services:
db:
image: supabase/postgres:17.6.1.136
@@ -0,0 +1,47 @@
services:
rustfs:
image: rustfs/rustfs:1.0.0-beta.11
environment:
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CORS_ALLOWED_ORIGINS: "*"
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
security_opt:
- "no-new-privileges:true"
healthcheck:
test: [ "CMD", "curl", "-f", "http://rustfs:9000/health" ]
interval: 2s
timeout: 10s
retries: 5
volumes:
- rustfs-data:/data
rustfs-createbucket:
image: rustfs/rc
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -ec "
rc alias set supa-rustfs http://rustfs:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
rc mb --ignore-existing supa-rustfs/${GLOBAL_S3_BUCKET}
"
storage:
depends_on:
rustfs-createbucket:
condition: service_completed_successfully
rustfs:
condition: service_healthy
environment:
STORAGE_BACKEND: s3
GLOBAL_S3_ENDPOINT: http://rustfs:9000
GLOBAL_S3_PROTOCOL: http
GLOBAL_S3_FORCE_PATH_STYLE: true
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
volumes:
rustfs-data:
+46
View File
@@ -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:
+586
View File
@@ -0,0 +1,586 @@
# Usage
# Start: docker compose up -d
# Stop: docker compose down
# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d
# Reset everything: sh reset.sh
#
# Notes:
# - Nested variable interpolation (${A:-${B}}) requires podman-compose >= 1.6.0
#
name: supabase
services:
studio:
container_name: supabase-studio
image: supabase/studio:2026.08.03-sha-022b374
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
# Listen on all IPv4 interfaces
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access
POSTGRES_USER_READ_WRITE: postgres
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: http://api-gw:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
# See: docker-compose.logs.yml
ENABLED_FEATURES_LOGS_ALL: "false"
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
volumes:
- ./volumes/snippets:/app/snippets:z
- ./volumes/functions:/app/edge-functions:ro,z
# Envoy is the default API gateway
# See: https://github.com/orgs/supabase/discussions/48048
api-gw:
container_name: supabase-envoy
image: envoyproxy/envoy:v1.39.0
restart: unless-stopped
networks:
default:
# Expose `envoy` and `kong` as network aliases, so internal configs
# that reference either hostname resolve to whichever gateway is active.
aliases:
- envoy
- kong
healthcheck:
# Using a TCP port check because this image does not include curl or wget.
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/8000'"]
interval: 10s
timeout: 5s
retries: 3
depends_on:
studio:
condition: service_healthy
ports:
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}: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
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"]
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}
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}/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}/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}/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
# See: https://supabase.com/docs/guides/self-hosting/self-hosted-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_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION}
# Optional, defaults to API_EXTERNAL_URL if not set
# GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL}
# 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: "<standard-base64-secret>"
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true"
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt"
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true"
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt"
# GOTRUE_HOOK_SEND_SMS_ENABLED: "false"
# GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook"
# GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
# GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false"
# GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender"
# GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
rest:
container_name: supabase-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
healthcheck:
test: [ "CMD", "postgrest", "--ready" ]
interval: 5s
timeout: 5s
retries: 3
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
PGRST_DB_ANON_ROLE: anon
PGRST_ADMIN_SERVER_PORT: 3001
PGRST_ADMIN_SERVER_HOST: localhost
# PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS.
# For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or
# PGRST_JWT_SECRET: ${JWT_JWKS}
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command:
[
"postgrest"
]
realtime:
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
container_name: realtime-dev.supabase-realtime
image: supabase/realtime:v2.102.3
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
]
timeout: 5s
interval: 30s
retries: 3
start_period: 10s
environment:
PORT: 4000
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: ${REALTIME_DB_ENC_KEY:-supabaserealtime}
# Legacy symmetric HS256 key
API_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: API_JWT_JWKS: ${JWT_JWKS}
#API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
METRICS_JWT_SECRET: ${JWT_SECRET}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
DISABLE_HEALTHCHECK_LOGGING: "true"
# To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up
storage:
container_name: supabase-storage
image: supabase/storage-api:v1.60.4
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://storage:5000/status"
]
timeout: 5s
interval: 5s
retries: 3
start_period: 10s
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
# Legacy symmetric HS256 key
AUTH_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: JWT_JWKS: ${JWT_JWKS}
#JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
# S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
# S3 Backend configuration
#GLOBAL_S3_ENDPOINT: https://your-s3-endpoint
#GLOBAL_S3_PROTOCOL: https
#GLOBAL_S3_FORCE_PATH_STYLE: "true"
#AWS_ACCESS_KEY_ID: your-access-key-id
#AWS_SECRET_ACCESS_KEY: your-secret-access-key
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
# TODO: https://github.com/supabase/storage-api/issues/55
REGION: ${REGION}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
# S3 protocol endpoint configuration
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
volumes:
- ./volumes/storage:/var/lib/storage:z
imgproxy:
container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./volumes/storage:/var/lib/storage:z
healthcheck:
test:
[
"CMD",
"imgproxy",
"health"
]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
container_name: supabase-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: postgres
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.74.0
restart: unless-stopped
volumes:
- ./volumes/functions:/home/deno/functions:z
- deno-cache:/root/.cache/deno
depends_on:
api-gw:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/9000'"]
interval: 5s
timeout: 5s
retries: 3
environment:
# Legacy symmetric HS256 key
JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: SUPABASE_JWKS: ${JWT_JWKS}
#SUPABASE_JWKS: ${JWT_JWKS:-{"keys":[]}}
SUPABASE_URL: http://api-gw: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
# To upgrade an existing Postgres 15 database in place, see utils/upgrade-pg17.sh.
image: supabase/postgres:17.6.1.136
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:
+76
View File
@@ -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 ""
+297
View File
@@ -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 <name> | config remove <name>
# (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 <svc>... # 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 <svc>... # 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 <service> # docker inspect on a service's container
# sh run.sh printenv <service> # 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 <name> # add an override to COMPOSE_FILE in .env
# sh run.sh config remove <name> # 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 <<EOF
############
# Docker compose override files to layer on top of docker-compose.yml.
# Colon-separated list. Manage with: sh run.sh config add|remove <name>.
#
# 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 <svc>..." >&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 <svc>..." >&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 <service> [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 <service>" >&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 <name>..." >&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 <name>..." >&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 <name>... | config remove <name>..." >&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 <<EOF
Usage: $(basename "$0") <command>
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 <svc>...
Restart all services except the named ones
recreate [service] Stop then start, or force-recreate one service (--no-deps)
recreate --except <svc>...
Force-recreate all services except the named ones (--no-deps)
status Show service status
logs [service] Follow logs (optionally for a single service)
inspect <service> Inspect a service's container (forwards extra args to docker inspect)
printenv <service> Print a service's environment variables (one per line)
pull Pull all images
config Show the active COMPOSE_FILE list
config add <name> Add an override to COMPOSE_FILE in .env (short name or full filename)
config remove <name> 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
+455
View File
@@ -0,0 +1,455 @@
#!/bin/sh
#
# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora).
#
# What it does:
# 1. Installs prerequisites: git, openssl, jq, ca-certificates
# 2. Installs Docker Engine + Compose plugin (if missing)
# 3. Optionally installs the AWS CLI v2 (--with-aws)
# 4. Sparse-clones the repo to extract the contents of ./docker
# 5. Creates a project directory in CWD and copies docker/* into it
# 6. Records the base version the deployment was set up from (.supabase-version)
# 7. Prompts for the main URLs and writes them to .env
# 8. Generates secrets and asymmetric API keys via utils/*.sh
#
# Usage:
# sh setup.sh # interactive
# sh setup.sh -y # accept defaults, no prompts
# sh setup.sh --project-dir my-supabase # name the project directory
# sh setup.sh --skip-deps # skip system-package installation
# sh setup.sh --with-aws # also install the AWS CLI v2
# sh setup.sh --ref self-hosted/v0.7.0 # clone docker/ from a specific git ref
# sh setup.sh --head # clone docker/ from HEAD (skip tags)
#
# curl -fsSL <url-to-this-script> | sh # bootstrap from scratch in CWD
#
# By default the docker/ sources are cloned from the latest self-hosted release
# tag (self-hosted/v*), falling back to the default branch (HEAD) if none exist.
#
set -e
PROJECT_DIR="supabase-project"
SKIP_DEPS=0
WITH_AWS=0
ASSUME_YES=0
SOURCE_REF=""
FORCE_HEAD=0
print_help() {
cat <<EOF
Usage: setup.sh [options]
Options:
-p, --project-dir <name> Name of the project directory (default: supabase-project)
--skip-deps Skip installation of system packages
--with-aws Install the AWS CLI v2
--ref <tag|branch> Clone docker/ from this git ref instead of the
latest self-hosted tag (no HEAD fallback)
--head Clone docker/ from the default branch (HEAD),
skipping self-hosted tag detection
-y, --yes Non-interactive: accept defaults, no prompts
-h, --help Show this help and exit
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-p|--project-dir) PROJECT_DIR="$2"; shift 2 ;;
--skip-deps) SKIP_DEPS=1; shift ;;
--with-aws) WITH_AWS=1; shift ;;
--ref) SOURCE_REF="$2"; shift 2 ;;
--head) FORCE_HEAD=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) print_help; exit 0 ;;
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
esac
done
# Interactive vs not: -y forces non-interactive; otherwise we're non-interactive
# when there's no controlling terminal to prompt on (e.g. curl | sh in CI).
if [ "$ASSUME_YES" = "1" ] || ! ( : < /dev/tty ) 2>/dev/null; then
NON_INTERACTIVE=1
else
NON_INTERACTIVE=0
fi
if [ "$(id -u)" = "0" ]; then
SUDO=""
else
SUDO="sudo"
fi
log() { printf "===> %s\n" "$*"; }
warn() { printf "WARNING: %s\n" "$*" >&2; }
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
# Prompt with a default; echoes the chosen value on stdout.
# Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh).
# Falls back to the default with -y or when no controlling terminal exists.
ask() {
# ask <prompt> <default> -> echoes chosen value
if [ "$NON_INTERACTIVE" = "1" ]; then
printf '%s' "$2"
return
fi
printf "%s [%s]: " "$1" "$2" > /dev/tty
read -r reply < /dev/tty
[ -z "$reply" ] && reply="$2"
printf '%s' "$reply"
}
# True if the value looks like an http(s) URL (scheme check only, not validation).
valid_url() {
case "$1" in
http://?*|https://?*) return 0 ;;
*) return 1 ;;
esac
}
# Like ask, but requires an http(s):// value. Re-prompts interactively; with no
# usable terminal (-y or curl | sh) a bad value is fatal rather than silently kept.
ask_url() {
# ask_url <prompt> <default> -> echoes a validated URL
while :; do
_url=$(ask "$1" "$2")
valid_url "$_url" && { printf '%s' "$_url"; return 0; }
if [ "$NON_INTERACTIVE" = "1" ]; then
die "$1 must start with http:// or https:// (got: '$_url')"
fi
printf " '%s' is not a URL - it must start with http:// or https://.\n" "$_url" > /dev/tty
done
}
OS_FAMILY=""
OS_ID=""
OS_CODENAME=""
detect_os() {
[ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only."
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="$ID"
OS_CODENAME="${VERSION_CODENAME:-}"
case "$ID" in
ubuntu|debian) OS_FAMILY="debian" ;;
centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;;
*)
case "${ID_LIKE:-}" in
*debian*|*ubuntu*) OS_FAMILY="debian" ;;
*rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;;
*) die "Unsupported distribution: $ID" ;;
esac
;;
esac
log "Detected OS: $ID ($OS_FAMILY)"
}
pkg_update() {
if [ "$OS_FAMILY" = "debian" ]; then
$SUDO apt-get update -qq -y
else
$SUDO dnf makecache -q -y || true
fi
}
pkg_install() {
if [ "$OS_FAMILY" = "debian" ]; then
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get install -qq -y "$@"
else
$SUDO dnf install -q -y "$@"
fi
}
install_base_packages() {
log "Installing base packages: git, openssl, jq, ca-certificates"
pkg_update
if [ "$OS_FAMILY" = "debian" ]; then
pkg_install git openssl jq ca-certificates \
apt-transport-https gnupg lsb-release
else
pkg_install git openssl jq ca-certificates dnf-plugins-core
fi
}
docker_present() {
command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1
}
install_docker() {
if docker_present; then
log "Docker already installed: $(docker --version)"
return 0
fi
log "Installing Docker Engine and Compose plugin"
if [ "$OS_FAMILY" = "debian" ]; then
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \
| $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
$SUDO chmod a+r /etc/apt/keyrings/docker.gpg
codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}"
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \
| $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update -y
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
else
# Amazon Linux
if [ "$OS_ID" = "amzn" ]; then
# Install Docker from the repo
pkg_install docker
# Install Docker Compose
$SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \
$SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose && \
$SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
else
repo_distro="centos"
case "$OS_ID" in
fedora) repo_distro="fedora" ;;
rhel) repo_distro="rhel" ;;
esac
$SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \
|| $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo"
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
fi
log "Enabling and starting docker service"
$SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually."
docker_present || die "Docker installation finished but 'docker compose' is still unavailable."
}
install_aws() {
if command -v aws >/dev/null 2>&1; then
log "AWS CLI already installed: $(aws --version 2>&1)"
return 0
fi
arch=$(uname -m)
case "$arch" in
x86_64|amd64) aws_arch="x86_64" ;;
aarch64|arm64) aws_arch="aarch64" ;;
*) die "Unsupported architecture for AWS CLI: $arch" ;;
esac
log "Installing AWS CLI v2 (${aws_arch})"
command -v unzip >/dev/null 2>&1 || pkg_install unzip
tmp=$(mktemp -d)
(
cd "$tmp"
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip
unzip -q -o awscliv2.zip
$SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
)
rm -rf "$tmp"
}
SRC_DIR=""
SRC_TMP=""
RESOLVED_REF=""
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
STAMP_FILE=".supabase-version"
# Highest self-hosted/v* tag on the remote, or empty when the remote has none.
# Returns non-zero (printing nothing) when the remote can't be reached.
latest_release_tag() {
_refs=$(git ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || return 1
printf '%s\n' "$_refs" \
| sed 's#^.*refs/tags/##' \
| grep -E '^self-hosted/v[0-9]' \
| sort -V | tail -n1
}
# Clone only docker/ from <ref> (empty = default branch) into <dest>.
sparse_clone() {
# sparse_clone <dest> [ref]
_dest="$1"
_ref="$2"
if [ -n "$_ref" ]; then
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
--branch "$_ref" "$REPO_URL" "$_dest" 2>/dev/null || return 1
else
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
"$REPO_URL" "$_dest" 2>/dev/null || return 1
fi
( cd "$_dest" &&
git sparse-checkout init --cone &&
git sparse-checkout set docker &&
git checkout --quiet ) 2>/dev/null || return 1
}
# Echo the commit the clone resolved to (for stamping HEAD-based checkouts).
resolved_sha() {
git -C "$1" rev-parse HEAD 2>/dev/null || true
}
prepare_source() {
SRC_TMP=$(mktemp -d) || die "Could not create a temporary directory"
dest="$SRC_TMP/supabase"
# Pick the ref to clone; empty means the default branch (HEAD), used only
# when no release tag exists yet (or --head). A resolved tag that fails to
# clone is an error, not a reason to silently install HEAD instead.
if [ -n "$SOURCE_REF" ]; then
ref="$SOURCE_REF"
elif [ "$FORCE_HEAD" = "1" ]; then
ref=""
else
if ref=$(latest_release_tag); then
[ -n "$ref" ] || log "No self-hosted release tag found; using the default branch (HEAD)"
else
die "Could not reach $REPO_URL to look up release tags. Check your network and retry, or pass --head (default branch) or --ref <tag>."
fi
fi
log "Sparse-cloning supabase repo at ${ref:-HEAD}"
sparse_clone "$dest" "$ref" || die "Could not clone '${ref:-HEAD}' from $REPO_URL"
# Stamp the tag name for a release tag, else the exact commit SHA.
case "$ref" in
self-hosted/v*) RESOLVED_REF="$ref" ;;
*) RESOLVED_REF=$(resolved_sha "$dest") ;;
esac
SRC_DIR="$dest/docker"
}
cleanup_src_tmp() {
if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then
rm -rf "$SRC_TMP"
fi
}
trap cleanup_src_tmp EXIT
read_env() {
grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2-
}
# Record the base version this deployment was set up from, so update.sh can
# 3-way merge future upgrades against it: it fetches the snapshot at this ref
# as the merge base, and derives the base version from the ref itself. Just the
# ref - per-deployment state, not vendor content: gitignored.
write_version_stamp() {
[ -n "$1" ] || { warn "Could not resolve a base ref; skipping $STAMP_FILE"; return 0; }
{
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
echo "ref=$1"
} > "$STAMP_FILE"
log "Recorded base version in $STAMP_FILE (ref=$1)"
}
# --- Main ---
log "Setup starting in $(pwd)"
log "This may take several minutes..."
if [ "$SKIP_DEPS" = "1" ]; then
log "Skipping system-package installation (--skip-deps)"
else
detect_os
install_base_packages
install_docker
fi
if [ "$WITH_AWS" = "1" ]; then
install_aws
fi
# Idempotent re-run: if CWD is already a set-up project, skip bootstrap.
# A clone has docker-compose.yml + utils/ but only .env.example;
# a set-up project also has a real .env.
if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then
log "Already in a Supabase project directory; skipping bootstrap."
exit 0
fi
prepare_source
target="$(pwd)/$PROJECT_DIR"
if [ -e "$target" ]; then
die "Target $target already exists. Pick a different name with --project-dir"
fi
log "Creating project at $target"
mkdir -p "$target"
cp -rf "$SRC_DIR/." "$target/"
if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then
cp "$target/.env.example" "$target/.env"
fi
cd "$target"
current_public_url=$(read_env SUPABASE_PUBLIC_URL)
current_site_url=$(read_env SITE_URL)
[ -z "$current_public_url" ] && current_public_url="http://localhost:8000"
[ -z "$current_site_url" ] && current_site_url="http://localhost:3000"
if [ "$NON_INTERACTIVE" = "1" ]; then
log "Non-interactive: using default URLs (edit .env to change)"
else
echo ""
echo "Configure the main URLs (press Enter to accept the default)."
echo ""
fi
public_url=$(ask_url "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url")
api_url=$(ask_url "API_EXTERNAL_URL (Auth callbacks)" "$public_url/auth/v1")
site_url=$(ask_url "SITE_URL (default Auth redirect)" "$current_site_url")
# Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish)
public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||')
case "$public_host" in
localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;;
*) current_proxy_domain="$public_host" ;;
esac
[ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com"
proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain")
# Derive CERTBOT_EMAIL = admin@<last-two-labels-of-proxy-domain>.
# Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after.
domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }')
certbot_email="admin@${domain_root}"
log "Setting CERTBOT_EMAIL=${certbot_email}"
sed -i.old \
-e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \
-e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \
-e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \
-e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \
-e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \
.env
rm -f .env.old
log "Generating secrets and legacy API keys"
sh utils/generate-keys.sh --update-env
log "Generating asymmetric key pair and opaque API keys"
sh utils/add-new-auth-keys.sh --update-env
write_version_stamp "$RESOLVED_REF"
log "Pulling Docker images"
if [ "$NON_INTERACTIVE" = "1" ]; then
docker compose --progress quiet pull || warn "docker compose pull failed; you can retry later."
else
docker compose pull || warn "docker compose pull failed; you can retry later."
fi
echo ""
echo "Setup complete. Project ready at: $(pwd)"
echo ""
echo "Next steps:"
echo " cd $(pwd)"
echo " sh run.sh config"
echo " sh run.sh secrets"
echo " sh run.sh start"
echo ""
echo "To enable docker-compose overrides (caddy, nginx, logs, rustfs, s3, kong):"
echo " sh run.sh config add caddy"
echo ""
@@ -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"
@@ -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"
+398
View File
@@ -0,0 +1,398 @@
#!/bin/sh
#
# Test API key types and asymmetric auth against a running self-hosted instance.
#
# Usage:
# sh test-auth-keys.sh # Uses http://localhost:8000
# sh test-auth-keys.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with all keys configured
# - jq (for JSON parsing)
# - node >= 16 (for HS256 token minting test only)
#
set -e
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in jq node; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-)
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name (HTTP $actual)"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
http_status() {
url="$1"
shift
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
}
echo ""
echo "=== Testing against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. Route tests with API key types
# ---------------------------------------------
echo "--- REST API (/rest/v1/) ---"
check "Legacy ANON_KEY -> 403" "403" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")"
check "Legacy SERVICE_ROLE_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> 403" "403" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
check "New SECRET_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")"
else
echo " SKIP: Opaque keys not configured"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/")"
check "Invalid key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")"
echo ""
echo "--- Auth (/auth/v1/settings) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/auth/v1/settings")"
echo ""
echo "--- Storage (/storage/v1/bucket) ---"
# Storage has no key-auth - passes through, Storage returns its own errors
check "No key -> not 401 (Storage handles auth)" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# With opaque key, the API gateway translates to asymmetric JWT in Authorization
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- Storage S3 (/storage/v1/s3/) ---"
# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression
# passes the Authorization header through unchanged for non-sb_ values
check "S3 route accessible" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)"
echo ""
echo "--- GraphQL (/graphql/v1) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
echo ""
echo "--- Realtime REST (/realtime/v1/api/) ---"
# Realtime REST API - use /api/ping to verify key auth (expect 200 with a valid key)
check "Legacy ANON_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/realtime/v1/api/ping")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "/api/tenants blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")"
check "/api/openapi blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")"
echo ""
echo "--- supabase-js style requests (apikey + Authorization) ---"
# supabase-js sends both apikey header AND Authorization: Bearer <apikey>
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "apikey + Authorization: Bearer sb_ (replace path)" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
check "secret apikey + Authorization: Bearer sb_secret" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_SECRET_KEY" \
-H "Authorization: Bearer $SUPABASE_SECRET_KEY")"
fi
check "Legacy apikey + Authorization: Bearer <legacy jwt>" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY")"
check "Service role apikey + Authorization: Bearer <legacy jwt>" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")"
echo ""
echo "--- Edge cases ---"
# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "sb_ in Authorization only (no apikey) -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- JWKS endpoint ---"
check "JWKS public endpoint (no auth)" "200" \
"$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")"
# Verify JWKS content: should have EC key, should NOT have symmetric key
jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json")
jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null)
jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null)
check "JWKS contains EC public key" "true" "$jwks_has_ec"
check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct"
#echo ""
#echo "--- OAuth metadata endpoint ---"
#check "well-known oauth (no auth)" "200" \
# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")"
echo ""
echo "--- Realtime WebSocket upgrade ---"
# Test that WebSocket upgrade request gets through (expect 101 or non-401)
# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open)
ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
# 101 = upgrade success, 000 = timeout (connection stayed open = success)
check "WebSocket upgrade with legacy key -> not 401" "true" \
"$([ "$ws_status" != "401" ] && echo true || echo false)"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
check "WebSocket upgrade with opaque key -> not 401" "true" \
"$([ "$ws_status_new" != "401" ] && echo true || echo false)"
fi
# ---------------------------------------------
# 2. User session JWT tests
# ---------------------------------------------
echo ""
echo "--- User session JWT ---"
# Create user via admin API (works regardless of email autoconfirm setting)
test_email="test-keys-$$@example.com"
test_password="test-password-123456"
create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
# Sign in to get session JWT
auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$access_token" ]; then
# Check the algorithm in the JWT header
jwt_alg=$(echo "$access_token" | cut -d. -f1 | \
jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null)
if [ -n "$jwt_alg" ]; then
echo " INFO: User session JWT signed with: $jwt_alg"
if [ "$jwt_alg" = "ES256" ]; then
check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg"
else
check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg"
fi
fi
# Use the session JWT with PostgREST
check "Session JWT with PostgREST" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
check "Session JWT with PostgREST + service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $access_token")"
# Use the session JWT with Storage
check "Session JWT with Storage" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
# CRITICAL: Authenticated user + opaque key (most common supabase-js flow)
# supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer <user_session_jwt>
# The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
echo ""
echo "--- Authenticated user + opaque key (critical path) ---"
check "Opaque apikey + user JWT -> PostgREST uses user JWT" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Secret apikey + user JWT -> PostgREST allowed" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_SECRET_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \
"$(http_status "$BASE_URL/auth/v1/user" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
fi
else
check "Sign in test user" "true" "false"
fi
# Clean up test user
if [ -n "$test_user_id" ]; then
curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY"
fi
# ---------------------------------------------
# 3. HS256 backward compatibility
# ---------------------------------------------
echo ""
echo "--- HS256 backward compatibility ---"
# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token)
hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e "
const crypto = require('crypto');
const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
const payload = Buffer.from(JSON.stringify({
role:'anon',iss:'supabase',
iat:Math.floor(Date.now()/1000),
exp:Math.floor(Date.now()/1000)+3600
})).toString('base64url');
const sig = crypto.createHmac('sha256',process.env.JWT_SECRET)
.update(header+'.'+payload).digest('base64url');
console.log(header+'.'+payload+'.'+sig);
" 2>/dev/null)
if [ -n "$hs256_token" ]; then
check "HS256 token with PostgREST (backward compat)" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $hs256_token")"
check "HS256 token with PostgREST + service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $hs256_token")"
else
echo " SKIP: Could not mint HS256 token (node required)"
fi
# ---------------------------------------------
# 4. JWT_KEYS format validation
# ---------------------------------------------
echo ""
echo "--- JWT_KEYS format ---"
JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-)
if [ -n "$JWT_KEYS_VAL" ]; then
# Auth expects a JSON array, not a JWKS object
jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array"
jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign"
else
echo " SKIP: JWT_KEYS not configured"
fi
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
@@ -0,0 +1,169 @@
#!/bin/sh
#
# Verify all self-hosted Supabase services started correctly by checking log output.
#
# Usage:
# sh test-container-logs.sh
#
# Prerequisites:
# - Running self-hosted Supabase instance (docker compose up)
#
set -e
pass=0
fail=0
project_name="${COMPOSE_PROJECT_NAME:-supabase}"
fail_msg() {
fail=$((fail + 1))
echo " FAIL: $1"
}
pass_msg() {
pass=$((pass + 1))
echo " PASS: $1"
}
# The `docker ps` fallback in the helpers below exists because the script
# doesn't know which compose `-f` flags the user ran `up` with. `docker compose
# ps` only sees services defined in the currently loaded compose files, but
# compose stamps `com.docker.compose.{project,service}` labels at `up` time -
# so a label-based lookup finds the container regardless of which override
# files are active in this shell.
is_service_running() {
service="$1"
if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then
return 0
fi
docker ps --filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--filter "status=running" \
--quiet | grep -q '.'
}
get_container_id() {
service="$1"
container_id=$(docker compose ps -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
printf '%s' "$container_id"
return
fi
container_id=$(docker ps -a \
--filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--quiet)
set -- $container_id
printf '%s' "$1"
}
# Check that a service's logs contain all expected patterns.
# Logs are written to a temp file so that grep -q exits cleanly.
check_logs() {
service="$1"
shift
logfile=$(mktemp)
docker compose logs "$service" > "$logfile" 2>/dev/null || true
if [ ! -s "$logfile" ]; then
container_id=$(get_container_id "$service")
if [ -n "$container_id" ]; then
docker logs "$container_id" > "$logfile" 2>&1 || true
fi
fi
if [ ! -s "$logfile" ]; then
rm -f "$logfile"
fail_msg "$service (no logs found)"
return
fi
for pattern in "$@"; do
if ! grep -q -i -E "$pattern" "$logfile"; then
rm -f "$logfile"
fail_msg "$service (missing: $pattern)"
return
fi
done
rm -f "$logfile"
pass_msg "$service"
}
check_logs_if_running() {
service="$1"
shift
if is_service_running "$service"; then
check_logs "$service" "$@"
else
pass_msg "$service (skipped: service not running)"
fi
}
echo ""
echo "=== Checking service startup logs ==="
echo ""
check_logs db \
'PostgreSQL init process complete; ready for start up.|Skipping initialization'
check_logs auth \
'db worker started'
# API gateway: Envoy by default, or Kong when the kong override is enabled.
# The service is named api-gw in both cases, so accept either startup marker.
check_logs api-gw \
'init\.lua.*declarative config loaded|Envoy configuration generated successfully'
check_logs rest \
'Schema cache loaded in.*milliseconds'
check_logs realtime \
'Starting Realtime' \
'Connected to Postgres database' \
'Janitor started' \
'Starting MetricsCleaner'
check_logs storage \
'Started Successfully'
check_logs studio \
'ready in.*s$'
check_logs meta \
'Server listening at http'
check_logs functions \
'main function started'
check_logs_if_running analytics \
'Access LogflareWeb.Endpoint at http://localhost:4000' \
'Executing startup tasks' \
'Ensuring single tenant user is seeded'
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 <service>"
echo ""
exit 1
fi
+355
View File
@@ -0,0 +1,355 @@
#!/bin/sh
#
# Test Postgres 15 -> 17 upgrade for self-hosted Supabase.
#
# Seeds test data on a running Postgres 15 stack, runs the upgrade script,
# and verifies data integrity + service connectivity using pgTAP.
#
# Usage:
# cd docker/
# sudo bash tests/test-pg17-upgrade.sh
#
# Prerequisites:
# - Running self-hosted Supabase with a clean, tests-only Postgres 15.
# Postgres 17 is now the default, so start PG 15 explicitly via the override:
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
# - .env file with POSTGRES_PASSWORD, ANON_KEY
#
set -eu
DB_CONTAINER="supabase-db"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the docker/ directory."
exit 1
fi
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-)
anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true)
if [ -z "$pg_password" ]; then
echo "Error: POSTGRES_PASSWORD not set in .env"
exit 1
fi
run_sql() {
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
}
echo ""
echo "=== Postgres 15 -> 17 Upgrade Test ==="
echo ""
# --- Verify we're starting from Postgres 15 --------------------------------
current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1)
case "$current_version" in
15.*) echo "Starting version: PostgreSQL $current_version" ;;
17.*) echo "Error: Already on Postgres 17. Start with a PG 15 stack."; exit 1 ;;
*) echo "Error: Unexpected version: $current_version"; exit 1 ;;
esac
# --- Seed test data --------------------------------------------------------
# Note: this script is designed to run against a fresh docker-compose stack,
# not an existing database with user data.
echo ""
echo "Seeding test data on Postgres 15..."
run_sql <<'EOSQL'
-- Test table with various column types
CREATE TABLE IF NOT EXISTS public._upgrade_test (
id serial PRIMARY KEY,
name text NOT NULL,
value numeric(10,2),
created_at timestamptz DEFAULT now(),
metadata jsonb
);
TRUNCATE public._upgrade_test;
INSERT INTO public._upgrade_test (name, value, metadata) VALUES
('alpha', 1.50, '{"tag": "a"}'),
('bravo', 2.75, '{"tag": "b"}'),
('charlie', 3.00, '{"tag": "c"}'),
('delta', 4.25, '{"tag": "d"}'),
('echo', 5.99, '{"tag": "e"}');
-- Index
CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name);
-- Function
CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int)
RETURNS int LANGUAGE sql IMMUTABLE AS $$
SELECT n * 2;
$$;
-- Grant access so PostgREST can read it
GRANT SELECT ON public._upgrade_test TO anon, authenticated;
-- pg_cron: created here as supabase_admin (the common manually-enabled case),
-- so the extension is owned by supabase_admin, NOT postgres. complete.sh's
-- drop+recreate only fires for postgres-owned pg_cron, so this exercises the
-- version reconcile in the upgrade script: Supabase PG 15 registers pg_cron
-- as '1.6', while the target image packages it as '1.6.4' with no update
-- path between them.
CREATE EXTENSION IF NOT EXISTS pg_cron;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
PERFORM cron.unschedule('upgrade_test_job');
END IF;
END $$;
SELECT cron.schedule('upgrade_test_job', '5 4 * * *', 'SELECT 1');
EOSQL
pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]')
pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]')
echo " Rows: $pre_count"
echo " Checksum: $pre_checksum"
# --- Seed a Vault secret ---------------------------------------------------
# Verifies the pgsodium root key survives the volume swap/chown AND that Vault
# secrets still decrypt on Postgres 17. For legacy (key_id-based) secrets this
# also exercises complete.sh's pgsodium->Vault re-encryption; on a stock
# self-hosted stack the secret is already pgsodium-less, so this confirms the
# round-trip and the post-upgrade invariant (key_id IS NULL).
VAULT_SECRET_NAME="upgrade_test_secret"
VAULT_SECRET_VALUE="upgrade-test-secret-value-42"
vault_available="f"
if [ "$(run_sql -A -t -c "SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'supabase_vault');" | tr -d '[:space:]')" = "t" ]; then
echo ""
echo "Seeding Vault secret on Postgres 15..."
run_sql <<EOSQL
CREATE EXTENSION IF NOT EXISTS supabase_vault;
-- Pre-clean so a re-run after a mid-run failure (where the trailing cleanup
-- never executed) does not abort on the unique (name) index.
DELETE FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}';
SELECT vault.create_secret('${VAULT_SECRET_VALUE}', '${VAULT_SECRET_NAME}', 'pg17 upgrade test');
EOSQL
pre_secret=$(run_sql -A -t -c "SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}';" | tr -d '\n')
if [ "$pre_secret" = "$VAULT_SECRET_VALUE" ]; then
echo " Seeded '${VAULT_SECRET_NAME}' (decrypts correctly on PG 15)"
else
echo " Warning: seeded secret did not decrypt as expected on PG 15" >&2
fi
vault_available="t"
else
echo ""
echo "Skipping Vault seed: supabase_vault extension not available."
fi
# --- Run upgrade -----------------------------------------------------------
echo ""
echo "Running upgrade script..."
echo ""
bash utils/upgrade-pg17.sh --yes
echo ""
# --- Verify with pgTAP ----------------------------------------------------
echo "Running pgTAP verification..."
echo ""
# Optional Vault assertions, only when a secret was seeded above.
vault_plan=0
vault_tests=""
if [ "$vault_available" = "t" ]; then
vault_plan=3
vault_tests=$(cat <<EOSQL
-- Vault secret survived the upgrade and still decrypts (root key intact).
SELECT ok(
EXISTS (SELECT 1 FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
'Vault secret survived upgrade'
);
SELECT is(
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}'),
'${VAULT_SECRET_VALUE}',
'Vault secret still decrypts to original plaintext after upgrade'
);
SELECT ok(
(SELECT key_id IS NULL FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
'Vault secret is in pgsodium-less format (key_id IS NULL) after upgrade'
);
EOSQL
)
fi
total_plan=$((16 + vault_plan))
# Use a non-quoted heredoc so $pre_count, $pre_checksum, $total_plan and
# $vault_tests are interpolated.
run_sql <<EOSQL
CREATE EXTENSION IF NOT EXISTS pgtap;
SELECT plan(${total_plan});
-- Version
SELECT ok(version() LIKE 'PostgreSQL 17%', 'Running Postgres 17');
-- Table
SELECT has_table('public', '_upgrade_test', 'Test table survived upgrade');
-- Row count
SELECT is(
(SELECT count(*)::int FROM public._upgrade_test),
${pre_count},
'Row count preserved'
);
-- Data checksum
SELECT is(
(SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test),
'${pre_checksum}',
'Data checksum matches'
);
-- Index
SELECT has_index('public', '_upgrade_test', '_upgrade_test_name_idx', 'Index survived upgrade');
-- Function
SELECT has_function('public', '_upgrade_test_fn', ARRAY['integer'], 'Function survived upgrade');
SELECT is(public._upgrade_test_fn(21), 42, 'Function returns correct result');
-- Core extensions
-- Note: pgsodium may not be created as an extension in the postgres database
-- on default self-hosted installs (it's loaded via shared_preload_libraries
-- but the CREATE EXTENSION is conditional in the init migration).
SELECT ok(
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net'),
'pg_net extension exists'
);
-- Roles
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_etl_admin'),
'supabase_etl_admin role exists'
);
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_read_only_user'),
'supabase_read_only_user role exists'
);
-- postgres is not superuser
SELECT ok(
NOT (SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'),
'postgres role is not superuser'
);
-- pg_cron: registered as 1.6 on PG 15; the upgrade must reconcile the version
-- label to the target's packaged 1.6.4 and preserve scheduled jobs.
SELECT ok(
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron'),
'pg_cron extension exists'
);
SELECT is(
(SELECT extversion FROM pg_extension WHERE extname = 'pg_cron'),
'1.6.4',
'pg_cron version label reconciled to 1.6.4'
);
SELECT ok(
EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job'),
'pg_cron scheduled job survived the upgrade'
);
-- New predefined role (initdb-only migration; the target image's supautils.conf
-- references it, so it must exist on an upgraded instance).
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_privileged_role'),
'supabase_privileged_role exists'
);
-- Extension version reconcile: complete.sh ran with .063 binaries, so the
-- catalog should be reconciled to the target image's default version.
SELECT is(
(SELECT extversion FROM pg_extension WHERE extname = 'pg_net'),
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_net'),
'pg_net version reconciled to image default'
);
${vault_tests}
SELECT * FROM finish(true);
EOSQL
# --- Check service connectivity --------------------------------------------
echo ""
echo "Checking service connectivity..."
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
}
# PostgREST
if [ -n "$anon_key" ]; then
rest_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
-H "Authorization: Bearer $anon_key" \
"http://localhost:8000/rest/v1/_upgrade_test?select=count" 2>/dev/null) || rest_status="000"
check "PostgREST connectivity" "200" "$rest_status"
fi
# Auth health (needs apikey header through the API gateway)
if [ -n "$anon_key" ]; then
auth_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
"http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000"
check "Auth service health" "200" "$auth_status"
fi
echo ""
echo " Services: $pass passed, $fail failed"
# --- Clean up test artifacts ----------------------------------------------
echo ""
echo "Cleaning up test artifacts..."
run_sql <<'EOSQL' || true
DROP FUNCTION IF EXISTS public._upgrade_test_fn(int);
DROP TABLE IF EXISTS public._upgrade_test;
DROP EXTENSION IF EXISTS pgtap;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
PERFORM cron.unschedule('upgrade_test_job');
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'supabase_vault') THEN
DELETE FROM vault.secrets WHERE name = 'upgrade_test_secret';
END IF;
END $$;
EOSQL
# --- Summary --------------------------------------------------------------
echo ""
if [ "$fail" -gt 0 ]; then
echo "=== SOME TESTS FAILED ==="
exit 1
fi
echo "=== Upgrade test passed ==="
echo ""
echo "To reclaim disk space:"
echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz"
echo ""
+371
View File
@@ -0,0 +1,371 @@
#!/bin/sh
#
# Test S3 backend directly, bypassing the Storage service.
#
# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles
# all S3 operations that Storage relies on. Uses the aws cli so the test
# is backend-agnostic - no vendor-specific tools required.
#
# Usage:
# sh test-s3-backend.sh # Uses localhost:9100
# sh test-s3-backend.sh <backend_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 backend + test override:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
# -f ./tests/docker-compose.s3.test.yml up -d
# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET
# - aws cli v2
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read backend credentials from .env
BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-)
BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-)
GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
REGION="${REGION:-us-east-1}"
if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then
echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws commands against the backend directly.
#
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
# spot, so the check never records a FAIL and no summary is printed. The aws
# error is echoed to stderr so a FAIL stays explainable even where the caller
# discards stdout.
s3() {
s3_out=$(AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \
aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1) ||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
printf '%s\n' "$s3_out"
}
# Wrapper for jq that yields empty output instead of aborting the suite when
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
jq_r() {
jq -r "$@" 2>/dev/null || true
}
bucket_name="backend-test-$$"
echo ""
echo "=== S3 backend test against $BACKEND_URL ==="
echo ""
# ---------------------------------------------
# 1. ListBuckets (backend reachable)
# ---------------------------------------------
echo "--- Connectivity ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
check "Backend reachable (ListBuckets)" "true" "$list_ok"
if [ "$list_ok" != "true" ]; then
echo " Cannot reach backend. Is the test override running?"
echo " Response: $list_output"
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# ---------------------------------------------
# 2. Verify GLOBAL_S3_BUCKET exists
# ---------------------------------------------
echo ""
echo "--- Storage bucket ---"
if [ -n "$GLOBAL_S3_BUCKET" ]; then
storage_bucket_exists=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists"
else
echo " SKIP: GLOBAL_S3_BUCKET not set"
fi
# ---------------------------------------------
# 3. CreateBucket
# ---------------------------------------------
echo ""
echo "--- CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify in ListBuckets (separate call)
bucket_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "Bucket visible in ListBuckets" "true" "$bucket_found"
# ---------------------------------------------
# 4. PutObject
# ---------------------------------------------
echo ""
echo "--- PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from backend test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject" "true" "$put_ok"
# ---------------------------------------------
# 5. ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq_r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end')
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 6. HeadObject
# ---------------------------------------------
echo ""
echo "--- HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json)
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 7. GetObject + content verify
# ---------------------------------------------
echo ""
echo "--- GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from backend test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 8. CopyObject
# ---------------------------------------------
echo ""
echo "--- CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 9. DeleteObject
# ---------------------------------------------
echo ""
echo "--- DeleteObject ---"
s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copy_gone=$(echo "$list_after_delete" | \
jq_r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end')
check "Deleted object no longer listed" "true" "$copy_gone"
# ---------------------------------------------
# 10. Multipart upload (7MB)
# ---------------------------------------------
echo ""
echo "--- Multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size"
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 11. DeleteObjects (batch delete)
# ---------------------------------------------
echo ""
echo "--- DeleteObjects (batch) ---"
batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file"
echo "batch-a" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null
echo "batch-b" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null
echo "batch-c" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null
rm -f "$batch_file"
delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \
--delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \
--output json)
deleted_count=$(echo "$delete_objects_output" | jq_r '.Deleted | length')
check "DeleteObjects removed 3 objects" "3" "$deleted_count"
# Verify all gone
batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json)
batch_remaining=$(echo "$batch_list" | jq_r '[.Contents[]?] | length')
check "Batch-deleted objects gone" "0" "$batch_remaining"
# ---------------------------------------------
# 12. Presigned URLs
# ---------------------------------------------
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url" || true)
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 13. Conditional request (IfNoneMatch)
# ---------------------------------------------
echo ""
echo "--- Conditional request (IfNoneMatch) ---"
# --if-none-match requires aws cli v2.22+ ; skip if not supported
if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then
cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file"
echo "conditional test" > "$cond_file"
# First put should succeed (key doesn't exist)
first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true")
check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok"
# Second put should fail with PreconditionFailed (key exists)
cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false")
check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected"
rm -f "$cond_file"
else
echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)"
fi
# ---------------------------------------------
# 14. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 15. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$BACKEND_URL" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 16. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
bucket_gone=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
check "Test bucket deleted" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
+301
View File
@@ -0,0 +1,301 @@
#!/bin/sh
#
# Test S3 protocol endpoint for self-hosted Supabase Storage.
#
# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with
# standard S3 clients - the same way end users interact with it via
# aws cli, rclone, or other S3-compatible tools.
#
# Usage:
# sh test-s3.sh # Uses http://localhost:8000
# sh test-s3.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 enabled:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION
# - aws cli v2 (for S3 operations)
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
S3_ENDPOINT="$BASE_URL/storage/v1/s3"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-)
S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then
echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws s3/s3api commands with correct endpoint and credentials.
#
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
# spot, so the check never records a FAIL and no summary is printed. The aws
# error is echoed to stderr so a FAIL stays explainable even where the caller
# discards stdout.
s3() {
s3_out=$(AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1) ||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
printf '%s\n' "$s3_out"
}
# Wrapper for jq that yields empty output instead of aborting the suite when
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
jq_r() {
jq -r "$@" 2>/dev/null || true
}
bucket_name="s3-test-$$"
echo ""
echo "=== S3 protocol test against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. S3 ListBuckets
# ---------------------------------------------
echo "--- S3 ListBuckets ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
check "ListBuckets returns valid response" "true" "$list_ok"
# ---------------------------------------------
# 2. S3 CreateBucket
# ---------------------------------------------
echo ""
echo "--- S3 CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify bucket appears in ListBuckets (separate call)
s3_bucket_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "Bucket visible in ListBuckets" "true" "$s3_bucket_found"
# ---------------------------------------------
# 3. S3 PutObject
# ---------------------------------------------
echo ""
echo "--- S3 PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from s3 upload test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject upload" "true" "$put_ok"
# ---------------------------------------------
# 4. S3 ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- S3 ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq_r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end')
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 5. S3 HeadObject
# ---------------------------------------------
echo ""
echo "--- S3 HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json)
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 6. S3 GetObject (download) + content verify
# ---------------------------------------------
echo ""
echo "--- S3 GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from s3 upload test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 7. S3 CopyObject (server-side copy)
# ---------------------------------------------
echo ""
echo "--- S3 CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
# Verify copied content
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 8. S3 DeleteObject
# ---------------------------------------------
echo ""
echo "--- S3 DeleteObject ---"
s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null
# Verify object is gone
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copied_gone=$(echo "$list_after_delete" | \
jq_r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end')
check "Deleted object no longer listed" "true" "$copied_gone"
# ---------------------------------------------
# 9. Multipart upload (>5MB triggers multipart)
# ---------------------------------------------
echo ""
echo "--- S3 multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
# Verify size via HeadObject
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size"
# Download and verify size
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 10. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 11. Presigned URLs
# ---------------------------------------------
# Storage supports S3 presigned URLs (query-parameter auth).
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url" || true)
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 12. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$S3_ENDPOINT" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 13. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
# Verify bucket is gone
bucket_gone=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
check "Bucket deleted via S3" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
+498
View File
@@ -0,0 +1,498 @@
#!/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 <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with keys configured
# - jq (for JSON parsing)
# - sha256sum or shasum (for file integrity checks)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/"
exit 1
fi
# Portable file hash: prefers sha256sum (Linux), falls back to shasum (macOS)
if command -v sha256sum >/dev/null 2>&1; then
file_hash() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
file_hash() { shasum -a 256 "$1" | awk '{print $1}'; }
else
echo "Error: sha256sum or shasum not found."
exit 1
fi
# Read keys from .env
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
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 route with anon key" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY")"
echo ""
echo "--- PostgREST ---"
check "REST API route with service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY")"
# ---------------------------------------------
# 5. GraphQL
# ---------------------------------------------
echo ""
echo "--- GraphQL (optional; off by default) ---"
# pg_graphql is OFF by default since the PG17 image (the image drops the extension
# on init, matching platform behavior for new projects), but users may enable it
# (Studio extensions UI / CREATE EXTENSION pg_graphql). Both are valid states. A
# healthy endpoint returns HTTP 200 either way:
# enabled => {"data": ...}
# disabled => {"errors":[{"message":"pg_graphql extension is not enabled."}]}
# Assert the status AND the response shape, so a non-200, non-JSON, or empty body
# (a real gateway/runtime failure) is not silently classified as "disabled".
gql_status=$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')
gql_body=$(http_body "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')
if [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.data' >/dev/null 2>&1; then
gql_state="enabled"
elif [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.errors' >/dev/null 2>&1; then
gql_state="disabled"
else
gql_state="unhealthy (HTTP $gql_status)"
fi
case "$gql_state" in
enabled | disabled) gql_health="healthy" ;;
*) gql_health="unhealthy" ;;
esac
check "GraphQL endpoint healthy" "healthy" "$gql_health"
echo " (GraphQL is $gql_state)"
# ---------------------------------------------
# 6. Storage: create bucket, upload >6MB file, download, cleanup
# ---------------------------------------------
echo ""
echo "--- Storage: bucket + file lifecycle ---"
bucket_name="smoke-test-$$"
# Create bucket
create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}")
check "Create bucket" "200" "$create_bucket_status"
if [ "$create_bucket_status" = "200" ]; then
# Generate a ~7MB file
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null
# Upload file
upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$tmpfile")
check "Upload 7MB file" "200" "$upload_status"
# Download file and verify integrity
download_tmp=$(mktemp); cleanup_files="$cleanup_files $download_tmp"
curl -s "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" -o "$download_tmp"
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
download_size=$(wc -c < "$download_tmp" | tr -d ' ')
check "Download file (size matches)" "$original_size" "$download_size"
original_hash=$(file_hash "$tmpfile")
download_hash=$(file_hash "$download_tmp")
check "Download file (hash matches)" "$original_hash" "$download_hash"
rm -f "$download_tmp"
rm -f "$tmpfile"
# Signed URL: upload a small file, create signed URL, fetch without auth
sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: text/plain" \
--data-binary "signed url test content")
check "Upload file for signing" "200" "$sign_upload_status"
if [ "$sign_upload_status" = "200" ]; then
sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"expiresIn": 600}')
signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null)
if [ -n "$signed_path" ]; then
check "Create signed URL" "true" "true"
# Fetch signed URL without any auth headers (goes through the API gateway)
signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path")
check "Fetch signed URL (no auth)" "signed url test content" "$signed_content"
else
check "Create signed URL" "true" "false"
fi
fi
# Delete file
delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete file" "200" "$delete_file_status"
# Delete signed test file
http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
# Delete bucket
delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete bucket" "200" "$delete_bucket_status"
fi
# ---------------------------------------------
# 6b. Storage: TUS resumable upload
# ---------------------------------------------
echo ""
echo "--- Storage: TUS resumable upload ---"
tus_bucket="smoke-tus-$$"
tus_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$tus_bucket\",\"name\":\"$tus_bucket\",\"public\":true}")
check "TUS: create bucket" "200" "$tus_bucket_status"
if [ "$tus_bucket_status" = "200" ]; then
# Generate a ~7MB file (above Studio's 6MB TUS threshold)
tusfile=$(mktemp); cleanup_files="$cleanup_files $tusfile"
dd if=/dev/urandom of="$tusfile" bs=1048576 count=7 2>/dev/null
tus_file_size=$(wc -c < "$tusfile" | tr -d ' ')
tus_chunk_size=$((4 * 1048576)) # 4MB first chunk
# Encode TUS metadata values as base64
tus_bucket_b64=$(printf '%s' "$tus_bucket" | base64)
tus_object_b64=$(printf '%s' "tus-test-file.bin" | base64)
tus_mime_b64=$(printf '%s' "application/octet-stream" | base64)
# 1. Create resumable upload
tus_create_resp=$(curl -s -i -X POST \
"$BASE_URL/storage/v1/upload/resumable" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: $tus_file_size" \
-H "Upload-Metadata: bucketName $tus_bucket_b64,objectName $tus_object_b64,contentType $tus_mime_b64" \
-H "x-upsert: true")
tus_create_status=$(echo "$tus_create_resp" | grep -m1 '^HTTP/' | grep -o '[0-9][0-9][0-9]')
# Supabase Storage always returns an absolute Location URL (see generateUrl in storage/src/http/routes/tus/lifecycle.ts)
tus_location=$(echo "$tus_create_resp" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
check "TUS: create resumable upload" "201" "$tus_create_status"
if [ -n "$tus_location" ]; then
# 2. Upload first chunk (0 to 4MB)
tus_chunk1_status=$(dd if="$tusfile" bs=1048576 count=4 2>/dev/null | \
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
"$tus_location" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Offset: 0" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary @-)
check "TUS: upload chunk 1 (4MB)" "204" "$tus_chunk1_status"
# 3. Upload second chunk (4MB to end)
tus_remaining=$((tus_file_size - tus_chunk_size))
tus_chunk2_status=$(dd if="$tusfile" bs=1048576 skip=4 count=3 2>/dev/null | \
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
"$tus_location" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Offset: $tus_chunk_size" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary @-)
check "TUS: upload chunk 2 (remaining)" "204" "$tus_chunk2_status"
# 4. Verify download matches original (hash check proves correct chunk reassembly)
tus_download_tmp=$(mktemp); cleanup_files="$cleanup_files $tus_download_tmp"
curl -s "$BASE_URL/storage/v1/object/public/$tus_bucket/tus-test-file.bin" -o "$tus_download_tmp"
tus_download_size=$(wc -c < "$tus_download_tmp" | tr -d ' ')
check "TUS: download size matches" "$tus_file_size" "$tus_download_size"
tus_original_hash=$(file_hash "$tusfile")
tus_download_hash=$(file_hash "$tus_download_tmp")
check "TUS: download hash matches" "$tus_original_hash" "$tus_download_hash"
rm -f "$tus_download_tmp"
fi
rm -f "$tusfile"
# Cleanup: delete file and bucket
http_status "$BASE_URL/storage/v1/object/$tus_bucket/tus-test-file.bin" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
tus_delete_bucket=$(http_status "$BASE_URL/storage/v1/bucket/$tus_bucket" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "TUS: delete bucket" "200" "$tus_delete_bucket"
fi
# ---------------------------------------------
# 7. Edge Functions
# ---------------------------------------------
echo ""
echo "--- Edge Functions ---"
fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \
-X POST \
-H "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 (ping)" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" \
-H "apikey: $ANON_KEY")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "Realtime /api/tenants blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" \
-H "apikey: $ANON_KEY")"
check "Realtime /api/openapi blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" \
-H "apikey: $ANON_KEY")"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
+380
View File
@@ -0,0 +1,380 @@
#!/bin/sh
#
# Hermetic test for update.sh (the self-hosted in-place update script).
#
# Builds a tiny synthetic "upstream" git repo with two tagged releases
# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key),
# where the target ships a breaking-change manifest with entries at the base and
# inside the window. It then simulates a configured deployment based on v0.9.0
# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting:
# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not
# ones the user commented out), applies clean merges, reports real conflicts,
# honors .gitignore for user-owned paths, surfaces only the in-window gate
# entries (base-version entry excluded), and advances the stamp ONLY on a clean
# apply. Also covers the clean-apply path, default-target resolution, --from,
# --dry-run, the missing-stamp report-only path, and a malformed manifest being
# refused.
#
# Usage:
# sh tests/test-update.sh # run from the docker/ directory
#
set -eu
# Isolate from the developer's global/system git config (gpgsign, hooksPath,
# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's
# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so
# the update.sh subprocess inherits them too.
export GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_NOSYSTEM=1
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
UPDATE_SH="$DOCKER_DIR/update.sh"
[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; }
WORK=$(mktemp -d)
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
assert_file_contains() { # <file> <pattern> <desc>
if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi
}
assert_file_missing_pattern() { # <file> <pattern> <desc>
if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi
}
assert_line() { # <file> <ERE> <desc>
if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi
}
assert_no_line() { # <file> <ERE> <desc>
if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi
}
assert_path_exists() { # <path> <desc>
if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi
}
assert_path_absent() { # <path> <desc>
if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi
}
# portable in-place sed (BSD + GNU): sedi <expr> <file>
sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; }
# --- 1. Build the synthetic upstream repo -----------------------------------
SRC="$WORK/upstream"
mkdir -p "$SRC/docker/volumes/api"
cd "$SRC"
git init -q
git config user.email t@t.t
git config user.name t
cat > docker/docker-compose.yml <<'EOF'
services:
studio:
image: supabase/studio:OLD
db:
image: supabase/postgres:15
EOF
cat > docker/.env.example <<'EOF'
POSTGRES_PASSWORD=changeme
JWT_SECRET=changeme
KEEP_ME=base-default
EOF
printf 'base kong\n' > docker/volumes/api/kong.yml
printf 'remove me\n' > docker/old-only.txt
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
mkdir -p docker/volumes/functions/main
printf 'base main\n' > docker/volumes/functions/main/index.ts
git add -A && git commit -qm base && git tag self-hosted/v0.9.0
# target commit
cat > docker/docker-compose.yml <<'EOF'
services:
studio:
image: supabase/studio:NEW
db:
image: supabase/postgres:17
EOF
cat > docker/.env.example <<'EOF'
POSTGRES_PASSWORD=changeme
JWT_SECRET=changeme
KEEP_ME=base-default
NEW_KEY=new-default
EOF
printf 'brand new\n' > docker/new-only.txt
printf 'target main\n' > docker/volumes/functions/main/index.ts
mkdir -p docker/volumes/functions/hello
printf 'target hello\n' > docker/volumes/functions/hello/index.ts
# A gitignored sample under volumes/snippets shipped by upstream: must NOT
# overwrite the user's file of the same name (exercises is_excluded directly).
mkdir -p docker/volumes/snippets
printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql
# Manifest with entries at/below and inside the window:
# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]).
# 1.0.0 inside -> surfaces (carries requires+gate).
# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after),
# guarding the set -e regression where a requires-less final entry
# aborted the script. Do NOT add a version key after 1.1.0.
cat > docker/upgrades.json <<'EOF'
{
"0.9.0": {
"breaking": true
},
"1.0.0": {
"breaking": true,
"gate": "utils/demo-migrate.sh",
"migration_guide_url": "https://example.test/guide",
"requires": ["Run the demo migration first."]
},
"1.1.0": {
"breaking": true
}
}
EOF
git rm -q docker/old-only.txt
git add -A
git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql
# Release tag for the target / default-target (latest self-hosted/v*) path.
git commit -qm target && git tag self-hosted/v1.1.0
# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must
# refuse (die) rather than silently skip the gate. Named so latest_release_tag
# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0.
printf '["valid JSON, but not an object"]\n' > docker/upgrades.json
git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest
# --- helper: lay down a deployment based on v0.9.0 --------------------------
# make_deploy <dir> [conflict] - pass "conflict" to pin the studio image so the
# merge produces a real conflict; omit for a clean apply.
make_deploy() { # <dir> [conflict]
d="$1"
_mode="${2:-clean}"
mkdir -p "$d"
git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1
cp "$UPDATE_SH" "$d/update.sh"
# configured .env: real secret, an extra user key, and KEEP_ME commented out
# on purpose (must NOT be re-added by the .env key-union).
cp "$d/.env.example" "$d/.env"
sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env"
sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env"
printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env"
# user-owned override (must never be touched)
printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml"
# data dirs with sentinels (must never be touched)
mkdir -p "$d/volumes/db/data" "$d/volumes/storage"
printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt"
printf 'OBJ\n' > "$d/volumes/storage/keep.txt"
# user adds a line to kong (upstream unchanged -> clean merge, must survive)
printf 'user added line\n' >> "$d/volumes/api/kong.yml"
# user-owned paths per .gitignore (must not be touched by the merge)
mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn"
printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql"
printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts"
# user file at the SAME path as the vendor snippet shipped at target:
# is_excluded must skip it so the user's content survives.
printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql"
# legacy sample fn in snapshot at target but gitignored - must not overwrite
mkdir -p "$d/volumes/functions/hello"
printf 'user hello\n' > "$d/volumes/functions/hello/index.ts"
# version stamp pointing at the base (ref only; update.sh derives the rest)
printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version"
if [ "$_mode" = "conflict" ]; then
# user pins the studio image (same line upstream changes -> conflict)
sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml"
fi
}
echo ""
echo "=== update.sh: apply path (with a conflict) ==="
DEPLOY="$WORK/deploy"
make_deploy "$DEPLOY" conflict
cd "$DEPLOY"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$?
sed 's/^/ | /' "$WORK/apply.log"
if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi
assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved"
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended"
assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept"
assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented"
assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented"
assert_file_contains "docker-compose.override.yml" "my override" "override untouched"
assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched"
assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched"
assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written"
assert_file_contains "docker-compose.yml" "=======" "conflict separator written"
assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written"
assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict"
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict"
assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)"
assert_path_exists "new-only.txt" "new upstream file added"
assert_path_exists "old-only.txt" "removed-upstream file left in place"
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict"
assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary"
assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced"
assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)"
assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)"
assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced"
assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced"
assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced"
assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown"
assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report"
if ls backups/*.tgz >/dev/null 2>&1; then
ok "backup archive created"
for _bk in backups/*.tgz; do break; done
tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true
assert_file_contains "$WORK/bk.list" ".env" "backup includes .env"
assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir"
assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir"
assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir"
else
bad "no backup archive"
fi
assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched"
# seed.sql and hello/index.ts are the only files that are BOTH shipped in the
# target snapshot AND gitignored, so they are the real is_excluded coverage.
# Assert the user's content survives AND no vendor content / conflict markers
# leaked in - i.e. the file was skipped, not merged/conflicted.
assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept"
assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content"
assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)"
assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched"
assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated"
assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept"
assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content"
assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)"
echo ""
echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ==="
CLEAN="$WORK/clean"
make_deploy "$CLEAN"
cd "$CLEAN"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi
assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced"
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply"
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)"
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)"
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)"
echo ""
echo "=== update.sh: default target resolves to latest self-hosted/v* tag ==="
TAGD="$WORK/tagdefault"
make_deploy "$TAGD"
cd "$TAGD"
SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true
assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag"
assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target"
echo ""
echo "=== update.sh: --from supplies the base when the stamp is missing ==="
FROMD="$WORK/fromd"
make_deploy "$FROMD"
cd "$FROMD"
rm -f .supabase-version
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$?
assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)"
assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp"
echo ""
echo "=== update.sh: --dry-run writes nothing ==="
DRYD="$WORK/dry"
make_deploy "$DRYD" conflict
cd "$DRYD"
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true
assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key"
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged"
assert_path_absent "backups" "dry-run took no backup"
assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output"
echo ""
echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ==="
MISS="$WORK/miss"
make_deploy "$MISS"
cd "$MISS"
rm -f .supabase-version
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi
assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced"
assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env"
assert_path_absent "backups" "report-only took no backup"
assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate"
assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release"
assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry"
echo ""
echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ==="
BADM="$WORK/badmanifest"
make_deploy "$BADM"
cd "$BADM"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$?
if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi
assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced"
assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched"
assert_path_absent "backups" "malformed manifest: no backup taken"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced"
echo ""
echo "=== update.sh: never overwrites the running script; stages the target's copy as .dist ==="
# A dedicated upstream whose docker/ ships an update.sh that DIFFERS from the one
# we run, so the merge must divert it to update.sh.dist rather than rewrite the
# live script mid-run (which would corrupt this process).
SELFSRC="$WORK/selfsrc"
mkdir -p "$SELFSRC/docker"
cd "$SELFSRC"
git init -q
git config user.email t@t.t
git config user.name t
printf 'services:\n db:\n image: x\n' > docker/docker-compose.yml
printf 'KEEP=1\n' > docker/.env.example
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
printf '#!/bin/sh\necho THIS-IS-THE-NEW-UPDATE-SH\n' > docker/update.sh
git add -A && git commit -qm self && git tag self-hosted/v2.0.0
SELFDEP="$WORK/selfdep"
mkdir -p "$SELFDEP"
printf 'services:\n db:\n image: x\n' > "$SELFDEP/docker-compose.yml"
printf 'KEEP=1\n' > "$SELFDEP/.env"
cp "$UPDATE_SH" "$SELFDEP/update.sh" # the running script = the real update.sh
printf 'ref=self-hosted/v2.0.0\n' > "$SELFDEP/.supabase-version"
cd "$SELFDEP"
rc=0
SUPABASE_REPO_URL="$SELFSRC" sh ./update.sh --to self-hosted/v2.0.0 --yes > "$WORK/self.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "self-update run exits 0"; else bad "expected exit 0, got $rc"; fi
if cmp -s ./update.sh "$UPDATE_SH"; then ok "running update.sh left byte-identical"; else bad "running update.sh was modified in place"; fi
assert_no_line "./update.sh" '^(<<<<<<<|=======|>>>>>>>)' "no conflict markers written into the running update.sh"
assert_path_exists "$SELFDEP/update.sh.dist" "target's update.sh staged as update.sh.dist"
assert_file_contains "$SELFDEP/update.sh.dist" "THIS-IS-THE-NEW-UPDATE-SH" "update.sh.dist holds the target's version"
assert_file_contains "$WORK/self.log" "update.sh.dist" "summary points the user at update.sh.dist"
# --- summary -----------------------------------------------------------------
echo ""
echo "=== Result: $PASS passed, $FAIL failed ==="
[ "$FAIL" = "0" ] || exit 1
@@ -0,0 +1,90 @@
#!/bin/sh
#
# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq.
#
# - json: upgrades.json is valid JSON
# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the
# optional "_schema" documentation block
# - schema: each version-keyed entry has only known fields, with valid types
# (an unknown/misspelled key like "breakng" would silently disarm
# its gate, so it is rejected here)
# - gate: any non-null "gate" points at a script that exists in the repo
#
# The manifest is the source of truth for gating; the CHANGELOG is display only,
# so this test deliberately does NOT cross-check the two. Requires jq (already a
# runtime dependency of update.sh); no yq, no generation step.
#
# Usage:
# sh tests/test-upgrades-manifest.sh # run from the docker/ directory
#
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
cd "$DOCKER_DIR"
JSON=upgrades.json
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; }
[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT INT TERM
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
echo ""
echo "=== upgrades.json is valid JSON ==="
if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then
ok "upgrades.json parses"
else
bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)"
echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1
fi
echo ""
echo "=== top-level keys are versions (or _schema) ==="
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in
_schema) ok "doc block '_schema' present" ;;
[0-9]*.[0-9]*) ok "version key $k" ;;
*) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;;
esac
done
echo ""
echo "=== version-keyed entries have only known fields, with valid types ==="
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
errs=$(jq -r --arg k "$k" '.[$k] as $e
| (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown
| [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end),
(if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end),
(if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end),
(if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end),
(if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end)
] | join("; ")' "$JSON")
if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi
done
echo ""
echo "=== gate scripts referenced by entries exist ==="
checked=0
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON")
[ -n "$gate" ] || continue
checked=$((checked+1))
if [ -f "$gate" ]; then
ok "gate for $k exists: $gate"
else
bad "gate for $k missing: $gate"
fi
done
[ "$checked" = 0 ] && echo " (no entries reference a gate script)"
echo ""
echo "=== Result: $PASS passed, $FAIL failed ==="
[ "$FAIL" = 0 ] || exit 1
+692
View File
@@ -0,0 +1,692 @@
#!/bin/sh
#
# Update an existing self-hosted Supabase deployment in place.
#
# The deployment directory mixes vendor-owned files (docker-compose.yml, the
# override files, volumes/*, scripts, .env.example) with user-owned state
# (.env, docker-compose.override.yml, volumes/db/data, volumes/storage, etc.).
# This script pulls a newer version of the Supabase files on top of yours
# using a 3-way merge against the version you started from, so local edits
# survive and genuine conflicts are surfaced rather than silently overwritten.
#
# The version you started from is recorded in .supabase-version (written by
# setup.sh). If it is missing, pass --from <ref> or follow the printed guidance.
#
# What it never touches: .env values you set, docker-compose.override.yml, and
# the data directories (volumes/db/data, volumes/storage, etc.). New keys from
# .env.example are appended to your .env; existing values are kept as-is.
#
# By default it updates to the latest self-hosted/v* release tag (or 'master'
# until the first tag exists). Pass --to to pin a specific tag/branch.
#
# Usage:
# sh update.sh # update to the latest release tag
# sh update.sh --dry-run # show what would change, write nothing
# sh update.sh --to <tag> # update to a specific tag/branch
# sh update.sh --from <ref> # base to merge from (if no .supabase-version)
# sh update.sh --yes # don't prompt, even on breaking changes
#
# Env:
# SUPABASE_REPO_URL Override the upstream repo (default: github supabase/supabase)
#
# Documentation: https://supabase.com/docs/guides/self-hosting/updating
#
set -e
cd "$(dirname "$0")"
# Pipeline (see main at the bottom):
# resolve refs -> fetch base+target snapshots -> [report-only exit]
# -> build manifest gate -> confirm_gate (before any writes)
# -> backup → merge vendor files + .env keys -> summary → stamp
#
# Three trees for every vendor file path:
# - base - upstream at BASE_REF (.supabase-version ref=, or --from)
# - target - upstream at TARGET_REF (--to, or latest self-hosted/v* tag)
# - user's - the deployment directory (cwd); _not_ a git checkout
#
# Git is only used to fetch snapshots (fetch_snapshot) and to run git merge-file.
#
# Breaking-change gate (upgrades.json on the target snapshot):
# - Keyed by version (e.g. "0.7.0"); window = entries in (BASE_VER, TARGET_VER],
# where the bounds are the base/target refs reduced to bare semver
# (self-hosted/vX.Y.Z -> X.Y.Z) and compared with sort -V.
# - If a bound is not a release tag (a commit SHA, or "master"), that side of
# the window is left open and all applicable entries are shown with a warning.
# - Prompt when an entry has breaking:true or a gate script; runs before
# backup/merge so abort leaves the deployment untouched.
# - CHANGELOG.md is never parsed (update.sh only points users at it); routine
# "requires compose update" items are applied by the merge, not listed here.
#
# User-owned paths skipped during merge are defined in .gitignore (loaded from the
# target snapshot). git check-ignore --no-index applies negation rules (e.g.
# volumes/functions/** ignored except volumes/functions/main/index.ts).
# .git/ is excluded here only - never listed in .gitignore.
#
# .env is never 3-way merged: append missing keys from .env.example only.
# --- globals (set during main) -----------------------------------------------
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
STAMP_FILE=".supabase-version"
SELF_NAME=$(basename "$0")
DRY_RUN=0
ASSUME_YES=0
TO_REF=""
FROM_REF=""
TARGET_REF=""
BASE_REF=""
BASE_VER=""
TARGET_VER=""
REPORT_ONLY=0
TMP_ROOT=""
TARGET_DIR=""
BASE_DIR=""
REPORT=""
ENV_ADDED=""
ENV_REMOVED=""
GATE_REPORT=""
GATE_REQUIRED=0
IGNORE_FILE=""
IGNORE_GIT_DIR=""
# --- logging -----------------------------------------------------------------
log() { printf "===> %s\n" "$*"; }
warn() { printf "WARNING: %s\n" "$*" >&2; }
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
print_help() {
awk 'NR==1 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0"
}
# --- small helpers -----------------------------------------------------------
# load_ignore_file - vendor/user split from the target snapshot's .gitignore.
# Uses a throwaway git dir so check-ignore works on deployment trees that are
# not git repos (and on Apple Git, which rejects --git-dir=/dev/null).
load_ignore_file() {
if [ -f "$TARGET_DIR/.gitignore" ]; then
IGNORE_FILE="$TARGET_DIR/.gitignore"
elif [ -f .gitignore ]; then
IGNORE_FILE="$(pwd)/.gitignore"
else
IGNORE_FILE=""
warn "No .gitignore found; only .git paths are excluded from the merge."
return 0
fi
case "$IGNORE_FILE" in
/*) ;;
*) IGNORE_FILE="$(cd "$(dirname "$IGNORE_FILE")" && pwd)/$(basename "$IGNORE_FILE")" ;;
esac
IGNORE_GIT_DIR="$TMP_ROOT/ignore-git"
git init -q "$IGNORE_GIT_DIR"
}
# is_excluded <relpath> - true when the path is user-owned and must not merge.
is_excluded() {
case "$1" in
.git|.git/*) return 0 ;;
esac
[ -n "$IGNORE_FILE" ] || return 1
git -C "$IGNORE_GIT_DIR" -c "core.excludesfile=$IGNORE_FILE" \
check-ignore -q --no-index "$1" 2>/dev/null
}
read_stamp_ref() {
[ -f "$STAMP_FILE" ] || return 1
val=$(grep -E '^ref=' "$STAMP_FILE" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d "\r\"' ")
if [ -z "$val" ]; then
val=$(grep -vE '^[[:space:]]*#' "$STAMP_FILE" 2>/dev/null \
| grep -vE '^[[:space:]]*$' | head -n1 | tr -d "\r\"' ")
fi
[ -n "$val" ] && printf '%s' "$val"
}
# env_has_key <key> <file> - true if the key appears as KEY=, even commented
# (e.g. "#GOOGLE_ENABLED="), so we never re-add a key the user disabled on purpose.
env_has_key() {
grep -qE "^[[:space:]]*#?[[:space:]]*$1=" "$2" 2>/dev/null
}
record() { printf '%s:%s\n' "$1" "$2" >> "$REPORT"; }
count_status() { grep -cE "^$1:" "$REPORT" 2>/dev/null || true; }
list_status() {
grep -E "^$1:" "$REPORT" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
}
# --- upstream snapshots ------------------------------------------------------
_sparse_init() {
git -C "$1" init -q
git -C "$1" remote add origin "$REPO_URL"
git -C "$1" config core.sparseCheckout true
git -C "$1" sparse-checkout init --cone >/dev/null 2>&1
git -C "$1" sparse-checkout set docker >/dev/null 2>&1
}
# fetch_snapshot <ref> <dest_dir>
# Materializes ./docker at <ref> into <dest_dir> via a shallow fetch. Also the
# seam an artifact source (tarball + sha256) would slot into later.
fetch_snapshot() {
_ref="$1"
_dest="$2"
_work=$(mktemp -d "$TMP_ROOT/fetch.XXXXXX")
if _sparse_init "$_work" \
&& git -C "$_work" fetch --depth=1 --filter=blob:none -q origin "$_ref" 2>/dev/null \
&& git -C "$_work" checkout -q FETCH_HEAD 2>/dev/null \
&& [ -d "$_work/docker" ]; then
mkdir -p "$_dest"
cp -rf "$_work/docker/." "$_dest/"
rm -rf "$_work"
else
rm -rf "$_work"
return 1
fi
}
latest_release_tag() {
git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \
| sed 's#^.*refs/tags/##' \
| grep -E '^self-hosted/v[0-9]' \
| sort -V | tail -n1
}
list_files() {
( cd "$1" && find . -type f | sed 's|^\./||' | grep -vE '^\.git(/|$)' | sort )
}
# normalize_version <ref> - reduce a ref to bare semver (0.7.0) for comparison,
# or empty when it is not a self-hosted release tag (commit SHA, "master", …).
normalize_version() {
_v="${1#refs/tags/}"
_v="${_v#self-hosted/}"
_v="${_v#v}"
case "$_v" in
[0-9]*.[0-9]*) printf '%s' "$_v" ;;
*) ;;
esac
}
# ver_gt A B - true when version A is strictly greater than B (sort -V order).
ver_gt() {
if [ "$1" = "$2" ]; then return 1; fi
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}
# ver_in_window VER - true when BASE_VER < VER <= TARGET_VER. An empty bound
# leaves that side open (over-reports a gate rather than hiding one).
ver_in_window() {
if [ -n "$TARGET_VER" ] && ver_gt "$1" "$TARGET_VER"; then return 1; fi
if [ -n "$BASE_VER" ] && ! ver_gt "$1" "$BASE_VER"; then return 1; fi
return 0
}
# --- ref resolution ----------------------------------------------------------
resolve_target_ref() {
if [ -n "$TO_REF" ]; then
TARGET_REF="$TO_REF"
return
fi
TARGET_REF=$(latest_release_tag)
if [ -n "$TARGET_REF" ]; then
log "Latest release tag: $TARGET_REF"
else
TARGET_REF="master"
warn "No self-hosted/v* release tags found; targeting 'master'. Pin a version with --to."
fi
}
resolve_base_ref() {
REPORT_ONLY=0
if [ -n "$FROM_REF" ]; then
BASE_REF="$FROM_REF"
elif BASE_REF=$(read_stamp_ref) && [ -n "$BASE_REF" ]; then
:
else
REPORT_ONLY=1
BASE_REF=""
fi
}
print_report_only_guidance() {
warn "No $STAMP_FILE found and no --from <ref> given; cannot determine the version you started from."
cat >&2 <<EOF
Without a base version a safe 3-way merge is not possible. To fix this, find the
version your deployment was based on and record it, then re-run:
1. Identify your base - the self-hosted/vX.Y.Z release (or commit) your files
came from. Cross-reference the image tags in docker-compose.yml / .env
against versions.md, or the newest CHANGELOG.md section you remember pulling.
2. Write it to $STAMP_FILE, e.g.:
printf 'ref=self-hosted/v0.7.0\n' > $STAMP_FILE
3. Re-run: sh update.sh
Or supply it inline for this run: sh update.sh --from <commit-sha-or-tag>
Continuing in REPORT-ONLY mode. NOTE: this is NOT the full set of changes -
without a base version it can only list files and .env keys that are entirely
NEW to you; it CANNOT show which existing files would change or conflict. Record
a base and re-run for the real preview. Nothing will be written.
EOF
}
fetch_snapshots() {
TARGET_DIR="$TMP_ROOT/target"
log "Fetching target snapshot ($TARGET_REF)"
fetch_snapshot "$TARGET_REF" "$TARGET_DIR" \
|| die "Could not fetch target snapshot '$TARGET_REF' from $REPO_URL"
if [ "$REPORT_ONLY" = "1" ]; then
return
fi
BASE_DIR="$TMP_ROOT/base"
log "Fetching base snapshot ($BASE_REF)"
fetch_snapshot "$BASE_REF" "$BASE_DIR" \
|| die "Could not fetch base snapshot '$BASE_REF' from $REPO_URL"
}
run_report_only() {
log "Files in '$TARGET_REF' you do NOT have yet (brand-new only; existing files that changed are NOT shown here):"
while IFS= read -r f; do
is_excluded "$f" && continue
[ -f "$f" ] || echo " + $f"
done <<EOF
$(list_files "$TARGET_DIR")
EOF
echo ""
log ".env keys in the new .env.example that your .env is missing (add these):"
if [ -f "$TARGET_DIR/.env.example" ]; then
while IFS= read -r k; do
env_has_key "$k" .env || echo " + $k"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$TARGET_DIR/.env.example" | cut -d= -f1)
EOF
fi
echo ""
warn "This is NOT the full update. To see what would actually change, record a base version."
warn "See the guidance above and re-run the script. Nothing was written."
}
# --- breaking-change gate (manifest-driven, before any writes) ---------------
# Reads docker/upgrades.json from the target snapshot; gating keys off manifest
# entries in (BASE_VER, TARGET_VER].
build_gate_report() {
_manifest="$TARGET_DIR/upgrades.json"
GATE_REQUIRED=0
: > "$GATE_REPORT"
if [ ! -f "$_manifest" ]; then
return 0
fi
if ! jq -e 'type == "object"' "$_manifest" >/dev/null 2>&1; then
die "$_manifest is present but is not a valid JSON object; refusing to update without a working breaking-change gate. Please report this to the maintainers."
fi
if [ -z "$BASE_VER" ] || [ -z "$TARGET_VER" ]; then
warn "Base or target is not a self-hosted/vX.Y.Z tag; cannot compute an exact update window."
warn "Showing all applicable manual-action releases."
warn "Review which ones apply to your deployment."
fi
for k in $(jq -r 'keys[]' "$_manifest" 2>/dev/null); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
ver_in_window "$k" || continue
breaking=$(jq -r --arg k "$k" '.[$k].breaking // false' "$_manifest")
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$_manifest")
url=$(jq -r --arg k "$k" '.[$k].migration_guide_url // empty' "$_manifest")
reqs=$(jq -r --arg k "$k" '.[$k].requires[]? // empty' "$_manifest")
if [ "$breaking" = "true" ] || [ -n "$gate" ]; then
GATE_REQUIRED=1
fi
{
[ "$breaking" = "true" ] && echo "[$k] BREAKING" || echo "[$k]"
[ -n "$gate" ] && echo " gate: '$gate' must be run first (see the steps below for the exact command)"
[ -n "$url" ] && echo " guide: $url"
[ -n "$reqs" ] && printf '%s\n' "$reqs" | sed 's/^/ - /'
} >> "$GATE_REPORT"
done
# Explicit success: the loop's last iteration can end on a false test (e.g.
# an entry with no 'requires'), which would otherwise make this function
# return non-zero and abort the whole script under 'set -e'.
return 0
}
confirm_gate() {
[ "$GATE_REQUIRED" = "1" ] || return 0
[ "$DRY_RUN" != "1" ] || return 0
[ "$ASSUME_YES" != "1" ] || return 0
echo "" >&2
warn "This update requires manual action - review before continuing:"
sed 's/^/ /' "$GATE_REPORT" >&2
echo "" >&2
if { : > /dev/tty; } 2>/dev/null; then
printf "Have you completed the required steps and want to continue? [y/N]: " > /dev/tty
read -r reply < /dev/tty
case "$reply" in
y|Y|yes|YES) return 0 ;;
*) die "Aborted by user. Nothing was modified." ;;
esac
fi
die "Breaking/gated changes present and no controlling terminal to confirm. Re-run with --yes to proceed."
}
# --- backup ------------------------------------------------------------------
take_backup() {
if [ "$DRY_RUN" = "1" ]; then
log "Dry run: no backup taken, nothing will be written."
return 0
fi
mkdir -p backups
_backup="backups/pre-update-$(date +%Y%m%d-%H%M%S).tgz"
log "Backing up current configuration to $_backup (excluding data directories)"
tar czf "$_backup" \
--exclude='./backups' \
--exclude='./volumes/db/data' \
--exclude='./volumes/storage' \
. 2>/dev/null || warn "Backup archive reported errors; review $_backup before relying on it."
warn "This does NOT back up your database. Back it up separately before updating."
}
# --- vendor file merge -------------------------------------------------------
apply_file() {
[ "$DRY_RUN" = "1" ] && return 0
_dir=$(dirname "$1")
[ "$_dir" = "." ] || mkdir -p "$_dir"
cp -f "$2" "$1"
}
# Per-file 3-way merge (u=yours, b=base snapshot, t=target snapshot):
# no t → keep u; report removed-upstream
# no u → copy t; report new
# u == t → report unchanged
# u == b → copy t; report updated (user never edited)
# else → git merge-file u b t; report merged-clean or CONFLICT
# If b had no file, an empty file stands in for b.
merge_one_file() {
f="$1"
empty="$2"
merged="$TMP_ROOT/merged.out"
b="$BASE_DIR/$f"
t="$TARGET_DIR/$f"
u="$f"
if [ ! -f "$t" ]; then
[ -f "$u" ] && record "removed-upstream" "$f"
return 0
fi
if [ ! -f "$u" ]; then
apply_file "$f" "$t"
record "new" "$f"
return 0
fi
if cmp -s "$u" "$t"; then
record "unchanged" "$f"
return 0
fi
base_for_merge="$b"
[ -f "$base_for_merge" ] || base_for_merge="$empty"
if cmp -s "$u" "$base_for_merge"; then
apply_file "$f" "$t"
record "updated" "$f"
return 0
fi
if git merge-file -p -q \
-L "yours ($f)" -L "base" -L "new ($TARGET_REF)" \
"$u" "$base_for_merge" "$t" > "$merged" 2>/dev/null; then
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
record "merged-clean" "$f"
elif [ -s "$merged" ]; then
# Non-zero exit with output = a normal conflict (markers written).
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
record "CONFLICT" "$f"
else
# git merge-file errored and produced no output; keep the user's file
# intact rather than truncating it to empty.
record "merge-failed" "$f"
fi
}
# The running script is a vendor file too, but overwriting it in place would
# corrupt this process (the shell reads $0 as it runs). Never write it directly:
# if the target ships a different version, stage it as <name>.dist to review.
stage_self_update() {
_t="$TARGET_DIR/$SELF_NAME"
if [ ! -f "$_t" ] || cmp -s "$SELF_NAME" "$_t"; then
record "unchanged" "$SELF_NAME"
return 0
fi
[ "$DRY_RUN" = "1" ] || cp -f "$_t" "$SELF_NAME.dist"
record "self-staged" "$SELF_NAME"
}
merge_vendor_files() {
_empty="$TMP_ROOT/empty"
: > "$_empty"
: > "$REPORT"
while IFS= read -r f; do
[ -n "$f" ] || continue
is_excluded "$f" && continue
if [ "$f" = "$SELF_NAME" ]; then
stage_self_update
continue
fi
merge_one_file "$f" "$_empty"
done <<EOF
$( { list_files "$BASE_DIR"; list_files "$TARGET_DIR"; } | sort -u )
EOF
}
# --- .env key-union merge ----------------------------------------------------
merge_env_file() {
_example="$TARGET_DIR/.env.example"
: > "$ENV_ADDED"
: > "$ENV_REMOVED"
[ -f "$_example" ] || return 0
while IFS= read -r k; do
env_has_key "$k" .env || echo "$k" >> "$ENV_ADDED"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$_example" | cut -d= -f1)
EOF
while IFS= read -r k; do
env_has_key "$k" "$_example" || echo "$k" >> "$ENV_REMOVED"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | cut -d= -f1)
EOF
if [ ! -s "$ENV_ADDED" ] || [ "$DRY_RUN" = "1" ]; then
return 0
fi
{
echo ""
echo "############"
echo "# Added by update.sh on $(date +%Y-%m-%d) from .env.example ($TARGET_REF)."
echo "# Review and set values as needed."
echo "############"
while IFS= read -r k; do
grep -E "^${k}=" "$_example" | head -n1
done < "$ENV_ADDED"
} >> .env
}
# --- summary and finish ------------------------------------------------------
print_summary() {
echo ""
if [ "$DRY_RUN" = "1" ]; then
log "DRY RUN - the following WOULD change (nothing was written):"
else
log "Update applied. Summary:"
fi
printf " updated: %s\n" "$(count_status updated)"
printf " new: %s\n" "$(count_status new)"
printf " merged (clean): %s\n" "$(count_status merged-clean)"
printf " CONFLICTS: %s\n" "$(count_status CONFLICT)"
printf " merge failures: %s\n" "$(count_status merge-failed)"
printf " removed upstream: %s\n" "$(count_status removed-upstream)"
printf " env keys added: %s\n" "$( [ -s "$ENV_ADDED" ] && wc -l < "$ENV_ADDED" | tr -d ' ' || echo 0 )"
if [ "$(count_status CONFLICT)" != "0" ]; then
echo ""
warn "Files with merge conflicts (edit these and remove the <<<<<<< ======= >>>>>>> markers):"
list_status CONFLICT
fi
if [ "$(count_status merge-failed)" != "0" ]; then
echo ""
warn "Files git could not merge (left unchanged - update these manually):"
list_status merge-failed
fi
if [ "$(count_status merged-clean)" != "0" ]; then
echo ""
log "Files merged cleanly (review recommended):"
list_status merged-clean
fi
if [ "$(count_status removed-upstream)" != "0" ]; then
echo ""
log "Removed upstream but kept in place (you may no longer need these):"
list_status removed-upstream
fi
if [ "$(count_status self-staged)" != "0" ]; then
echo ""
if [ "$DRY_RUN" = "1" ]; then
log "$SELF_NAME differs from the version in '$TARGET_REF'; a real run would stage that version as $SELF_NAME.dist (the running script is never overwritten in place)."
else
log "$SELF_NAME differs from the version in '$TARGET_REF', staged as $SELF_NAME.dist (the running script was not modified)."
log "Review it, then swap it in if needed: mv $SELF_NAME.dist $SELF_NAME"
fi
fi
if [ -s "$ENV_ADDED" ]; then
echo ""
log ".env keys added (review values):"
sed 's/^/ + /' "$ENV_ADDED"
fi
if [ -s "$ENV_REMOVED" ]; then
echo ""
log ".env keys you have that are gone from the new .env.example (review/remove manually):"
sed 's/^/ - /' "$ENV_REMOVED"
fi
if [ -s "$GATE_REPORT" ]; then
echo ""
log "Required manual steps for this update (from upgrades.json):"
sed 's/^/ /' "$GATE_REPORT"
fi
echo ""
log "For what changed in this update, see CHANGELOG.md (from $BASE_REF to $TARGET_REF)."
}
write_stamp() {
{
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
echo "ref=$TARGET_REF"
} > "$STAMP_FILE"
}
print_next_steps() {
echo ""
log "Next steps:"
echo " 1. Review the changes above (compare against the latest backup in backups/ if needed)."
echo " 2. sh run.sh pull"
echo " 3. sh run.sh recreate"
}
# --- main --------------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--to) [ $# -ge 2 ] || die "--to requires a <tag> argument."; TO_REF="$2"; shift 2 ;;
--from) [ $# -ge 2 ] || die "--from requires a <ref> argument."; FROM_REF="$2"; shift 2 ;;
-h|--help) print_help; exit 0 ;;
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
esac
done
[ -f docker-compose.yml ] || die "docker-compose.yml not found in $(pwd). Run this from your deployment directory."
[ -f .env ] || die ".env not found in $(pwd). This does not look like a configured deployment."
command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH."
command -v jq >/dev/null 2>&1 || die "jq is required but was not found on PATH."
resolve_target_ref
resolve_base_ref
TARGET_VER=$(normalize_version "$TARGET_REF")
BASE_VER=$(normalize_version "$BASE_REF")
[ "$REPORT_ONLY" = "1" ] && print_report_only_guidance
TMP_ROOT=$(mktemp -d)
REPORT="$TMP_ROOT/report"
ENV_ADDED="$TMP_ROOT/env_added"
ENV_REMOVED="$TMP_ROOT/env_removed"
GATE_REPORT="$TMP_ROOT/gate_report"
trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM
fetch_snapshots
load_ignore_file
if [ "$REPORT_ONLY" = "1" ]; then
run_report_only
build_gate_report
if [ -s "$GATE_REPORT" ]; then
echo ""
log "Breaking changes / required manual steps in this range (from upgrades.json):"
sed 's/^/ /' "$GATE_REPORT"
warn "Record a base version (see above) and re-run to apply with the gate enforced."
fi
exit 0
fi
build_gate_report
confirm_gate
take_backup
merge_vendor_files
merge_env_file
print_summary
if [ "$DRY_RUN" = "1" ]; then
echo ""
log "Dry run complete. Re-run without --dry-run to apply."
exit 0
fi
if [ "$(count_status CONFLICT)" != "0" ] || [ "$(count_status merge-failed)" != "0" ]; then
echo ""
warn "Update applied WITH CONFLICTS. Resolve the files listed above (remove the"
warn "<<<<<<< ======= >>>>>>> markers, or fix the files git could not merge)"
warn "before starting the stack."
warn "The version stamp was NOT advanced; it will update on your next clean run."
warn "Exiting with status 2."
exit 2
fi
write_stamp
print_next_steps
log "Update applied cleanly."
+39
View File
@@ -0,0 +1,39 @@
{
"_schema": {
"description": "Manual-action manifest for self-hosted upgrades, read by update.sh with jq. Hand-edit this file directly; it is the source of truth (no generation step). Keys are self-hosted release versions as bare semver (e.g. \"0.7.0\"), matching the self-hosted/vX.Y.Z tag and the \"## [0.7.0]\" CHANGELOG heading. Only add an entry for a release that needs an action a file diff cannot encode: a data migration, a run-this-first script, or a breaking default. Routine config changes are applied automatically by update.sh's 3-way merge and must NOT be listed here. update.sh gates on entries in (your version, target version], ordered with sort -V.",
"fields": {
"breaking": "bool - requires explicit user confirmation before applying",
"gate": "string|null - script to run before upgrading past this release (e.g. utils/upgrade-pg17.sh)",
"migration_guide_url": "string|null - link to the per-release migration guide",
"requires": "string[] - free-text manual steps shown to the user"
}
},
"0.6.0": {
"breaking": true,
"gate": "utils/upgrade-pg17.sh",
"migration_guide_url": "https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17",
"requires": [
"Postgres 17 is now the default. Do NOT start Postgres 17 against an existing Postgres 15 data directory - back up your database first.",
"Run 'sudo bash utils/upgrade-pg17.sh' (needs bash + root) to migrate Postgres 15 -> 17, then recreate containers. To defer, pin Postgres 15 with the docker-compose.pg15.yml override.",
"Includes a security fix for the API gateway (Realtime /api/tenants and /api/openapi routes) - strongly recommended for any instance running Realtime."
]
},
"0.7.0": {
"breaking": true,
"gate": null,
"migration_guide_url": "https://github.com/orgs/supabase/discussions/47093",
"requires": [
"API_EXTERNAL_URL now includes the /auth/v1 path prefix, and SAML SSO endpoints moved to /auth/v1/sso/saml/*. Update custom OAuth provider callback URLs and any SAML configuration accordingly.",
"Anon (publishable) key access to the OpenAPI spec at /rest/v1/ has been removed. Use the service role or secret API key if you relied on it; normal data access via /rest/v1/<table> is unaffected."
]
},
"0.8.0": {
"breaking": true,
"gate": null,
"migration_guide_url": "https://github.com/orgs/supabase/discussions/48048",
"requires": [
"Envoy is now the default API gateway, replacing Kong. The gateway service is renamed from 'kong' to 'api-gw' (container 'supabase-envoy'); the 'kong' network alias still resolves, so internal service references keep working.",
"If you customized volumes/api/kong.yml or the gateway service, enable the Kong override with 'sh run.sh config add kong' to keep running Kong; otherwise the merge switches you to Envoy."
]
}
}
+226
View File
@@ -0,0 +1,226 @@
#!/bin/sh
#
# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation.
#
# Reads JWT_SECRET from .env and generates:
# - EC P-256 key pair (JWT_KEYS, JWT_JWKS)
# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY)
# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC)
#
# Usage:
# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env
# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env
# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only
#
# Prerequisites:
# - .env file with JWT_SECRET set (run generate-keys.sh first)
# - node (>= 16) or docker
#
set -e
node_ok() {
command -v node >/dev/null 2>&1 || return 1
major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
[ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null
}
# Resolve how to run node: local install (>= 16) preferred, docker fallback.
if node_ok; then
node_runner="node"
else
if command -v node >/dev/null 2>&1; then
echo "Local node $(node -v) is too old (need >= 16), falling back to docker."
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Error: requires either node (>= 16) or docker."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Error: docker is installed but the daemon is not running."
exit 1
fi
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
echo "Pulling node:22-alpine (first-run only)..."
docker pull node:22-alpine
fi
node_runner="docker run --rm node:22-alpine node"
fi
# Read JWT_SECRET from .env
if [ ! -f .env ]; then
echo "Error: .env file not found. Run generate-keys.sh first."
exit 1
fi
jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2- | tr -d '\r')
if [ -z "$jwt_secret" ]; then
echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first."
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Node.js does the crypto-heavy work:
# - EC P-256 keypair generation
# - JWKS construction (with symmetric key included)
# - ES256 JWT signing
# - Opaque API key generation with checksum
$node_runner -e '
const crypto = require("crypto");
const jwtSecret = process.argv[1];
// Generate EC P-256 keypair and export as JWK
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
const jwkPrivate = privateKey.export({ format: "jwk" });
const kid = crypto.randomUUID();
// Symmetric key as JWK (base64url-encoded)
const octKey = {
kty: "oct",
k: Buffer.from(jwtSecret).toString("base64url"),
alg: "HS256"
};
// JWKS with private key (for Auth to sign tokens)
const jwksKeypair = { keys: [
{ kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true,
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d },
octKey
]};
// JWKS with public key only (for PostgREST, Realtime, Storage to verify)
const jwksPublic = { keys: [
{ kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true,
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y },
octKey
]};
// Sign ES256 JWT
function signES256(payload) {
const header = { alg: "ES256", typ: "JWT", kid };
const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const data = b64Header + "." + b64Payload;
const sig = crypto.sign("SHA256", Buffer.from(data), {
key: privateKey,
dsaEncoding: "ieee-p1363"
}).toString("base64url");
return data + "." + sig;
}
const iat = Math.floor(Date.now() / 1000);
const exp = iat + 5 * 365 * 24 * 3600; // 5 years
const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp });
const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp });
// Generate opaque API keys with checksum
const PROJECT_REF = "supabase-self-hosted";
function generateOpaqueKey(prefix) {
const random = crypto.randomBytes(17).toString("base64url").slice(0, 22);
const intermediate = prefix + random;
const checksum = crypto.createHash("sha256")
.update(PROJECT_REF + "|" + intermediate)
.digest("base64url")
.slice(0, 8);
return intermediate + "_" + checksum;
}
const publishableKey = generateOpaqueKey("sb_publishable_");
const secretKey = generateOpaqueKey("sb_secret_");
// Output as KEY=value lines for shell to parse
console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey);
console.log("SUPABASE_SECRET_KEY=" + secretKey);
console.log("ANON_KEY_ASYMMETRIC=" + anonJwt);
console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt);
console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys));
console.log("JWT_JWKS=" + JSON.stringify(jwksPublic));
' "$jwt_secret" > "$tmpdir/output"
# Read generated values
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-)
ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-)
JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-)
echo ""
echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}"
echo ""
echo "JWT_KEYS=${JWT_KEYS}"
echo ""
echo "JWT_JWKS=${JWT_JWKS}"
echo ""
echo "Ensure the following configuration is uncommented in docker-compose.yml for the asymmetric key pair to work:"
echo ""
echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}"
echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo " Functions: SUPABASE_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo ""
if [ "$1" = "--update-env" ]; then
update_env=true
elif test -t 0; then
printf "Update .env file? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy]) update_env=true ;;
*) update_env=false ;;
esac
else
echo "Running non-interactively. Pass --update-env to write to .env."
update_env=false
fi
if [ "$update_env" != "true" ]; then
exit 0
fi
echo "Updating .env..."
# Append new variables if they don't exist, or update them if they do
for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do
eval "val=\$$var"
if grep -q "^${var}=" .env; then
sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env
else
echo "${var}=${val}" >> .env
fi
done
# Uncomment new auth configuration in docker-compose.yml
echo "Updating docker-compose.yml..."
if [ ! -f docker-compose.yml ]; then
echo "Error: docker-compose.yml not found in $(pwd)"
exit 1
fi
# Always fall through to the grep check
sed -i.old \
-e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \
-e '/^[ ]*#API_JWT_JWKS:/ s/#//' \
-e '/^[ ]*#JWT_JWKS:/ s/#//' \
-e '/^[ ]*#SUPABASE_JWKS:/ s/#//' \
docker-compose.yml || true
if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \
grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \
grep -q '^[ ]*JWT_JWKS:' docker-compose.yml && \
grep -q '^[ ]*SUPABASE_JWKS:' docker-compose.yml; then
echo "Done."
else
echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually."
fi
+157
View File
@@ -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 <<EOF
alter user anon with password '${new_passwd}';
alter user authenticated with password '${new_passwd}';
alter user authenticator with password '${new_passwd}';
alter user dashboard_user with password '${new_passwd}';
alter user pgbouncer with password '${new_passwd}';
alter user postgres with password '${new_passwd}';
alter user service_role with password '${new_passwd}';
alter user supabase_admin with password '${new_passwd}';
alter user supabase_auth_admin with password '${new_passwd}';
alter user supabase_functions_admin with password '${new_passwd}';
alter user supabase_replication_admin with password '${new_passwd}';
alter user supabase_storage_admin with password '${new_passwd}';
DROP SCHEMA _supavisor CASCADE;
create schema if not exists _supavisor;
alter schema _supavisor owner to supabase_admin;
DO \$\$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = '_analytics'
AND table_name = 'source_backends'
) THEN
UPDATE _analytics.source_backends
SET config = jsonb_set(
config,
'{url}',
'"postgresql://${db_admin_user}:${new_passwd}@${db_srv_name}:${db_srv_port}/postgres"',
false
)
WHERE type = 'postgres';
END IF;
END
\$\$;
EOF
echo "Updating POSTGRES_PASSWORD in .env..."
sed -i.old "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=$new_passwd|" .env
echo ""
echo "Success. To update and restart containers use:"
echo ""
echo "docker compose up -d --force-recreate"
echo ""
+138
View File
@@ -0,0 +1,138 @@
#!/bin/sh
#
# Generate secrets and legacy symmetric JWT API keys for self-hosted Supabase.
#
# Generates: JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, and other secrets
# needed for a fresh installation.
#
# Usage:
# sh generate-keys.sh # Interactive: prints keys, prompts to update .env
# sh generate-keys.sh --update-env # Prints keys and writes them to .env
# sh generate-keys.sh | tee keys # Non-interactive: prints keys only
#
# Portions of this code are derived from Inder Singh's setup.sh shell script.
# Copyright 2025 Inder Singh. Licensed under Apache License 2.0.
# Original source: https://github.com/singh-inder/supabase-automated-self-host/blob/main/setup.sh
#
set -e
gen_hex() {
openssl rand -hex "$1"
}
gen_base64() {
openssl rand -base64 "$1"
}
base64_url_encode() {
openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}
gen_token() {
payload=$1
payload_base64=$(printf %s "$payload" | base64_url_encode)
header_base64=$(printf %s "$header" | base64_url_encode)
signed_content="${header_base64}.${payload_base64}"
signature=$(printf %s "$signed_content" | openssl dgst -binary -sha256 -hmac "$jwt_secret" | base64_url_encode)
printf '%s' "${signed_content}.${signature}"
}
if ! command -v openssl >/dev/null 2>&1; then
echo "Error: openssl is required but not found."
exit 1
fi
jwt_secret="$(gen_base64 30)"
# Used in gen_token()
header='{"alg":"HS256","typ":"JWT"}'
iat=$(date +%s)
exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years
# Normalizes JSON formatting so that the token matches https://www.jwt.io/ results
anon_payload="{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
service_role_payload="{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
#echo "anon_payload=$anon_payload"
#echo "service_role_payload=$service_role_payload"
anon_key=$(gen_token "$anon_payload")
service_role_key=$(gen_token "$service_role_payload")
secret_key_base=$(gen_base64 48)
realtime_db_enc_key=$(gen_hex 8)
vault_enc_key=$(gen_hex 16)
pg_meta_crypto_key=$(gen_base64 24)
logflare_public_access_token=$(gen_base64 24)
logflare_private_access_token=$(gen_base64 24)
s3_protocol_access_key_id=$(gen_hex 16)
s3_protocol_access_key_secret=$(gen_hex 32)
minio_root_password=$(gen_hex 16)
echo ""
echo "JWT_SECRET=${jwt_secret}"
echo ""
#echo "Issued at: $iat"
#echo "Expire: $exp"
echo "ANON_KEY=${anon_key}"
echo "SERVICE_ROLE_KEY=${service_role_key}"
echo ""
echo "SECRET_KEY_BASE=${secret_key_base}"
echo "REALTIME_DB_ENC_KEY=${realtime_db_enc_key}"
echo "VAULT_ENC_KEY=${vault_enc_key}"
echo "PG_META_CRYPTO_KEY=${pg_meta_crypto_key}"
echo "LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}"
echo "LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}"
echo "S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}"
echo "S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}"
echo "MINIO_ROOT_PASSWORD=${minio_root_password}"
echo ""
postgres_password=$(gen_hex 16)
dashboard_password=$(gen_hex 16)
echo "POSTGRES_PASSWORD=${postgres_password}"
echo "DASHBOARD_PASSWORD=${dashboard_password}"
echo ""
if [ "$1" = "--update-env" ]; then
update_env=true
elif test -t 0; then
printf "Update .env file? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy]) update_env=true ;;
*) update_env=false ;;
esac
else
echo "Running non-interactively. Pass --update-env to write to .env."
update_env=false
fi
if [ "$update_env" != "true" ]; then
exit 0
fi
echo "Updating .env..."
sed \
-i.old \
-e "s|^JWT_SECRET=.*$|JWT_SECRET=${jwt_secret}|" \
-e "s|^ANON_KEY=.*$|ANON_KEY=${anon_key}|" \
-e "s|^SERVICE_ROLE_KEY=.*$|SERVICE_ROLE_KEY=${service_role_key}|" \
-e "s|^SECRET_KEY_BASE=.*$|SECRET_KEY_BASE=${secret_key_base}|" \
-e "s|^REALTIME_DB_ENC_KEY=.*$|REALTIME_DB_ENC_KEY=${realtime_db_enc_key}|" \
-e "s|^VAULT_ENC_KEY=.*$|VAULT_ENC_KEY=${vault_enc_key}|" \
-e "s|^PG_META_CRYPTO_KEY=.*$|PG_META_CRYPTO_KEY=${pg_meta_crypto_key}|" \
-e "s|^LOGFLARE_PUBLIC_ACCESS_TOKEN=.*$|LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}|" \
-e "s|^LOGFLARE_PRIVATE_ACCESS_TOKEN=.*$|LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}|" \
-e "s|^S3_PROTOCOL_ACCESS_KEY_ID=.*$|S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}|" \
-e "s|^S3_PROTOCOL_ACCESS_KEY_SECRET=.*$|S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}|" \
-e "s|^MINIO_ROOT_PASSWORD=.*$|MINIO_ROOT_PASSWORD=${minio_root_password}|" \
-e "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=${postgres_password}|" \
-e "s|^DASHBOARD_PASSWORD=.*$|DASHBOARD_PASSWORD=${dashboard_password}|" \
.env
+153
View File
@@ -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."
@@ -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
+822
View File
@@ -0,0 +1,822 @@
#!/usr/bin/env bash
#
# Requires bash (not sh) for pipefail, which ensures failures in piped
# commands are caught during the upgrade.
#
# Upgrade self-hosted Supabase Postgres from 15 to 17.
#
# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a
# temporary PG 15 container, then swaps data directories and starts Postgres 17.
#
# Usage (must be run as root or with sudo):
# cd docker/
# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation)
# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts)
#
# Requirements:
# - Docker with Docker Compose (docker compose, not docker-compose)
# - Running Supabase self-hosted setup with Postgres 15
# - At least 2x current database size + 5 GB free disk space
#
# Backup:
# The original Postgres 15 data directory is preserved as
# ./volumes/db/data.bak.pg15 during the upgrade.
# DO NOT DELETE it until you have verified the upgrade was successful.
#
# Rollback (if the upgrade fails or you want to revert):
# 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down
# 2. rm -rf ./volumes/db/data
# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data
# 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/
# 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
#
# Ensure we're running under bash (not sh/zsh/dash).
# Check that $BASH ends with /bash (not /sh, /zsh, etc.).
case "${BASH:-}" in
*/bash) ;;
*) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;;
esac
set -euo pipefail
AUTO_CONFIRM=false
for arg in "$@"; do
case "$arg" in
--yes|-y) AUTO_CONFIRM=true ;;
esac
done
# --- Configuration ----------------------------------------------------------
# Image used for the upgrade tarball + complete.sh container.
# Must share glibc with PG 15 (the extracted ELF binaries run inside PG15).
# Pinned to .063: later images bumped glibc, which breaks the ELF extraction.
PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063"
# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts)
PG17_SCRIPTS_REF="17.6.1.063"
# Final Postgres 17 image that runs after the upgrade. Pinned here (not read from
# the compose files). Keep in sync with the db image in docker-compose.yml (and
# docker-compose.pg17.yml). Used for pulling, chowning data/db-config to the
# target's postgres UID, and running the post-upgrade migrations.
PG17_TARGET_IMAGE="supabase/postgres:17.6.1.136"
DB_CONTAINER="supabase-db"
UPGRADE_CONTAINER="supabase-pg-upgrade"
COMPLETE_CONTAINER="supabase-pg-complete"
DATA_DIR="./volumes/db/data"
BACKUP_DIR="./volumes/db/data.bak.pg15"
# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it
PG17_TAG="${PG17_UPGRADE_IMAGE##*:}"
TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz"
# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/
MIGRATION_DIR="./volumes/db/data_migration"
# --- Helpers ----------------------------------------------------------------
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
info() { printf '\n==> %s\n' "$*"; }
warn() { printf 'Warning: %s\n' "$*" >&2; }
# Temp dir on host for tarball + scripts (mounted into containers)
staging_dir=""
pg_password=""
current_image=""
drop_extensions=""
db_config_vol=""
# Remove leftover containers and staging dir on exit.
# Uses an alpine container for rm because the tarball build runs as root
# inside Docker - the resulting files are root-owned and can't be deleted
# by the host user on macOS.
cleanup() {
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then
docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true
rm -rf "$staging_dir" 2>/dev/null || true
fi
}
trap cleanup EXIT
on_interrupt() {
echo ""
warn "Interrupted. Cleaning up..."
# If db-config was chowned to PG17, restore for PG 15 rollback
if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then
docker run --rm -v "${db_config_vol}:/vol" "$current_image" \
chown -R postgres:postgres /vol/ 2>/dev/null || true
fi
die "Interrupted."
}
trap on_interrupt INT
confirm() {
if [ "$AUTO_CONFIRM" = true ]; then return 0; fi
if ! test -t 0; then
die "This script must be run interactively, or use --yes to skip prompts."
fi
printf '%s (y/N) ' "$1"
read -r reply
case "$reply" in
[Yy]*) return 0 ;;
*) echo "Aborted."; exit 0 ;;
esac
}
run_sql_on() {
local container=$1; shift
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$container" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
}
wait_for_healthy() {
local container=$1 retries=30
while [ $retries -gt 0 ]; do
if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
return 0
fi
retries=$((retries - 1))
sleep 1
done
die "Postgres in '$container' did not become ready in 30 seconds."
}
# --- Pre-flight checks -----------------------------------------------------
preflight() {
info "Running pre-flight checks"
if [ "$(id -u)" -ne 0 ]; then
die "This script must be run as root (e.g. sudo bash $0)."
fi
docker compose version >/dev/null 2>&1 || die "Docker Compose not found."
command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)."
[ -f docker-compose.yml ] || die "Run this script from the docker/ directory."
[ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml."
[ -f .env ] || die "Missing .env file."
# Resolve db-config volume (exact match on _db-config suffix or bare db-config)
db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \
| grep -E '^db-config$|_db-config$' | head -n 1)
[ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?"
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1)
[ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env."
docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \
|| die "Container '$DB_CONTAINER' not found. Is Supabase running?"
current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}')
case "$current_image" in
supabase/postgres:15.*|supabase.postgres:15.*) ;;
supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;;
*) die "Unexpected database image: $current_image" ;;
esac
local status
status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}')
[ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)."
[ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR"
if [ -d "$BACKUP_DIR" ]; then
warn "Backup directory already exists: $BACKUP_DIR"
warn "This is likely from a previous upgrade attempt."
warn "If you haven't verified that previous upgrade, roll back first:"
warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
warn " 2. rm -rf $DATA_DIR"
warn " 3. mv $BACKUP_DIR $DATA_DIR"
warn " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
warn " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
warn "Continuing will DELETE the existing backup permanently."
confirm "Delete $BACKUP_DIR and start a fresh upgrade?"
rm -rf "$BACKUP_DIR"
fi
if [ -d "$MIGRATION_DIR" ]; then
rm -rf "$MIGRATION_DIR"
fi
# Disk space
local data_size_kb data_size_mb avail_kb avail_mb needed_mb
data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1)
[ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR"
data_size_mb=$((data_size_kb / 1024))
avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }')
[ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")"
avail_mb=$((avail_kb / 1024))
needed_mb=$((data_size_mb * 2 + 5000))
echo " Data size: ${data_size_mb} MB"
echo " Available space: ${avail_mb} MB"
echo " Estimated need: ${needed_mb} MB"
if [ "$avail_mb" -lt "$needed_mb" ]; then
warn "Disk space may be insufficient."
warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball."
confirm "Continue anyway?"
fi
# Incompatible extensions
info "Checking for incompatible extensions"
local incompatible
incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c "
SELECT string_agg(extname, ', ')
FROM pg_extension
WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls');
" 2>/dev/null | tr -d '[:space:]') || true
if [ -n "$incompatible" ]; then
warn "Incompatible extensions found: $incompatible"
warn "These do not exist in Postgres 17 and must be dropped before upgrading."
warn "If you proceed, they will be dropped automatically."
warn "The original data is preserved as a backup so you can roll back."
confirm "Drop these extensions and continue with the upgrade?"
drop_extensions="$incompatible"
fi
echo ""
echo "This script will:"
echo " 1. Pull the Postgres 17 image"
echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)"
echo " 3. Stop all Supabase services"
echo " 4. Run pg_upgrade (Postgres 15 -> 17)"
echo " 5. Apply post-upgrade patches"
echo " 6. Start Supabase with Postgres 17"
echo " 7. Apply additional migrations"
echo ""
echo " Current image: $current_image"
echo " Target image: $PG17_TARGET_IMAGE"
echo " Upgrade image: $PG17_UPGRADE_IMAGE"
echo " Data directory: $DATA_DIR"
echo " Backup location: $BACKUP_DIR"
echo ""
confirm "Proceed with the upgrade?"
}
# --- Step 1: Pull Postgres 17 image ----------------------------------------
pull_image() {
info "Pulling Postgres 17 images"
docker pull "$PG17_UPGRADE_IMAGE"
if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then
docker pull "$PG17_TARGET_IMAGE"
fi
}
# --- Step 2: Build upgrade tarball -----------------------------------------
#
# Extracts PG 17 binaries, libraries, share data, and upgrade scripts from
# the PG 17 Docker image into a tarball that initiate.sh can consume.
#
# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no
# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find
# the bundled libraries.
build_tarball() {
local tmpbase="${TMPDIR:-/tmp}"
staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX")
# World-writable so Docker containers can write to bind mounts on macOS,
# where the VM's root user has no special access to host directories.
chmod 777 "$staging_dir"
echo " Staging directory: $staging_dir"
# Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF).
# These are no longer bundled in the latest PG 17 Docker images.
info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)"
local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts"
mkdir -p "$staging_dir/scripts"
for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do
curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \
|| die "Failed to download $script from GitHub"
done
if [ -f "$TARBALL_CACHE" ]; then
info "Using cached upgrade tarball: $TARBALL_CACHE"
cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz"
return
fi
info "Building upgrade tarball from Postgres 17 image (first run)"
docker run --rm --user root --entrypoint bash \
-v "$staging_dir:/export" \
"$PG17_UPGRADE_IMAGE" \
-c '
set -euo pipefail
mkdir -p /export/17/bin /export/17/lib /export/17/share
echo " Copying binaries..."
# Binaries in the nix profile are either ELF binaries or shell
# wrappers that exec a .xxx-wrapped ELF from the nix store.
# Extract the actual ELF binaries so they work outside nix.
BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres))
for f in "$BIN_DIR"/*; do
name=$(basename "$f")
# Skip nix wrapper-internal files
case "$name" in .*-wrapped) continue ;; esac
# Check for ELF
if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then
cp "$f" /export/17/bin/"$name"
else
# Shell wrapper - extract the real .xxx-wrapped ELF path
wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true)
if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then
cp "$wrapped" /export/17/bin/"$name"
else
cp "$f" /export/17/bin/"$name"
fi
fi
done
echo " Copying libraries..."
PKGLIBDIR=$(pg_config --pkglibdir)
LIBDIR=$(pg_config --libdir)
# These paths may overlap (PKGLIBDIR and LIBDIR often point to the
# same nix store path). Use cp -Lf to handle overwrites from
# read-only nix store source files.
cp -Lf "$PKGLIBDIR"/*.so /export/17/lib/ || echo " Warning: cp from $PKGLIBDIR failed" >&2
cp -Lf "$LIBDIR"/*.so* /export/17/lib/ || echo " Warning: cp from $LIBDIR failed" >&2
cp -Lf /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ || echo " Warning: cp from nix profile lib failed" >&2
echo " Copying share data..."
# Nix-built binaries resolve share dir relative to their location:
# <bindir>/../share/postgresql/
# so we need share/postgresql/ not just share/
mkdir -p /export/17/share/postgresql
# Remove cyclic symlink (timezonesets/timezonesets -> timezonesets).
rm -f /usr/share/postgresql/timezonesets/timezonesets 2>/dev/null || true
# Pre-create subdirectories so cp -rL does not need to mkdir them.
# On macOS with Docker Desktop bind mount rejects mkdir with the nix store
# read-only (dr-xr-xr-x) permissions; pre-creating with default
# writable permissions fixed this
mkdir -p /export/17/share/postgresql/{extension,timezonesets,tsearch_data}
mkdir -p /export/17/share/postgresql/extension/{functions,procedures,tables,types}
cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ || echo " Warning: cp share data had errors" >&2
# initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/
echo " Copying extension definitions to lib..."
SHAREDIR=$(pg_config --sharedir)
cp "$SHAREDIR"/extension/*.control /export/17/lib/ || echo " Warning: cp .control from $SHAREDIR/extension failed" >&2
cp "$SHAREDIR"/extension/*.sql /export/17/lib/ || echo " Warning: cp .sql from $SHAREDIR/extension failed" >&2
# Verify critical files before creating tarball
echo " Checking for key files..."
[ -f /export/17/bin/postgres ] || { echo "Error: bin/postgres missing"; exit 1; }
[ -f /export/17/share/postgresql/timezonesets/Default ] || { echo "Error: timezonesets/Default missing"; exit 1; }
ls /export/17/share/postgresql/extension/*.control >/dev/null 2>&1 || { echo "Error: no .control files in extension/"; exit 1; }
ls /export/17/lib/*.so >/dev/null 2>&1 || { echo "Error: no .so files in lib/"; exit 1; }
echo " Creating tarball (this may take several minutes)..."
cd /export && tar czf pg_upgrade_bin.tar.gz 17/
echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)"
'
# Cache for next run
cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE"
info "Tarball cached at $TARBALL_CACHE"
}
# --- Step 3: Drop incompatible extensions ----------------------------------
drop_incompatible_extensions() {
if [ -z "$drop_extensions" ]; then
return
fi
info "Dropping incompatible extensions"
local ext
echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do
ext=$(echo "$ext" | tr -d '[:space:]')
[ -z "$ext" ] && continue
echo " DROP EXTENSION $ext CASCADE"
run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;"
done
}
# --- Step 4: Stop services and back up -------------------------------------
stop_and_backup() {
info "Backing up pgsodium root key"
local key_backup="./volumes/db/pgsodium_root.key.bak.pg15"
docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \
alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \
|| die "Failed to back up pgsodium root key from db-config volume."
echo " Saved to: $key_backup"
info "Stopping all Supabase services"
docker compose down
echo " Original data will be preserved as: $BACKUP_DIR"
}
# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------
#
# Host directories are mounted at non-standard paths (/mnt/host-*) with
# symlinks at the paths the upgrade scripts expect. This lets complete.sh's
# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and
# /data_migration) operate on symlinks rather than bind mounts.
run_upgrade() {
local abs_data_dir abs_migration_dir
mkdir -p "$MIGRATION_DIR"
# World-writable for macOS Docker bind mount compatibility (see build_tarball)
chmod 777 "$MIGRATION_DIR"
abs_data_dir=$(cd "$DATA_DIR" && pwd)
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
info "Starting upgrade container"
docker run -d --name "$UPGRADE_CONTAINER" \
--entrypoint sleep \
-v "${abs_data_dir}:/mnt/host-pgdata" \
-v "${abs_migration_dir}:/mnt/host-migration" \
-v "${db_config_vol}:/etc/postgresql-custom" \
-v "${staging_dir}:/tmp/staging:ro" \
-e PGPASSWORD="$pg_password" \
"$current_image" \
infinity
info "Preparing upgrade environment"
docker exec "$UPGRADE_CONTAINER" bash -c '
# Symlink bind mounts to the paths the upgrade scripts expect
rm -rf /var/lib/postgresql/data
ln -s /mnt/host-pgdata /var/lib/postgresql/data
ln -s /mnt/host-migration /data_migration
mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade
cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
chmod +x /tmp/upgrade/*.sh
# Patch CI_start_postgres to use "restart" instead of "start" so it
# is idempotent (initiate.sh starts postgres for top-level queries,
# then handle_extensions calls CI_start_postgres again)
sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh
# Patch PGSHARENEW to match nix binary expectations (share/postgresql/)
sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh
'
info "Starting Postgres 15 in upgrade container"
docker exec "$UPGRADE_CONTAINER" bash -c '
su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log"
'
wait_for_healthy "$UPGRADE_CONTAINER"
# initiate.sh expects the PG 17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz
# (hardcoded path - copied there during container setup above).
#
# Env vars for the unwrapped nix ELF binaries in the tarball:
# LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths)
# NIX_PGLIBDIR - postgres uses this to find extension .so files
info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)"
echo " This may take several minutes depending on database size..."
echo ""
if ! docker exec \
-e IS_CI=true \
-e PG_MAJOR_VERSION=17 \
-e PGPASSWORD="$pg_password" \
-e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \
-e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \
"$UPGRADE_CONTAINER" \
/tmp/upgrade/initiate.sh 17; then
echo ""
warn "initiate.sh failed. Its cleanup may have restored the original state"
warn "(re-enabled extensions, revoked superuser). Your data directory is"
warn "unchanged - no data was moved or deleted."
warn ""
warn "Check the output above for the root cause, fix it, and re-run."
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
die "initiate.sh failed"
fi
info "initiate.sh completed successfully"
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
}
# --- Step 6: Run complete.sh in a native PG 17 container -------------------
#
# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption,
# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container
# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed.
run_complete() {
local abs_migration_dir
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
info "Starting PG17 container for complete.sh"
docker run -d --name "$COMPLETE_CONTAINER" \
--entrypoint sleep \
-v "${abs_migration_dir}:/mnt/host-migration" \
-v "${db_config_vol}:/etc/postgresql-custom" \
-v "${staging_dir}:/tmp/staging:ro" \
-e PGPASSWORD="$pg_password" \
"$PG17_UPGRADE_IMAGE" \
infinity
info "Preparing complete.sh environment"
# Save original db-config ownership so we can restore it if complete.sh fails.
# complete.sh needs PG 17 ownership to start postgres, but if it fails the
# user needs to fall back to PG 15 which uses a different uid.
docker exec "$COMPLETE_CONTAINER" bash -c '
stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true
'
docker exec "$COMPLETE_CONTAINER" bash -c '
# Symlink bind mount so complete.sh CI wrapper can mv/rm/ln
ln -s /mnt/host-migration /data_migration
# Remove the image default data dir (complete.sh creates a symlink here)
rm -rf /var/lib/postgresql/data
# Fix ownership on db-config volume (PG15 uid differs from PG17)
chown -R postgres:postgres /etc/postgresql-custom/
# PG17 config includes this directory; may not exist from PG15
mkdir -p /etc/postgresql-custom/conf.d
mkdir -p /tmp/upgrade
# Copy upgrade scripts
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
chmod +x /tmp/upgrade/*.sh
# Patch --new-bin to use native bindir (we are in a PG17 container,
# no need for /tmp/pg_upgrade_bin/ paths)
sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh
'
info "Running complete.sh (post-upgrade patches, vacuum analyze)"
docker exec \
-e IS_CI=true \
-e PG_MAJOR_VERSION=17 \
-e PGPASSWORD="$pg_password" \
"$COMPLETE_CONTAINER" \
/tmp/upgrade/complete.sh || true
# complete.sh's ERR trap exits with 0 in some cases; check status file
local status
status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown")
if [ "$status" != "complete" ]; then
warn "complete.sh failed. Postgres log:"
docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true
echo ""
# Restore db-config ownership so PG 15 can start for rollback
warn "Restoring db-config ownership for PG15..."
local orig_owner
orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true)
if [ -n "$orig_owner" ]; then
docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true
fi
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
echo ""
echo " Your Postgres 15 data is unchanged (data swap has not happened yet)."
echo " To restart Postgres 15:"
echo " rm -rf $MIGRATION_DIR"
echo " docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
die "complete.sh failed (status: $status)"
fi
info "complete.sh finished successfully"
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
}
# --- Step 7: Swap data directories -----------------------------------------
swap_data() {
info "Swapping data directories"
echo " $DATA_DIR -> $BACKUP_DIR"
mv "$DATA_DIR" "$BACKUP_DIR"
echo " $MIGRATION_DIR/pgdata -> $DATA_DIR"
mv "$MIGRATION_DIR/pgdata" "$DATA_DIR"
rm -rf "$MIGRATION_DIR"
}
# --- Step 8: Start Postgres 17 ---------------------------------------------
start_pg17() {
info "Starting Supabase with Postgres 17"
# Ensure db-config volume has correct ownership and structure for PG 17.
# complete.sh does this too, but just in case of partial
# failures from previous runs.
docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c '
mkdir -p /vol/conf.d
chown -R postgres:postgres /vol/
'
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
echo " Waiting for Postgres 17 to be ready..."
local retries=60
while [ $retries -gt 0 ]; do
if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
break
fi
retries=$((retries - 1))
sleep 2
done
[ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds."
local new_version
new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1)
echo " Postgres version: $new_version"
case "$new_version" in
17.*) ;;
*) die "Expected Postgres 17.x, got: $new_version" ;;
esac
}
# --- Step 9: Apply migrations not covered by complete.sh -------------------
#
# These PG 17 migrations run on fresh installs via initdb but not after
# pg_upgrade (init scripts don't rerun when PG_VERSION already exists).
# complete.sh doesn't cover them either. On the platform a tracked migration
# runner (dbmate) applies them; self-hosted has no such tracking, so we apply
# the idempotent, safe-on-an-existing-DB ones directly here.
#
# Source: postgres/migrations/db/migrations/
# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql
# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor)
# - 20251105172723_grant_pg_reload_conf_to_postgres.sql
# - 20251121132723_correct_search_path_pgbouncer.sql
# - 20260211120934_supabase_privileged_role.sql (target image's supautils.conf
# references this role; it must exist or supautils config is broken)
# - 20260413000000_fix-authenticator-session-preload-libraries.sql
# - 20260421000001_rescope_pg_graphql_access_trigger.sql
#
# NOT applied:
# - 20260421000000_pg_graphql-off-by-default.sql: drops pg_graphql. Safe on a
# fresh install, but destructive on an existing DB that uses pg_graphql.
#
# This step also reconciles extension versions (complete.sh ran with the .063
# binaries) and the pg_cron 1.6 -> 1.6.4 version label. See below.
apply_role_migrations() {
info "Applying Postgres 17 migrations"
# Fix collation version mismatch first (upgrade used glibc 2.39, target
# image may use glibc 2.40). Do this before any other SQL to suppress
# the noisy warnings on every subsequent command.
for db in postgres template1 _supabase; do
docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d "$db" \
-c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true
done
# Create supabase_etl_admin role (doesn't exist in PG 15 images).
# Must be created before running predefined_role_grants.sql which
# assumes it exists.
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN
CREATE USER supabase_etl_admin WITH LOGIN REPLICATION;
GRANT pg_read_all_data TO supabase_etl_admin;
GRANT CREATE ON DATABASE postgres TO supabase_etl_admin;
END IF;
END
\$\$;" || true
# Run the migration files directly from the PG 17 container image.
# They're idempotent (IF EXISTS / IF NOT EXISTS guards).
local migration_dir="/docker-entrypoint-initdb.d/migrations"
local migrations="
20250710151649_supabase_read_only_user_default_transaction_read_only.sql
20251001204436_predefined_role_grants.sql
20251105172723_grant_pg_reload_conf_to_postgres.sql
20251121132723_correct_search_path_pgbouncer.sql
20260211120934_supabase_privileged_role.sql
20260413000000_fix-authenticator-session-preload-libraries.sql
20260421000001_rescope_pg_graphql_access_trigger.sql
"
for m in $migrations; do
echo " Running: $m"
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \
-f "${migration_dir}/${m}" || warn " $m failed (non-fatal)"
done
# Reconcile extension versions to the target image. pg_upgrade generates
# update_extensions.sql, but complete.sh runs it inside the .063 container, so
# extensions only get updated to .063's versions. The final image ships newer
# .so files, leaving the catalog version behind what's loaded.
#
# Mirror what update_extensions.sql does, but against the running target image
# and for every installed extension: ALTER EXTENSION ... UPDATE brings each one
# up to the image's default version. This is generic on purpose - it tracks the
# image (matching platform behavior) and stays correct across future image
# bumps without maintaining a hardcoded list. ALTER ... UPDATE is a no-op when
# already at the default. Per-extension exceptions are caught so one extension
# with no update path (e.g. pg_cron 1.6 -> 1.6.4, handled below) does not abort
# the rest.
info "Reconciling extension versions to the target image"
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
DECLARE r record;
BEGIN
FOR r IN SELECT extname FROM pg_extension LOOP
BEGIN
EXECUTE format('ALTER EXTENSION %I UPDATE', r.extname);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'skipped extension %: %', r.extname, SQLERRM;
END;
END LOOP;
END
\$\$;" || warn "extension version reconcile had errors (non-fatal)"
# pg_cron: PG 15 registers the extension as '1.6'; PG 17 images package it as
# '1.6.4' with no '1.6' -> '1.6.4' update path, so ALTER EXTENSION ... UPDATE
# has no path to follow. complete.sh only fixes this when pg_cron is owned by
# 'postgres' (it drops + recreates). Manually-enabled instances are often
# owned by supabase_admin, where that path is skipped. Reconcile the catalog
# label directly: the loaded .so and the SQL objects are already
# 1.6.4-equivalent (no SQL delta between 1.6 and 1.6.4). No DROP, so any
# scheduled jobs are untouched.
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
DECLARE want text;
BEGIN
SELECT default_version INTO want FROM pg_available_extensions WHERE name = 'pg_cron';
IF want IS NOT NULL
AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron' AND extversion <> want) THEN
UPDATE pg_extension SET extversion = want WHERE extname = 'pg_cron';
END IF;
END
\$\$;" || warn "pg_cron version reconcile had errors (non-fatal)"
}
# --- Step 10: Verify ------------------------------------------------------
verify() {
info "Verification"
local version
version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1)
echo " $version"
echo ""
echo " Extensions:"
run_sql_on "$DB_CONTAINER" -c \
"SELECT extname, extversion FROM pg_extension ORDER BY extname;"
echo ""
info "Upgrade complete!"
echo ""
echo " To use Postgres 17 going forward, always include the override:"
echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d"
echo ""
echo " Postgres 15 backup: $BACKUP_DIR"
echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15"
echo " Once satisfied, you can reclaim space:"
echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz"
echo ""
echo " Rollback (if needed):"
echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
echo " 2. rm -rf $DATA_DIR"
echo " 3. mv $BACKUP_DIR $DATA_DIR"
echo " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
echo " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
}
# --- Main -------------------------------------------------------------------
main() {
echo ""
echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade"
echo "================================================"
preflight
pull_image
build_tarball
drop_incompatible_extensions
stop_and_backup
run_upgrade
run_complete
swap_data
start_pg17
apply_role_migrations
verify
}
main "$@"
+145
View File
@@ -0,0 +1,145 @@
# Docker image version updates in docker-compose.yml
## 2026-08-03
- supabase/studio:2026.08.03-sha-022b374 (prev supabase/studio:2026.07.07-sha-a6a04f2)
- kong/kong:3.9.3 (prev kong/kong:3.9.1)
## 2026-07-07
- supabase/studio:2026.07.07-sha-a6a04f2 (prev supabase/studio:2026.06.03-sha-0bca601)
## 2026-06-17
- supabase/postgres:17.6.1.136 (prev supabase/postgres:15.8.1.085)
## 2026-06-03
- supabase/studio:2026.06.03-sha-0bca601 (prev supabase/studio:2026.04.27-sha-5f60601)
- supabase/gotrue:v2.189.0 (prev supabase/gotrue:v2.186.0)
- postgrest/postgrest:v14.12 (prev postgrest/postgrest:v14.8)
- supabase/realtime:v2.102.3 (prev supabase/realtime:v2.76.5)
- supabase/storage-api:v1.60.4 (prev supabase/storage-api:v1.48.26)
- supabase/postgres-meta:v0.96.6 (prev supabase/postgres-meta:v0.96.3)
- supabase/edge-runtime:v1.74.0 (prev supabase/edge-runtime:v1.71.2)
- supabase/supavisor:2.9.5 (prev supabase/supavisor:2.7.4)
- supabase/logflare:1.43.1 (prev supabase/logflare:1.36.1)
## 2026-04-27
- supabase/studio:2026.04.27-sha-5f60601 (prev supabase/studio:2026.04.08-sha-205cbe7)
## 2026-04-08
- supabase/studio:2026.04.08-sha-205cbe7 (prev supabase/studio:2026.03.16-sha-5528817)
- postgrest/postgrest:v14.8 (prev postgrest/postgrest:v14.6)
- supabase/storage-api:v1.48.26 (prev supabase/storage-api:v1.44.2)
- supabase/postgres-meta:v0.96.3 (prev supabase/postgres-meta:v0.95.2)
- supabase/logflare:1.36.1 (prev supabase/logflare:1.31.2)
## 2026-03-16
- supabase/studio:2026.03.16-sha-5528817 (prev supabase/studio:2026.02.16-sha-26c615c)
- kong/kong:3.9.1 (prev kong:2.8.1)
- postgrest/postgrest:v14.6 (prev postgrest/postgrest:v14.5)
- supabase/storage-api:v1.44.2 (prev supabase/storage-api:v1.37.8)
- supabase/edge-runtime:v1.71.2 (prev supabase/edge-runtime:v1.70.3)
## 2026-02-16
- supabase/studio:2026.02.16-sha-26c615c (prev supabase/studio:2026.01.27-sha-6aa59ff)
- supabase/gotrue:v2.186.0 (prev supabase/gotrue:v2.185.0)
- postgrest/postgrest:v14.5 (prev postgrest/postgrest:v14.3)
- supabase/realtime:v2.76.5 (prev supabase/realtime:v2.72.0)
- supabase/storage-api:v1.37.8 (prev supabase/storage-api:v1.37.1)
- supabase/edge-runtime:v1.70.3 (prev supabase/edge-runtime:v1.70.0)
- supabase/logflare:1.31.2 (prev supabase/logflare:1.30.3)
- timberio/vector:0.53.0-alpine (prev timberio/vector:0.28.1-alpine)
## 2026-02-05
- supabase/storage-api:v1.37.1 (prev supabase/storage-api:v1.33.5)
## 2026-01-27
- supabase/studio:2026.01.27-sha-6aa59ff (prev supabase/studio:2025.12.17-sha-43f4f7f)
- supabase/gotrue:v2.185.0 (prev supabase/gotrue:v2.184.0)
- postgrest/postgrest:v14.3 (prev postgrest/postgrest:v14.1)
- supabase/realtime:v2.72.0 (prev supabase/realtime:v2.68.0)
- supabase/storage-api:v1.33.5 (prev supabase/storage-api:v1.33.0)
- darthsim/imgproxy:v3.30.1 (prev darthsim/imgproxy:v3.8.0)
- supabase/postgres-meta:v0.95.2 (prev supabase/postgres-meta:v0.95.1)
- supabase/edge-runtime:v1.70.0 (prev supabase/edge-runtime:v1.69.28)
- supabase/logflare:1.30.3 (prev supabase/logflare:1.27.0)
## 2025-12-18
- supabase/studio:2025.12.17-sha-43f4f7f (prev supabase/studio:2025.12.09-sha-434634f)
- supabase/gotrue:v2.184.0 (prev supabase/gotrue:v2.183.0)
- supabase/postgres-meta:v0.95.1 (prev supabase/postgres-meta:v0.93.1)
- supabase/logflare:1.27.0 (prev supabase/logflare:1.26.25)
## 2025-12-10
- supabase/studio:2025.12.09-sha-434634f (prev supabase/studio:2025.11.26-sha-8f096b5)
- postgrest/postgrest:v14.1 (prev postgrest/postgrest:v13.0.7)
- supabase/realtime:v2.68.0 (prev supabase/realtime:v2.65.3)
- supabase/storage-api:v1.33.0 (prev supabase/storage-api:v1.32.0)
- supabase/edge-runtime:v1.69.28 (prev supabase/edge-runtime:v1.69.25)
- supabase/logflare:1.26.25 (prev supabase/logflare:1.26.13)
## 2025-11-26
- supabase/studio:2025.11.26-sha-8f096b5 (prev supabase/studio:2025.11.24-sha-d990ae8)
- supabase/realtime:v2.65.3 (prev supabase/realtime:v2.65.2)
- supabase/logflare:1.26.13 (prev supabase/logflare:1.26.12)
## 2025-11-25
- supabase/studio:2025.11.24-sha-d990ae8 (prev supabase/studio:2025.11.10-sha-5291fe3)
- supabase/gotrue:v2.183.0 (prev supabase/gotrue:v2.182.1)
- supabase/realtime:v2.65.2 (prev supabase/realtime:v2.63.0)
- supabase/storage-api:v1.32.0 (prev supabase/storage-api:v1.29.0)
- supabase/edge-runtime:v1.69.25 (prev supabase/edge-runtime:v1.69.23)
- supabase/logflare:1.26.12 (prev supabase/logflare:1.22.6)
## 2025-11-12
- supabase/studio:2025.11.10-sha-5291fe3 (prev supabase/studio:2025.10.27-sha-85b84e0)
- supabase/gotrue:v2.182.1 (prev supabase/gotrue:v2.180.0)
- supabase/realtime:v2.63.0 (prev supabase/realtime:v2.57.2)
- supabase/storage-api:v1.29.0 (prev supabase/storage-api:v1.28.2)
- supabase/edge-runtime:v1.69.23 (prev supabase/edge-runtime:v1.69.15)
- supabase/supavisor:2.7.4 (prev supabase/supavisor:2.7.3)
## 2025-10-28
- supabase/studio:2025.10.27-sha-85b84e0 (prev supabase/studio:2025.10.20-sha-5005fc6)
- supabase/realtime:v2.57.2 (prev supabase/realtime:v2.56.0)
- supabase/storage-api:v1.28.2 (prev supabase/storage-api:v1.28.1)
- supabase/postgres-meta:v0.93.1 (prev supabase/postgres-meta:v0.93.0)
- supabase/edge-runtime:v1.69.15 (prev supabase/edge-runtime:v1.69.14)
## 2025-10-21
- supabase/studio:2025.10.20-sha-5005fc6 (prev supabase/studio:2025.10.01-sha-8460121)
- supabase/realtime:v2.56.0 (prev supabase/realtime:v2.51.11)
- supabase/storage-api:v1.28.1 (prev supabase/storage-api:v1.28.0)
- supabase/postgres-meta:v0.93.0 (prev supabase/postgres-meta:v0.91.6)
- supabase/edge-runtime:v1.69.14 (prev supabase/edge-runtime:v1.69.6)
- supabase/supavisor:2.7.3 (prev supabase/supavisor:2.7.0)
## 2025-10-13
- supabase/logflare:1.22.6 (prev supabase/logflare:1.22.4)
## 2025-10-08
- supabase/studio:2025.10.01-sha-8460121 (prev supabase/studio:2025.06.30-sha-6f5982d)
- supabase/gotrue:v2.180.0 (prev supabase/gotrue:v2.177.0)
- postgrest/postgrest:v13.0.7 (prev postgrest/postgrest:v12.2.12)
- supabase/realtime:v2.51.11 (prev supabase/realtime:v2.34.47)
- supabase/storage-api:v1.28.0 (prev supabase/storage-api:v1.25.7)
- supabase/postgres-meta:v0.91.6 (prev supabase/postgres-meta:v0.91.0)
- supabase/logflare:1.22.4 (prev supabase/logflare:1.14.2)
- supabase/postgres:15.8.1.085 (prev supabase/postgres:15.8.1.060)
- supabase/supavisor:2.7.0 (prev supabase/supavisor:2.5.7)
## 2025-07-15
- supabase/gotrue:v2.177.0 (prev supabase/gotrue:v2.176.1)
- supabase/storage-api:v1.25.7 (prev supabase/storage-api:v1.24.7)
- supabase/postgres-meta:v0.91.0 (prev supabase/postgres-meta:v0.89.3)
- supabase/supavisor:2.5.7 (prev supabase/supavisor:2.5.6)
## 2025-07-02
- supabase/studio:2025.06.30-sha-6f5982d (prev supabase/studio:2025.06.02-sha-8f2993d)
- supabase/gotrue:v2.176.1 (prev supabase/gotrue:v2.174.0)
- supabase/storage-api:v1.24.7 (prev supabase/storage-api:v1.23.0)
- supabase/supavisor:2.5.6 (prev supabase/supavisor:2.5.1)
## 2025-06-03
- supabase/studio:2025.06.02-sha-8f2993d (prev supabase/studio:2025.05.19-sha-3487831)
- supabase/gotrue:v2.174.0 (prev supabase/gotrue:v2.172.1)
- supabase/storage-api:v1.23.0 (prev supabase/storage-api:v1.22.17)
- supabase/postgres-meta:v0.89.3 (prev supabase/postgres-meta:v0.89.0)