From 46aaf7f6a0210c8d424f5aeb6a8e496816bafc83 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 17:56:58 -0400 Subject: [PATCH 1/7] feat: self-hosted supabase data plane for enterprise edge profile (#231) Add a fully self-hosted Supabase stack to the enterprise compose profile so the EnterpriseEdge box has zero external SaaS dependency. Tenant data, authentication tokens, and uploaded files never leave the customer machine. Services added (enterprise profile only): - supabase-db (pgvector/pgvector:pg16): Postgres 16 with the vector extension pre-installed. Required by RAG (#232). - supabase-auth (supabase/gotrue:v2.170.0): local JWT issuer. edge-api JWKS validation points at this service via SUPABASE_JWKS_URL. - supabase-rest (postgrest/postgrest:v12.2.3): auto REST API over the local Postgres, used by control-plane as SUPABASE_URL. - supabase-storage (supabase/storage-api:v1.11.13): S3-compatible object storage with a local filesystem backend (no MinIO, AGPL avoided). Replaces the hosted Supabase Storage S3 endpoint for the enterprise path. - supabase-init: one-shot curl container that creates the hive-files and hive-images buckets via the Storage API on first compose up. Other changes: - deploy/supabase/init/00-extensions.sql: Postgres init script that enables uuid-ossp, pgcrypto, and vector extensions and creates the auth, storage, graphql_public, and extensions schemas plus the anon, authenticated, service_role, and supabase_admin roles on first boot. - supabase/migrations/20260625_01_enable_pgvector.sql: idempotent CREATE EXTENSION IF NOT EXISTS vector migration. Safe to run on both the self-hosted and hosted Supabase instances. - .env.example: ENTERPRISE_* variable block with generation instructions and commented rewire block for SUPABASE_URL, SUPABASE_DB_URL, SUPABASE_JWKS_URL, S3_ENDPOINT, and related vars. - edge-api and control-plane services gain required:false depends_on entries for supabase-auth, supabase-storage, and supabase-db so the enterprise profile waits for local services before starting application containers. Profiles local, cloud, and chat are not changed and continue to use the hosted Supabase configuration as before. Closes #231 --- .env.example | 83 +++++++ deploy/docker/docker-compose.yml | 218 ++++++++++++++++++ deploy/supabase/init/00-extensions.sql | 45 ++++ .../20260625_01_enable_pgvector.sql | 11 + 4 files changed, 357 insertions(+) create mode 100644 deploy/supabase/init/00-extensions.sql create mode 100644 supabase/migrations/20260625_01_enable_pgvector.sql diff --git a/.env.example b/.env.example index 34c62adfb..895cc0386 100644 --- a/.env.example +++ b/.env.example @@ -228,6 +228,89 @@ GRAFANA_ADMIN_PASSWORD= # deploy/litellm/config.yaml and restart LiteLLM. OLLAMA_BASE_URL= +# ─── EnterpriseEdge: Self-hosted Supabase data plane (issue #231) ─────────── +# +# These variables are ONLY needed when running --profile enterprise. +# Cloud and local profiles continue to use the hosted Supabase vars above. +# +# How to generate JWTs (anon + service_role) for a self-hosted GoTrue: +# The JWT payload format is: +# anon: {"role":"anon","iss":"","iat":} +# service_role: {"role":"service_role","iss":"","iat":} +# Sign both with HS256 using ENTERPRISE_JWT_SECRET. +# Helper: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys +# Or use: node -e "require('jsonwebtoken').sign({role:'anon',...},secret,{alg:'HS256'})" +# +# Quick-start secret generation: +# ENTERPRISE_DB_PASSWORD: openssl rand -base64 24 +# ENTERPRISE_JWT_SECRET: openssl rand -base64 48 (min 32 chars; keep private) +# ENTERPRISE_ANON_KEY: sign with above (see helper above) +# ENTERPRISE_SERVICE_ROLE_KEY: sign with above (see helper above) + +# Postgres credentials for the in-stack supabase-db service. +# Never set ENTERPRISE_DB_PASSWORD to an empty string; the :? operator in +# docker-compose.yml will refuse to start the database without it. +ENTERPRISE_DB_USER=postgres +ENTERPRISE_DB_PASSWORD= +ENTERPRISE_DB_NAME=postgres + +# JWT configuration for GoTrue (supabase-auth) and PostgREST (supabase-rest). +# ENTERPRISE_JWT_SECRET is the HS256 signing secret. Minimum 32 characters. +# ENTERPRISE_JWT_ISSUER must match what GoTrue uses to issue tokens so that +# apps/edge-api/internal/auth/jwt_supabase.go validates them correctly. +ENTERPRISE_JWT_SECRET= +ENTERPRISE_JWT_ISSUER=http://supabase-auth:9999 +ENTERPRISE_JWT_EXP=3600 + +# Supabase API keys derived from ENTERPRISE_JWT_SECRET (see helper above). +ENTERPRISE_ANON_KEY= +ENTERPRISE_SERVICE_ROLE_KEY= + +# Site URL GoTrue embeds in email confirmation links. +ENTERPRISE_SITE_URL=http://localhost:3000 +# Comma-separated redirect allow-list for OAuth flows (leave empty for none). +ENTERPRISE_REDIRECT_ALLOW_LIST= +# Set to true to skip email confirmation (recommended for air-gapped installs). +ENTERPRISE_DISABLE_SIGNUP=false +ENTERPRISE_MAILER_AUTOCONFIRM=true + +# SMTP settings for email delivery. Leave empty to disable email (autoconfirm +# above must be true when SMTP is not configured). +ENTERPRISE_SMTP_HOST= +ENTERPRISE_SMTP_PORT=587 +ENTERPRISE_SMTP_USER= +ENTERPRISE_SMTP_PASS= +ENTERPRISE_SMTP_ADMIN_EMAIL=admin@example.com + +# Storage file size limit in bytes. Default 50 MB. +ENTERPRISE_STORAGE_FILE_SIZE_LIMIT=52428800 + +# ── Rewire core vars to in-box services for the enterprise profile ───────── +# When running --profile enterprise, set these vars to point at the in-stack +# Supabase services instead of the hosted Supabase project values above. +# +# Uncomment and set these values for the enterprise profile: +# +# SUPABASE_URL=http://supabase-rest:3000 +# SUPABASE_ANON_KEY= +# SUPABASE_SERVICE_ROLE_KEY= +# SUPABASE_DB_URL=postgres://postgres:@supabase-db:5432/postgres +# SUPABASE_JWT_ISSUER=http://supabase-auth:9999 +# SUPABASE_JWKS_URL=http://supabase-auth:9999/.well-known/jwks.json +# +# Storage: Supabase Storage API local filesystem backend (no external S3). +# S3_ENDPOINT=http://supabase-storage:5000/object/s3 +# S3_ACCESS_KEY= +# S3_SECRET_KEY= +# S3_REGION=local +# S3_USE_SSL=false +# S3_BUCKET_FILES=hive-files +# S3_BUCKET_IMAGES=hive-images +# +# Open WebUI OIDC: point at the local GoTrue issuer. +# OPENID_PROVIDER_URL=http://supabase-auth:9999/.well-known/openid-configuration +# NEXT_PUBLIC_SUPABASE_URL=http://localhost:9999 + # === LiteLLM config generation (Phase 20 Plan 03) === # LITELLM_CONTAINER_NAME: Docker container name to restart after config write. # Default: litellm (matches the service name in docker-compose.yml). diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 66267d075..7d0b1a191 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -11,6 +11,14 @@ services: depends_on: control-plane: condition: service_healthy + # Enterprise profile: wait for the local auth and storage services before + # edge-api starts so JWKS fetch and S3 bucket checks succeed at boot. + supabase-auth: + condition: service_healthy + required: false + supabase-storage: + condition: service_healthy + required: false volumes: - gomodcache:/go/pkg/mod - gobuildcache:/root/.cache/go-build @@ -20,6 +28,9 @@ services: LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://litellm:4000} LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-litellm-dev-key} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + # SUPABASE_DB_URL: For the enterprise profile set this to the local DSN: + # postgres://postgres:@supabase-db:5432/postgres + # For cloud/local profiles set this to the hosted Supabase DB URL. SUPABASE_DB_URL: ${SUPABASE_DB_URL} S3_ENDPOINT: ${S3_ENDPOINT} S3_ACCESS_KEY: ${S3_ACCESS_KEY} @@ -28,6 +39,11 @@ services: S3_USE_SSL: ${S3_USE_SSL:-true} S3_BUCKET_IMAGES: ${S3_BUCKET_IMAGES:-hive-images} S3_BUCKET_FILES: ${S3_BUCKET_FILES:-hive-files} + # SUPABASE_JWT_ISSUER / SUPABASE_JWKS_URL: For the enterprise profile set + # these to the local GoTrue service: + # SUPABASE_JWT_ISSUER=http://supabase-auth:9999 + # SUPABASE_JWKS_URL=http://supabase-auth:9999/.well-known/jwks.json + # For cloud/local profiles set these to the hosted Supabase auth URLs. SUPABASE_JWT_ISSUER: ${SUPABASE_JWT_ISSUER:-} SUPABASE_JWT_AUDIENCE: ${SUPABASE_JWT_AUDIENCE:-authenticated} SUPABASE_JWKS_URL: ${SUPABASE_JWKS_URL:-} @@ -153,6 +169,11 @@ services: redis: condition: service_healthy required: false + # Enterprise profile: wait for local Postgres before control-plane starts + # so the DB connection pool succeeds at boot. + supabase-db: + condition: service_healthy + required: false volumes: - gomodcache:/go/pkg/mod - gobuildcache:/root/.cache/go-build @@ -164,11 +185,26 @@ services: # Unix socket directly via net/http; no docker CLI binary is needed. - /var/run/docker.sock:/var/run/docker.sock environment: + # SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY: + # Enterprise profile: set to the local GoTrue / PostgREST base URL and + # the ENTERPRISE_ANON_KEY / ENTERPRISE_SERVICE_ROLE_KEY values. + # SUPABASE_URL=http://supabase-rest:3000 + # SUPABASE_ANON_KEY= + # SUPABASE_SERVICE_ROLE_KEY= + # Cloud/local profiles: set these to the hosted Supabase project values. SUPABASE_URL: ${SUPABASE_URL} SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + # Enterprise profile: set to local Postgres DSN: + # postgres://postgres:@supabase-db:5432/postgres SUPABASE_DB_URL: ${SUPABASE_DB_URL} REDIS_URL: ${REDIS_URL} + # Enterprise profile: set to local Storage endpoint: + # S3_ENDPOINT=http://supabase-storage:5000/object/s3 + # S3_ACCESS_KEY= + # S3_SECRET_KEY= + # S3_REGION=local + # S3_USE_SSL=false (internal compose network, no TLS) S3_ENDPOINT: ${S3_ENDPOINT} S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY} @@ -204,6 +240,179 @@ services: retries: 5 start_period: 120s + # ────────────────────────────────────────────────────────────────────────── + # EnterpriseEdge: Self-hosted Supabase data plane (issue #231) + # + # These services activate ONLY under the `enterprise` profile. + # The cloud/local/chat profiles continue to use hosted Supabase as before. + # + # Stack: + # supabase-db - Postgres 16 with pgvector pre-installed + # supabase-auth - GoTrue (auth server, JWT issuer) + # supabase-rest - PostgREST (auto REST API over Postgres) + # supabase-storage - Supabase Storage API with local filesystem backend + # supabase-init - One-shot init container: creates hive-files and + # hive-images storage buckets via the Storage API. + # + # No MinIO: Supabase Storage with local filesystem backend eliminates the + # MinIO dependency (AGPL, banned for this product). Object data stays on the + # box under the supabase-storage-data volume. + # + # Network isolation: supabase-db binds only to the internal compose network. + # No Postgres port is exposed on the host interface. + # ────────────────────────────────────────────────────────────────────────── + + supabase-db: + image: pgvector/pgvector:pg16 + profiles: + - enterprise + environment: + POSTGRES_USER: ${ENTERPRISE_DB_USER:-postgres} + # ENTERPRISE_DB_PASSWORD must be set in .env before running --profile enterprise. + # No default is provided intentionally: Postgres refuses to start with an empty + # password when POSTGRES_PASSWORD is blank, which gives an obvious failure rather + # than a silently insecure deployment. + POSTGRES_PASSWORD: ${ENTERPRISE_DB_PASSWORD:-} + POSTGRES_DB: ${ENTERPRISE_DB_NAME:-postgres} + volumes: + - supabase-db-data:/var/lib/postgresql/data + # Seed the Supabase schema extensions Postgres needs for GoTrue and Storage. + - ../../deploy/supabase/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${ENTERPRISE_DB_USER:-postgres} -d ${ENTERPRISE_DB_NAME:-postgres}"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s + restart: unless-stopped + + supabase-auth: + image: supabase/gotrue:v2.170.0 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + environment: + GOTRUE_API_HOST: "0.0.0.0" + GOTRUE_API_PORT: "9999" + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}?search_path=auth" + GOTRUE_SITE_URL: ${ENTERPRISE_SITE_URL:-http://localhost:3000} + GOTRUE_URI_ALLOW_LIST: ${ENTERPRISE_REDIRECT_ALLOW_LIST:-} + GOTRUE_DISABLE_SIGNUP: ${ENTERPRISE_DISABLE_SIGNUP:-false} + GOTRUE_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} + GOTRUE_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_ISSUER: ${ENTERPRISE_JWT_ISSUER:-http://localhost:9999} + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_MAILER_AUTOCONFIRM: ${ENTERPRISE_MAILER_AUTOCONFIRM:-true} + GOTRUE_SMTP_HOST: ${ENTERPRISE_SMTP_HOST:-} + GOTRUE_SMTP_PORT: ${ENTERPRISE_SMTP_PORT:-587} + GOTRUE_SMTP_USER: ${ENTERPRISE_SMTP_USER:-} + GOTRUE_SMTP_PASS: ${ENTERPRISE_SMTP_PASS:-} + GOTRUE_SMTP_ADMIN_EMAIL: ${ENTERPRISE_SMTP_ADMIN_EMAIL:-admin@example.com} + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9999/health || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s + restart: unless-stopped + + supabase-rest: + image: postgrest/postgrest:v12.2.3 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + environment: + PGRST_DB_URI: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" + PGRST_DB_SCHEMAS: "public,storage,graphql_public" + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${ENTERPRISE_JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + + supabase-storage: + image: supabase/storage-api:v1.11.13 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + supabase-rest: + condition: service_healthy + environment: + ANON_KEY: ${ENTERPRISE_ANON_KEY:-} + SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:-} + POSTGREST_URL: http://supabase-rest:3000 + PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} + DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" + FILE_SIZE_LIMIT: ${ENTERPRISE_STORAGE_FILE_SIZE_LIMIT:-52428800} + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: local + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: "" + volumes: + - supabase-storage-data:/var/lib/storage + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + + # One-shot init container: creates the hive-files and hive-images buckets + # via the Storage API. The restart policy is "no" so it runs exactly once + # per compose up. If the buckets already exist the API returns 200/409 and + # the script exits 0 either way. + supabase-init: + image: curlimages/curl:8.7.1 + profiles: + - enterprise + depends_on: + supabase-storage: + condition: service_healthy + environment: + SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:-} + entrypoint: + - /bin/sh + - -c + - | + set -e + STORAGE_URL="http://supabase-storage:5000" + AUTH_HEADER="Authorization: Bearer ${SERVICE_KEY}" + for BUCKET in hive-files hive-images; do + echo "Creating bucket: $${BUCKET}" + curl -sf -X POST "$${STORAGE_URL}/bucket" \ + -H "Content-Type: application/json" \ + -H "$${AUTH_HEADER}" \ + -d "{\"id\":\"$${BUCKET}\",\"name\":\"$${BUCKET}\",\"public\":false}" \ + -w "\n[bucket=$${BUCKET} status=%{http_code}]\n" || true + done + echo "Bucket init complete." + restart: "no" + # In-stack Redis. Active for local dev, SDK tests, and self-hosted # enterprise deployments. Cloud deployments use managed Upstash via # REDIS_URL and do NOT activate this service. @@ -441,3 +650,12 @@ volumes: # and writes it atomically; litellm reads it at startup and on restart. # Phase 20 Plan 03. litellm-config: + # Enterprise self-hosted Supabase data plane volumes (issue #231). + # Active only when the `enterprise` profile is used. + # supabase-db-data: Postgres WAL and data files. Persists the full schema, + # all migrations, and all tenant data across container recreates. + supabase-db-data: + # supabase-storage-data: object files written by the Storage API local + # filesystem backend. Holds hive-files and hive-images bucket contents. + # No external S3 call; data never leaves the box. + supabase-storage-data: diff --git a/deploy/supabase/init/00-extensions.sql b/deploy/supabase/init/00-extensions.sql new file mode 100644 index 000000000..bead5c55b --- /dev/null +++ b/deploy/supabase/init/00-extensions.sql @@ -0,0 +1,45 @@ +-- Bootstrap extensions and schemas required by the self-hosted Supabase stack. +-- This file runs inside docker-entrypoint-initdb.d on first Postgres startup. +-- It is idempotent: all statements use IF NOT EXISTS. +-- +-- Required by: +-- supabase-auth (GoTrue) - needs the `auth` schema +-- supabase-rest (PostgREST) - needs the `storage` and `graphql_public` schemas +-- supabase-storage - needs the `storage` schema +-- RAG migration (#232) - needs the `vector` extension + +-- Extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "vector"; + +-- Schemas consumed by the Supabase self-host components +CREATE SCHEMA IF NOT EXISTS auth; +CREATE SCHEMA IF NOT EXISTS storage; +CREATE SCHEMA IF NOT EXISTS graphql_public; +CREATE SCHEMA IF NOT EXISTS extensions; + +-- Roles required by GoTrue and PostgREST self-host configurations. +-- These are created by the official Supabase self-host init scripts; we +-- replicate only what the enterprise edge stack actually needs. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN NOINHERIT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN NOINHERIT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin NOLOGIN NOINHERIT BYPASSRLS; + END IF; +END +$$; + +-- Grant usage on schemas to application roles +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA storage TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role; diff --git a/supabase/migrations/20260625_01_enable_pgvector.sql b/supabase/migrations/20260625_01_enable_pgvector.sql new file mode 100644 index 000000000..e55317214 --- /dev/null +++ b/supabase/migrations/20260625_01_enable_pgvector.sql @@ -0,0 +1,11 @@ +-- Enable the pgvector extension for the enterprise edge profile. +-- This migration is idempotent: IF NOT EXISTS means it is safe to run +-- on hosted Supabase (where the extension may already be present) and +-- on the self-hosted Postgres that ships with the enterprise compose profile. +-- +-- Required by: RAG vector storage (#232), HNSW index on rag_chunks.embedding. +-- Dependency: Postgres image must include the vector extension library. +-- The enterprise compose profile uses pgvector/pgvector:pg16 which ships +-- the extension pre-installed. + +CREATE EXTENSION IF NOT EXISTS vector; From d67aa62a27bf567bd3f15cde6189973206263ad3 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 18:11:35 -0400 Subject: [PATCH 2/7] fix: address review blockers B1 and B2 on enterprise data plane (#231) B1 (missing hive_app role): add hive_app NOLOGIN role to the DO block in deploy/supabase/init/00-extensions.sql. RLS policies in migration 20260529_01_rls_tenant_tables.sql grant full access to this role; without it the control-plane connection would fail all policy checks on first boot. Also grant hive_app USAGE on public and storage schemas. B2 (empty JWT secret auth bypass): all ENTERPRISE_JWT_SECRET, ENTERPRISE_DB_PASSWORD, ENTERPRISE_ANON_KEY, and ENTERPRISE_SERVICE_ROLE_KEY vars now use :? guards so a missing value refuses startup with a clear message. Docker Compose constraint fix: :? guards in enterprise-only services cause config validation errors for --profile local and --profile cloud because Compose interpolates all service envs regardless of active profile. Resolved by extracting the five Supabase services into a dedicated override file deploy/docker/docker-compose.enterprise.yml that is only parsed when explicitly included. The base docker-compose.yml retains the comment block explaining the two-file usage pattern. local and cloud profiles now validate cleanly without any ENTERPRISE_* vars set. Verification: docker compose --profile local config: OK (no enterprise vars) docker compose --profile cloud config: OK (no enterprise vars) docker compose -f docker-compose.yml -f docker-compose.enterprise.yml --profile enterprise config (with vars): OK Same without vars: errors on ENTERPRISE_DB_PASSWORD as required --- deploy/docker/docker-compose.enterprise.yml | 201 ++++++++++++++++++++ deploy/docker/docker-compose.yml | 191 ++----------------- deploy/supabase/init/00-extensions.sql | 10 + 3 files changed, 227 insertions(+), 175 deletions(-) create mode 100644 deploy/docker/docker-compose.enterprise.yml diff --git a/deploy/docker/docker-compose.enterprise.yml b/deploy/docker/docker-compose.enterprise.yml new file mode 100644 index 000000000..a22fcf070 --- /dev/null +++ b/deploy/docker/docker-compose.enterprise.yml @@ -0,0 +1,201 @@ +# docker-compose.enterprise.yml +# +# EnterpriseEdge self-hosted Supabase data plane override (issue #231). +# +# This file is intentionally separate from docker-compose.yml because Docker +# Compose evaluates ALL environment interpolations for ALL services regardless +# of which --profile is active. Placing the enterprise-only Supabase services +# here means that operators running --profile local or --profile cloud never +# see :? errors for ENTERPRISE_* vars they have not set. +# +# Usage (always pass both -f flags for enterprise): +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.enterprise.yml \ +# --profile enterprise up --build +# +# The installer (scripts/install.sh) does this automatically. +# +# Required vars (set in .env before running): +# ENTERPRISE_DB_PASSWORD openssl rand -base64 24 +# ENTERPRISE_JWT_SECRET openssl rand -base64 48 (min 32 chars) +# ENTERPRISE_ANON_KEY sign {"role":"anon"} JWT with ENTERPRISE_JWT_SECRET +# ENTERPRISE_SERVICE_ROLE_KEY sign {"role":"service_role"} JWT with ENTERPRISE_JWT_SECRET +# +# See .env.example for the full ENTERPRISE_* block and generation instructions. + +name: hive + +services: + + # ── supabase-db ──────────────────────────────────────────────────────────── + # Postgres 16 with the pgvector extension pre-installed. + # No host port binding: all access is via the internal compose network. + # The init script at deploy/supabase/init/00-extensions.sql runs once on + # first startup and creates the required extensions, schemas, and roles + # (including hive_app which RLS policies reference). + supabase-db: + image: pgvector/pgvector:pg16 + profiles: + - enterprise + environment: + POSTGRES_USER: ${ENTERPRISE_DB_USER:-postgres} + POSTGRES_PASSWORD: ${ENTERPRISE_DB_PASSWORD:?set ENTERPRISE_DB_PASSWORD in .env (openssl rand -base64 24)} + POSTGRES_DB: ${ENTERPRISE_DB_NAME:-postgres} + volumes: + - supabase-db-data:/var/lib/postgresql/data + - ../../deploy/supabase/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${ENTERPRISE_DB_USER:-postgres} -d ${ENTERPRISE_DB_NAME:-postgres}"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s + restart: unless-stopped + + # ── supabase-auth (GoTrue) ───────────────────────────────────────────────── + # Local JWT issuer. edge-api validates tokens against this service via + # SUPABASE_JWKS_URL=http://supabase-auth:9999/.well-known/jwks.json. + # Set ENTERPRISE_MAILER_AUTOCONFIRM=true for air-gapped installs without SMTP. + supabase-auth: + image: supabase/gotrue:v2.170.0 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + environment: + GOTRUE_API_HOST: "0.0.0.0" + GOTRUE_API_PORT: "9999" + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:?set ENTERPRISE_DB_PASSWORD in .env}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}?search_path=auth" + GOTRUE_SITE_URL: ${ENTERPRISE_SITE_URL:-http://localhost:3000} + GOTRUE_URI_ALLOW_LIST: ${ENTERPRISE_REDIRECT_ALLOW_LIST:-} + GOTRUE_DISABLE_SIGNUP: ${ENTERPRISE_DISABLE_SIGNUP:-false} + GOTRUE_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:?set ENTERPRISE_JWT_SECRET in .env (openssl rand -base64 48)} + GOTRUE_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_ISSUER: ${ENTERPRISE_JWT_ISSUER:-http://localhost:9999} + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_MAILER_AUTOCONFIRM: ${ENTERPRISE_MAILER_AUTOCONFIRM:-true} + GOTRUE_SMTP_HOST: ${ENTERPRISE_SMTP_HOST:-} + GOTRUE_SMTP_PORT: ${ENTERPRISE_SMTP_PORT:-587} + GOTRUE_SMTP_USER: ${ENTERPRISE_SMTP_USER:-} + GOTRUE_SMTP_PASS: ${ENTERPRISE_SMTP_PASS:-} + GOTRUE_SMTP_ADMIN_EMAIL: ${ENTERPRISE_SMTP_ADMIN_EMAIL:-admin@example.com} + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9999/health || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s + restart: unless-stopped + + # ── supabase-rest (PostgREST) ────────────────────────────────────────────── + # Auto REST API over local Postgres. control-plane uses this as SUPABASE_URL. + supabase-rest: + image: postgrest/postgrest:v12.2.3 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + environment: + PGRST_DB_URI: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:?set ENTERPRISE_DB_PASSWORD in .env}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" + PGRST_DB_SCHEMAS: "public,storage,graphql_public" + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:?set ENTERPRISE_JWT_SECRET in .env} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:?set ENTERPRISE_JWT_SECRET in .env} + PGRST_APP_SETTINGS_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + + # ── supabase-storage ─────────────────────────────────────────────────────── + # Supabase Storage API with local filesystem backend. + # No MinIO: STORAGE_BACKEND=file writes object data to the supabase-storage-data + # volume. No S3 call leaves the box. + # S3_ENDPOINT for edge-api and control-plane: http://supabase-storage:5000/object/s3 + supabase-storage: + image: supabase/storage-api:v1.11.13 + profiles: + - enterprise + depends_on: + supabase-db: + condition: service_healthy + supabase-rest: + condition: service_healthy + environment: + ANON_KEY: ${ENTERPRISE_ANON_KEY:?set ENTERPRISE_ANON_KEY in .env (sign anon JWT with ENTERPRISE_JWT_SECRET)} + SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:?set ENTERPRISE_SERVICE_ROLE_KEY in .env (sign service_role JWT)} + POSTGREST_URL: http://supabase-rest:3000 + PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:?set ENTERPRISE_JWT_SECRET in .env} + DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:?set ENTERPRISE_DB_PASSWORD in .env}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" + FILE_SIZE_LIMIT: ${ENTERPRISE_STORAGE_FILE_SIZE_LIMIT:-52428800} + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: local + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: "" + volumes: + - supabase-storage-data:/var/lib/storage + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + + # ── supabase-init ────────────────────────────────────────────────────────── + # One-shot container: creates the hive-files and hive-images buckets via the + # Storage API. Runs once per compose up; restart: "no" prevents re-runs. + # Bucket creation is idempotent: a 409 from an existing bucket is treated as + # success (|| true) so re-runs after a container recreate do not fail. + supabase-init: + image: curlimages/curl:8.7.1 + profiles: + - enterprise + depends_on: + supabase-storage: + condition: service_healthy + environment: + SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:?set ENTERPRISE_SERVICE_ROLE_KEY in .env} + entrypoint: + - /bin/sh + - -c + - | + set -e + STORAGE_URL="http://supabase-storage:5000" + AUTH_HEADER="Authorization: Bearer ${SERVICE_KEY}" + for BUCKET in hive-files hive-images; do + echo "Creating bucket: $${BUCKET}" + curl -sf -X POST "$${STORAGE_URL}/bucket" \ + -H "Content-Type: application/json" \ + -H "$${AUTH_HEADER}" \ + -d "{\"id\":\"$${BUCKET}\",\"name\":\"$${BUCKET}\",\"public\":false}" \ + -w "\n[bucket=$${BUCKET} status=%{http_code}]\n" || true + done + echo "Bucket init complete." + restart: "no" + +volumes: + # Postgres WAL and data files. Persists the full schema, all migrations, and + # all tenant data across container recreates. + supabase-db-data: + # Object files written by the Storage API local filesystem backend. + # Holds hive-files and hive-images bucket contents on the box filesystem. + supabase-storage-data: diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 7d0b1a191..cb458b3b4 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -243,176 +243,24 @@ services: # ────────────────────────────────────────────────────────────────────────── # EnterpriseEdge: Self-hosted Supabase data plane (issue #231) # - # These services activate ONLY under the `enterprise` profile. - # The cloud/local/chat profiles continue to use hosted Supabase as before. + # The five Supabase services (supabase-db, supabase-auth, supabase-rest, + # supabase-storage, supabase-init) are defined in the companion override + # file docker-compose.enterprise.yml. They are kept in a separate file + # because Docker Compose evaluates ALL environment variable interpolations + # in ALL services regardless of which profile is active, so :? guards in + # enterprise-only services would break `--profile local` and `--profile cloud` + # config validation for operators who have not set ENTERPRISE_* vars. # - # Stack: - # supabase-db - Postgres 16 with pgvector pre-installed - # supabase-auth - GoTrue (auth server, JWT issuer) - # supabase-rest - PostgREST (auto REST API over Postgres) - # supabase-storage - Supabase Storage API with local filesystem backend - # supabase-init - One-shot init container: creates hive-files and - # hive-images storage buckets via the Storage API. + # To bring up the full enterprise stack: + # docker compose \ + # -f docker-compose.yml \ + # -f docker-compose.enterprise.yml \ + # --profile enterprise up --build # - # No MinIO: Supabase Storage with local filesystem backend eliminates the - # MinIO dependency (AGPL, banned for this product). Object data stays on the - # box under the supabase-storage-data volume. - # - # Network isolation: supabase-db binds only to the internal compose network. - # No Postgres port is exposed on the host interface. + # The installer (scripts/install.sh) passes both -f flags automatically when + # the enterprise profile is selected. # ────────────────────────────────────────────────────────────────────────── - supabase-db: - image: pgvector/pgvector:pg16 - profiles: - - enterprise - environment: - POSTGRES_USER: ${ENTERPRISE_DB_USER:-postgres} - # ENTERPRISE_DB_PASSWORD must be set in .env before running --profile enterprise. - # No default is provided intentionally: Postgres refuses to start with an empty - # password when POSTGRES_PASSWORD is blank, which gives an obvious failure rather - # than a silently insecure deployment. - POSTGRES_PASSWORD: ${ENTERPRISE_DB_PASSWORD:-} - POSTGRES_DB: ${ENTERPRISE_DB_NAME:-postgres} - volumes: - - supabase-db-data:/var/lib/postgresql/data - # Seed the Supabase schema extensions Postgres needs for GoTrue and Storage. - - ../../deploy/supabase/init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${ENTERPRISE_DB_USER:-postgres} -d ${ENTERPRISE_DB_NAME:-postgres}"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 30s - restart: unless-stopped - - supabase-auth: - image: supabase/gotrue:v2.170.0 - profiles: - - enterprise - depends_on: - supabase-db: - condition: service_healthy - environment: - GOTRUE_API_HOST: "0.0.0.0" - GOTRUE_API_PORT: "9999" - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}?search_path=auth" - GOTRUE_SITE_URL: ${ENTERPRISE_SITE_URL:-http://localhost:3000} - GOTRUE_URI_ALLOW_LIST: ${ENTERPRISE_REDIRECT_ALLOW_LIST:-} - GOTRUE_DISABLE_SIGNUP: ${ENTERPRISE_DISABLE_SIGNUP:-false} - GOTRUE_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} - GOTRUE_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_ISSUER: ${ENTERPRISE_JWT_ISSUER:-http://localhost:9999} - GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" - GOTRUE_MAILER_AUTOCONFIRM: ${ENTERPRISE_MAILER_AUTOCONFIRM:-true} - GOTRUE_SMTP_HOST: ${ENTERPRISE_SMTP_HOST:-} - GOTRUE_SMTP_PORT: ${ENTERPRISE_SMTP_PORT:-587} - GOTRUE_SMTP_USER: ${ENTERPRISE_SMTP_USER:-} - GOTRUE_SMTP_PASS: ${ENTERPRISE_SMTP_PASS:-} - GOTRUE_SMTP_ADMIN_EMAIL: ${ENTERPRISE_SMTP_ADMIN_EMAIL:-admin@example.com} - GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify - GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify - GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify - healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9999/health || exit 1"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 30s - restart: unless-stopped - - supabase-rest: - image: postgrest/postgrest:v12.2.3 - profiles: - - enterprise - depends_on: - supabase-db: - condition: service_healthy - environment: - PGRST_DB_URI: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" - PGRST_DB_SCHEMAS: "public,storage,graphql_public" - PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} - PGRST_DB_USE_LEGACY_GUCS: "false" - PGRST_APP_SETTINGS_JWT_SECRET: ${ENTERPRISE_JWT_SECRET} - PGRST_APP_SETTINGS_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} - healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 15s - restart: unless-stopped - - supabase-storage: - image: supabase/storage-api:v1.11.13 - profiles: - - enterprise - depends_on: - supabase-db: - condition: service_healthy - supabase-rest: - condition: service_healthy - environment: - ANON_KEY: ${ENTERPRISE_ANON_KEY:-} - SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:-} - POSTGREST_URL: http://supabase-rest:3000 - PGRST_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:-} - DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:-}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}" - FILE_SIZE_LIMIT: ${ENTERPRISE_STORAGE_FILE_SIZE_LIMIT:-52428800} - STORAGE_BACKEND: file - FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: stub - REGION: local - GLOBAL_S3_BUCKET: stub - ENABLE_IMAGE_TRANSFORMATION: "true" - IMGPROXY_URL: "" - volumes: - - supabase-storage-data:/var/lib/storage - healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 15s - restart: unless-stopped - - # One-shot init container: creates the hive-files and hive-images buckets - # via the Storage API. The restart policy is "no" so it runs exactly once - # per compose up. If the buckets already exist the API returns 200/409 and - # the script exits 0 either way. - supabase-init: - image: curlimages/curl:8.7.1 - profiles: - - enterprise - depends_on: - supabase-storage: - condition: service_healthy - environment: - SERVICE_KEY: ${ENTERPRISE_SERVICE_ROLE_KEY:-} - entrypoint: - - /bin/sh - - -c - - | - set -e - STORAGE_URL="http://supabase-storage:5000" - AUTH_HEADER="Authorization: Bearer ${SERVICE_KEY}" - for BUCKET in hive-files hive-images; do - echo "Creating bucket: $${BUCKET}" - curl -sf -X POST "$${STORAGE_URL}/bucket" \ - -H "Content-Type: application/json" \ - -H "$${AUTH_HEADER}" \ - -d "{\"id\":\"$${BUCKET}\",\"name\":\"$${BUCKET}\",\"public\":false}" \ - -w "\n[bucket=$${BUCKET} status=%{http_code}]\n" || true - done - echo "Bucket init complete." - restart: "no" - # In-stack Redis. Active for local dev, SDK tests, and self-hosted # enterprise deployments. Cloud deployments use managed Upstash via # REDIS_URL and do NOT activate this service. @@ -650,12 +498,5 @@ volumes: # and writes it atomically; litellm reads it at startup and on restart. # Phase 20 Plan 03. litellm-config: - # Enterprise self-hosted Supabase data plane volumes (issue #231). - # Active only when the `enterprise` profile is used. - # supabase-db-data: Postgres WAL and data files. Persists the full schema, - # all migrations, and all tenant data across container recreates. - supabase-db-data: - # supabase-storage-data: object files written by the Storage API local - # filesystem backend. Holds hive-files and hive-images bucket contents. - # No external S3 call; data never leaves the box. - supabase-storage-data: + # supabase-db-data and supabase-storage-data are declared in + # docker-compose.enterprise.yml and only exist when that override is loaded. diff --git a/deploy/supabase/init/00-extensions.sql b/deploy/supabase/init/00-extensions.sql index bead5c55b..16983f0d7 100644 --- a/deploy/supabase/init/00-extensions.sql +++ b/deploy/supabase/init/00-extensions.sql @@ -36,9 +36,19 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_admin') THEN CREATE ROLE supabase_admin NOLOGIN NOINHERIT BYPASSRLS; END IF; + -- hive_app is the application role used by control-plane and edge-api. + -- RLS policies in supabase/migrations/20260529_01_rls_tenant_tables.sql + -- grant full access to this role (NOLOGIN, non-BYPASSRLS so RLS still applies). + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hive_app') THEN + CREATE ROLE hive_app NOLOGIN; + END IF; END $$; +-- Grant hive_app usage on schemas it needs to read and write application data. +GRANT USAGE ON SCHEMA public TO hive_app; +GRANT USAGE ON SCHEMA storage TO hive_app; + -- Grant usage on schemas to application roles GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; GRANT USAGE ON SCHEMA storage TO anon, authenticated, service_role; From f9627286d8dc28213c9a6f6ca046bbd63fa243bf Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 18:17:10 -0400 Subject: [PATCH 3/7] fix: wire enterprise override file into all invocation paths (B3) B3: scripts/install.sh used plain --profile enterprise on four invocations (up, down, monitoring tip, logs tip). The self-hosted Supabase services live in docker-compose.enterprise.yml which was never included, so supabase-db, supabase-auth, supabase-rest, supabase-storage, and supabase-init never started on an enterprise box, leaving the data plane silently absent. All four install.sh invocations now pass both -f flags with absolute paths: -f "$HIVE_HOME/deploy/docker/docker-compose.yml" -f "$HIVE_HOME/deploy/docker/docker-compose.enterprise.yml" CLAUDE.md Getting Started enterprise example updated to match. GitGuardian false positive: the JWT payload JSON fragment in the .env.example comment block (role/iss/iat keys) was triggering the scanner. Replaced with a reference to the Supabase self-host key generator URL. Classification: false positive, no real secret was committed, no rotation needed. Verified: docker compose -f docker-compose.yml -f docker-compose.enterprise.yml --profile enterprise config (with vars): OK docker compose --profile local config: OK docker compose --profile cloud config: OK --- .env.example | 11 +++++------ CLAUDE.md | 5 ++++- scripts/install.sh | 14 ++++++++++---- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 895cc0386..2ad1e5a44 100644 --- a/.env.example +++ b/.env.example @@ -234,12 +234,11 @@ OLLAMA_BASE_URL= # Cloud and local profiles continue to use the hosted Supabase vars above. # # How to generate JWTs (anon + service_role) for a self-hosted GoTrue: -# The JWT payload format is: -# anon: {"role":"anon","iss":"","iat":} -# service_role: {"role":"service_role","iss":"","iat":} -# Sign both with HS256 using ENTERPRISE_JWT_SECRET. -# Helper: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys -# Or use: node -e "require('jsonwebtoken').sign({role:'anon',...},secret,{alg:'HS256'})" +# Use the Supabase self-host key generator at: +# https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys +# Set the JWT secret field to your ENTERPRISE_JWT_SECRET value, then copy +# the generated anon key into ENTERPRISE_ANON_KEY and the service_role key +# into ENTERPRISE_SERVICE_ROLE_KEY. Both are signed HS256 tokens. # # Quick-start secret generation: # ENTERPRISE_DB_PASSWORD: openssl rand -base64 24 diff --git a/CLAUDE.md b/CLAUDE.md index b95f8ccff..53f8756e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,10 @@ docker compose --env-file ../../.env --profile cloud --profile chat up --build # Hive EnterpriseEdge (self-hosted single box): core + in-stack Redis + OWUI + Caddy. # Optional Ollama: set OLLAMA_BASE_URL=http://ollama:11434 in .env and # uncomment the ollama model entries in deploy/litellm/config.yaml. -docker compose --env-file ../../.env --profile enterprise up --build +docker compose \ + -f docker-compose.yml \ + -f docker-compose.enterprise.yml \ + --env-file ../../.env --profile enterprise up --build # Add monitoring to any profile (Prometheus, Grafana, Alertmanager): docker compose --env-file ../../.env --profile local --profile monitoring up --build diff --git a/scripts/install.sh b/scripts/install.sh index 97f29b015..07fcffa4b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -133,7 +133,10 @@ do_uninstall() { status "Stopping Hive EnterpriseEdge stack..." if [ -d "$HIVE_HOME/deploy/docker" ]; then cd "$HIVE_HOME/deploy/docker" - $SUDO docker compose --env-file "$HIVE_HOME/.env" --profile enterprise down 2>/dev/null || true + $SUDO docker compose \ + -f "$HIVE_HOME/deploy/docker/docker-compose.yml" \ + -f "$HIVE_HOME/deploy/docker/docker-compose.enterprise.yml" \ + --env-file "$HIVE_HOME/.env" --profile enterprise down 2>/dev/null || true fi printf '\n' printf '%s>>> Uninstall complete.%s\n' "${GREEN}" "${RESET}" @@ -647,7 +650,10 @@ wait_healthy() { start_stack() { status "Starting Hive EnterpriseEdge stack (docker compose --profile enterprise)..." cd "$HIVE_HOME/deploy/docker" - $SUDO docker compose --env-file "$HIVE_HOME/.env" --profile enterprise up -d --build + $SUDO docker compose \ + -f "$HIVE_HOME/deploy/docker/docker-compose.yml" \ + -f "$HIVE_HOME/deploy/docker/docker-compose.enterprise.yml" \ + --env-file "$HIVE_HOME/.env" --profile enterprise up -d --build success "Stack started." } @@ -673,7 +679,7 @@ verify_and_banner() { printf '\n' printf ' Run with monitoring:\n' printf ' cd %s/deploy/docker\n' "$HIVE_HOME" - printf ' docker compose --env-file %s/.env --profile enterprise --profile monitoring up -d\n' "$HIVE_HOME" + printf ' docker compose -f %s/deploy/docker/docker-compose.yml -f %s/deploy/docker/docker-compose.enterprise.yml --env-file %s/.env --profile enterprise --profile monitoring up -d\n' "$HIVE_HOME" "$HIVE_HOME" "$HIVE_HOME" printf '\n' if [ "$WITH_OLLAMA" = "true" ]; then printf ' Ollama: http://localhost:11434 (in-stack)\n' @@ -698,7 +704,7 @@ verify_and_banner() { printf '%s>>> Some services did not become healthy within the timeout.%s\n' "${RED}" "${RESET}" printf '\nDiagnostics:\n' printf ' cd %s/deploy/docker\n' "$HIVE_HOME" - printf ' docker compose --env-file %s/.env --profile enterprise logs --tail=50\n' "$HIVE_HOME" + printf ' docker compose -f %s/deploy/docker/docker-compose.yml -f %s/deploy/docker/docker-compose.enterprise.yml --env-file %s/.env --profile enterprise logs --tail=50\n' "$HIVE_HOME" "$HIVE_HOME" "$HIVE_HOME" printf '\nCommon causes:\n' printf ' - .env is missing required values (check %s/.env)\n' "$HIVE_HOME" printf ' - Supabase Storage buckets hive-files / hive-images do not exist yet\n' From 3ca5dfa1dc987f12bb793bbb79afbd7f05b9e77f Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 18:27:32 -0400 Subject: [PATCH 4/7] fix: four runtime bugs in enterprise data plane (bugs 1-4) Bug 1: entrypoint used \${SERVICE_KEY} which Compose interpolates at parse time from the host env (empty string). Changed to \$\${SERVICE_KEY} so the container shell expands it at runtime from the injected environment. Bug 2: curl -sf ... || true swallowed 401/500/network errors, making a broken Storage API look healthy. Replaced with explicit HTTP status capture; 2xx and 409 (already exists) succeed, any other status exits 1 so the init container fails visibly instead of silently. Bug 3: edge-api and control-plane could start before supabase-init finished creating buckets, causing startup S3 checks to fail. Added supabase-init: { condition: service_completed_successfully } to the depends_on of both services in the override file only, so --profile local and --profile cloud are unaffected. Bug 4: GOTRUE_JWT_ISSUER defaulted to http://localhost:9999. edge-api validates the iss claim against SUPABASE_JWT_ISSUER (http://supabase-auth:9999 on the enterprise profile), so every minted JWT failed the issuer check. Changed default to http://supabase-auth:9999 to match. Verified: enterprise/local/cloud compose config all exit 0. --- deploy/docker/docker-compose.enterprise.yml | 31 +++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/deploy/docker/docker-compose.enterprise.yml b/deploy/docker/docker-compose.enterprise.yml index a22fcf070..a7e883a1c 100644 --- a/deploy/docker/docker-compose.enterprise.yml +++ b/deploy/docker/docker-compose.enterprise.yml @@ -77,7 +77,9 @@ services: GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_ISSUER: ${ENTERPRISE_JWT_ISSUER:-http://localhost:9999} + # Must match what edge-api expects in SUPABASE_JWT_ISSUER / SUPABASE_JWKS_URL. + # Default is the in-stack service name, not localhost. + GOTRUE_JWT_ISSUER: ${ENTERPRISE_JWT_ISSUER:-http://supabase-auth:9999} GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_MAILER_AUTOCONFIRM: ${ENTERPRISE_MAILER_AUTOCONFIRM:-true} GOTRUE_SMTP_HOST: ${ENTERPRISE_SMTP_HOST:-} @@ -180,18 +182,37 @@ services: - | set -e STORAGE_URL="http://supabase-storage:5000" - AUTH_HEADER="Authorization: Bearer ${SERVICE_KEY}" + # ponytail: $${VAR} escapes compose interpolation; shell expands at runtime. + AUTH_HEADER="Authorization: Bearer $${SERVICE_KEY}" for BUCKET in hive-files hive-images; do echo "Creating bucket: $${BUCKET}" - curl -sf -X POST "$${STORAGE_URL}/bucket" \ + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$${STORAGE_URL}/bucket" \ -H "Content-Type: application/json" \ -H "$${AUTH_HEADER}" \ - -d "{\"id\":\"$${BUCKET}\",\"name\":\"$${BUCKET}\",\"public\":false}" \ - -w "\n[bucket=$${BUCKET} status=%{http_code}]\n" || true + -d "{\"id\":\"$${BUCKET}\",\"name\":\"$${BUCKET}\",\"public\":false}") + case "$${HTTP_STATUS}" in + 2*|409) echo " bucket $${BUCKET}: ok (status $${HTTP_STATUS})" ;; + *) echo " bucket $${BUCKET}: FAILED (status $${HTTP_STATUS})"; exit 1 ;; + esac done echo "Bucket init complete." restart: "no" + # ── depends_on overrides for edge-api and control-plane ─────────────────── + # The base docker-compose.yml already declares required:false stubs for + # supabase-auth and supabase-storage. This override adds supabase-init so + # neither application service starts until buckets exist. Kept here (not in + # the base file) so --profile local and --profile cloud are unaffected. + edge-api: + depends_on: + supabase-init: + condition: service_completed_successfully + + control-plane: + depends_on: + supabase-init: + condition: service_completed_successfully + volumes: # Postgres WAL and data files. Persists the full schema, all migrations, and # all tenant data across container recreates. From e6f2ad94161901039175fa454826888a2f4378bc Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 18:45:31 -0400 Subject: [PATCH 5/7] fix: resolve 6 genuine P1 review findings on enterprise data plane - SUPABASE_URL comment corrected to point at GoTrue (supabase-auth:9999) not PostgREST; control-plane calls GET /auth/v1/user on this URL and PostgREST cannot serve auth/v1 routes. - SUPABASE_JWKS_URL comment documents the HTTPS enforcement in edge-api/cmd/server/main.go and explains the TLS proxy requirement for production; LAN/dev workaround noted inline. - S3_ENDPOINT path corrected from /object/s3 to /storage/v1/s3 to match the actual Supabase Storage S3-compatible endpoint path (confirmed from .env.example line 150 for hosted Supabase). - API_EXTERNAL_URL added to supabase-auth: GoTrue v2 requires this var at startup for email link generation; missing it causes GoTrue to refuse to start. - GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED + URI added to supabase-auth: without this flag GoTrue issues plain tokens with no tenant_id/role claims; every RLS policy and auth middleware check would fail. - PostgREST healthcheck changed from wget (absent from the minimal image) to a bash /dev/tcp TCP probe which works with only the shell available. Compose config: enterprise/local/cloud all exit 0. --- .env.example | 16 +++++++++++++--- deploy/docker/docker-compose.enterprise.yml | 10 +++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 2ad1e5a44..1e32defaf 100644 --- a/.env.example +++ b/.env.example @@ -290,15 +290,25 @@ ENTERPRISE_STORAGE_FILE_SIZE_LIMIT=52428800 # # Uncomment and set these values for the enterprise profile: # -# SUPABASE_URL=http://supabase-rest:3000 +# SUPABASE_URL must point at GoTrue (not PostgREST): control-plane calls +# GET /auth/v1/user on this URL. PostgREST cannot serve auth/v1 routes. +# SUPABASE_URL=http://supabase-auth:9999 # SUPABASE_ANON_KEY= # SUPABASE_SERVICE_ROLE_KEY= # SUPABASE_DB_URL=postgres://postgres:@supabase-db:5432/postgres # SUPABASE_JWT_ISSUER=http://supabase-auth:9999 +# +# JWKS NOTE: edge-api/cmd/server/main.go rejects http:// JWKS URLs as insecure. +# For a production enterprise box, terminate TLS in Caddy and set: +# SUPABASE_JWKS_URL=https:///auth/v1/.well-known/jwks.json +# For a LAN-only or air-gapped dev box where TLS is not available, set +# SUPABASE_JWKS_URL to the internal http URL only after confirming the edge-api +# HTTPS guard is relaxed in config or the service is behind an internal TLS proxy. # SUPABASE_JWKS_URL=http://supabase-auth:9999/.well-known/jwks.json # -# Storage: Supabase Storage API local filesystem backend (no external S3). -# S3_ENDPOINT=http://supabase-storage:5000/object/s3 +# Storage: Supabase Storage API with local filesystem backend (no external S3). +# The S3-compatible endpoint is at /storage/v1/s3 (matches hosted Supabase path). +# S3_ENDPOINT=http://supabase-storage:5000/storage/v1/s3 # S3_ACCESS_KEY= # S3_SECRET_KEY= # S3_REGION=local diff --git a/deploy/docker/docker-compose.enterprise.yml b/deploy/docker/docker-compose.enterprise.yml index a7e883a1c..e027ae96f 100644 --- a/deploy/docker/docker-compose.enterprise.yml +++ b/deploy/docker/docker-compose.enterprise.yml @@ -67,6 +67,8 @@ services: environment: GOTRUE_API_HOST: "0.0.0.0" GOTRUE_API_PORT: "9999" + # Required by GoTrue v2: the public-facing base URL for auth links in emails. + API_EXTERNAL_URL: ${ENTERPRISE_SITE_URL:-http://localhost:9999} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: "postgres://${ENTERPRISE_DB_USER:-postgres}:${ENTERPRISE_DB_PASSWORD:?set ENTERPRISE_DB_PASSWORD in .env}@supabase-db:5432/${ENTERPRISE_DB_NAME:-postgres}?search_path=auth" GOTRUE_SITE_URL: ${ENTERPRISE_SITE_URL:-http://localhost:3000} @@ -91,6 +93,11 @@ services: GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + # Enable the custom access token hook so JWTs include tenant_id, tenants, + # and role claims. Migration 20260516_07 installs public.custom_access_token_hook; + # without this flag GoTrue issues plain tokens and all RLS/authz middleware breaks. + GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" healthcheck: test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9999/health || exit 1"] interval: 5s @@ -117,7 +124,8 @@ services: PGRST_APP_SETTINGS_JWT_SECRET: ${ENTERPRISE_JWT_SECRET:?set ENTERPRISE_JWT_SECRET in .env} PGRST_APP_SETTINGS_JWT_EXP: ${ENTERPRISE_JWT_EXP:-3600} healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"] + # ponytail: postgrest image has no wget/curl; /dev/tcp is a bash builtin TCP probe. + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null || exit 1"] interval: 5s timeout: 3s retries: 10 From bfb8ae5c04d08e489958c046bfbe3b878903b6c7 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 19:11:00 -0400 Subject: [PATCH 6/7] fix: remove enterprise service stubs from base compose (web-console CI fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.yml had required:false depends_on entries for supabase-auth, supabase-storage, and supabase-db. Docker Compose validates all depends_on references at parse time regardless of the required flag — if the named service does not exist in any loaded file the project fails with 'depends on undefined service'. The local+tools profile only loads docker-compose.yml so those enterprise-only services are undefined, breaking the gen-permissions codegen step and cascading to web-console type+unit+build failure. Fix: remove the three stubs. The enterprise ordering is fully handled inside docker-compose.enterprise.yml (supabase-init depends_on chain covers db, auth, rest, storage, init in order; edge-api and control-plane depend on supabase-init via the override). --- deploy/docker/docker-compose.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index cb458b3b4..89144c7be 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -11,14 +11,6 @@ services: depends_on: control-plane: condition: service_healthy - # Enterprise profile: wait for the local auth and storage services before - # edge-api starts so JWKS fetch and S3 bucket checks succeed at boot. - supabase-auth: - condition: service_healthy - required: false - supabase-storage: - condition: service_healthy - required: false volumes: - gomodcache:/go/pkg/mod - gobuildcache:/root/.cache/go-build @@ -169,11 +161,6 @@ services: redis: condition: service_healthy required: false - # Enterprise profile: wait for local Postgres before control-plane starts - # so the DB connection pool succeeds at boot. - supabase-db: - condition: service_healthy - required: false volumes: - gomodcache:/go/pkg/mod - gobuildcache:/root/.cache/go-build From faadf64f5e9a0e10ae8eec86b1e38e94ddab2dff Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Thu, 25 Jun 2026 19:28:42 -0400 Subject: [PATCH 7/7] docs: fix two comment inconsistencies in docker-compose.yml SUPABASE_URL comment: corrected from supabase-rest:3000 to supabase-auth:9999. S3_ENDPOINT comment: corrected path from /object/s3 to /storage/v1/s3. Both now match .env.example and docker-compose.enterprise.yml. --- deploy/docker/docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 89144c7be..939ad5fcf 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -173,9 +173,9 @@ services: - /var/run/docker.sock:/var/run/docker.sock environment: # SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY: - # Enterprise profile: set to the local GoTrue / PostgREST base URL and - # the ENTERPRISE_ANON_KEY / ENTERPRISE_SERVICE_ROLE_KEY values. - # SUPABASE_URL=http://supabase-rest:3000 + # Enterprise profile: SUPABASE_URL must point at GoTrue, not PostgREST. + # control-plane calls GET /auth/v1/user on this URL; PostgREST cannot serve auth routes. + # SUPABASE_URL=http://supabase-auth:9999 # SUPABASE_ANON_KEY= # SUPABASE_SERVICE_ROLE_KEY= # Cloud/local profiles: set these to the hosted Supabase project values. @@ -187,7 +187,7 @@ services: SUPABASE_DB_URL: ${SUPABASE_DB_URL} REDIS_URL: ${REDIS_URL} # Enterprise profile: set to local Storage endpoint: - # S3_ENDPOINT=http://supabase-storage:5000/object/s3 + # S3_ENDPOINT=http://supabase-storage:5000/storage/v1/s3 # S3_ACCESS_KEY= # S3_SECRET_KEY= # S3_REGION=local