From 62cecc8a2b3275e8f9b328be9ee4e892344edc79 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:12:27 +0200 Subject: [PATCH] supabase: the self-hosted stack as one module, its secrets rendered into files ace runs Supabase under HAL as upstream's 13-container compose: a 2.2 GB database (1.8 GB of it the dormant `novox` schema, 5.8 M rows in its largest table), Kong at supabase.zurag.be, the pooler on 5433/6543. This is that stack as a catalogue module, same images (the digests ace runs), same container names so an assignment holds the running ones and a take replaces them. The database stays inside the module. Supabase is a Postgres distribution: its own image with pgsodium, pg_graphql, pg_net, vault and timescale preloaded, a superuser (supabase_admin), a dozen reserved roles and a second database (_supabase). A postgres-database grant - one database, one unprivileged role - cannot hold it, so ace's data directory moves as a copy, not a dump/restore. What HAL did by shell and environment the mesh now renders as files: - kong.yml carries the anon/service keys and the dashboard login (owned by kong's uid 100), instead of an entrypoint that eval'd the environment; - GoTrue reads a dotenv file (auth -c), PostgREST a config file, Vector its yml with the Logflare key in it, the database POSTGRES_PASSWORD_FILE and a jwt.sql rendered with the secret (owned by postgres, uid 105). None of these five containers has a secret in its environment. - realtime, storage, meta, functions, analytics, studio and supavisor read their credentials from the environment only; each declares secrets-in-environment with the reason (ADR 0086). - Upstreams are container names (supabase-db, supabase-kong, ...) instead of compose service names, which the mesh does not have. - SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL are https://${bound:route:name} (mesh-controller #149); on ace HAL rendered them as "https://supabase." - broken today. - Vector reads the docker socket, as upstream does, but now includes only this module's containers instead of every container's logs on the machine. Three things compose did that a declaration cannot, done as steps: a run-once seed copies the image's /etc/postgresql-custom into the placed config directory with cp -n (a named volume did that implicitly; never overwrites the pgsodium root key), and two run-once gates wait for the database and for Logflare, which compose expressed as depends_on: service_healthy. The pooler bootstrap (pooler.exs) takes the tenant id and pool sizes from settings.json, the module's one merge:json file, so ace keeps its tenant "zurag"; and it repoints an existing tenant whose database host is not supabase-db - HAL created ace's with host "db", which no longer resolves. Secrets (vault, requires "secret"): postgres, jwt, anon-key, service-role-key, dashboard-user, dashboard, logflare, pooler-vault, key-base-a + key-base-b (concatenated: Phoenix wants 64+ bytes, a minted secret is 40), openai. Three cannot be minted on any machine: anon-key and service-role-key are JWTs signed with jwt, and pooler-vault must be exactly 32 bytes (AES-256-GCM, found in the bed). They are accepted. On ace every secret the data already knows is accepted (all but key-base-a/b). Not carried: Kong's 8443 and Logflare's 4000 on all interfaces (nothing outside the module uses them); realtime's DB_ENC_KEY stays upstream's constant (realtime deletes and re-seeds that tenant from its environment every start, and the key must be exactly 16 bytes). Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller main and #149 (on main the render is refused for "name", never written empty). The #149 resolution with stub providers, turned into a throwaway stack of all 13 pinned digests with dummy secrets and the rendered files at their owners and modes: the database initialised through the rendered scripts (jwt setting applied, _analytics/_supavisor created, roles' password from POSTGRES_PASSWORD_FILE); through Kong: REST 200 with the anon key and 401 without, auth health and settings 200, storage buckets 200, GraphQL 200, pg-meta 200, an edge function 200, Studio 401 without and 200 with the dashboard login, realtime tenant health 200; the pooler in session and transaction mode as postgres.zurag; a tenant set to host "db" was repointed to supabase-db by the bootstrap and connections worked. --- modules/supabase/module.json | 538 +++++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 modules/supabase/module.json diff --git a/modules/supabase/module.json b/modules/supabase/module.json new file mode 100644 index 0000000..95358cd --- /dev/null +++ b/modules/supabase/module.json @@ -0,0 +1,538 @@ +{ + "module": "supabase", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "api", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the Kong gateway: the REST, auth, storage, realtime, functions and GraphQL APIs under their /\u2026/v1 paths, and Studio at / behind the dashboard password. The one surface people and apps use, reached through the route" + }, + { + "name": "pooler-session", + "port": 5432, + "protocol": "tcp", + "from": "mesh", + "why": "Supavisor's session-mode Postgres port, for clients that connect to the database directly as ." + }, + { + "name": "pooler-transaction", + "port": 6543, + "protocol": "tcp", + "from": "mesh", + "why": "Supavisor's transaction-mode Postgres port" + } + ], + "requires": [ + "route", + "secret" + ], + "contributes": { + "route": { + "label": "supabase", + "endpoint": "api" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "secrets": { + "secret": { + "postgres": "${dir:state}/postgres.secret", + "jwt": "${dir:state}/jwt.secret", + "anon-key": "${dir:state}/anon-key.secret", + "service-role-key": "${dir:state}/service-role-key.secret", + "dashboard-user": "${dir:state}/dashboard-user.secret", + "dashboard": "${dir:state}/dashboard.secret", + "logflare": "${dir:state}/logflare.secret", + "pooler-vault": "${dir:state}/pooler-vault.secret", + "key-base-a": "${dir:state}/key-base-a.secret", + "key-base-b": "${dir:state}/key-base-b.secret", + "openai": "${dir:state}/openai.secret" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "db-init", + "type": "directory", + "path": "${dir:state}/db-init", + "mode": "0755" + }, + { + "id": "functions-main-dir", + "type": "directory", + "path": "${dir:state}/functions-main", + "mode": "0755" + }, + { + "id": "db-data", + "type": "directory", + "mode": "0700", + "owner": "105:106" + }, + { + "id": "db-config", + "type": "directory", + "mode": "0755", + "owner": "105:106" + }, + { + "id": "storage", + "type": "directory", + "mode": "0755" + }, + { + "id": "functions", + "type": "directory", + "mode": "0755" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0644", + "merge": "json", + "content": "{\n \"pooler\": {\n \"tenant\": \"default\",\n \"pool-size\": 20,\n \"max-client-connections\": 100\n }\n}\n" + }, + { + "id": "kong-conf", + "type": "file", + "path": "${dir:state}/kong.yml", + "mode": "0600", + "owner": "100:65533", + "content": "# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in.\n_format_version: '2.1'\n_transform: true\n\n###\n### Consumers / Users\n###\nconsumers:\n - username: DASHBOARD\n - username: anon\n keyauth_credentials:\n - key: \"${secret:anon-key}\"\n - username: service_role\n keyauth_credentials:\n - key: \"${secret:service-role-key}\"\n\n###\n### Access Control List\n###\nacls:\n - consumer: anon\n group: anon\n - consumer: service_role\n group: admin\n\n###\n### Dashboard credentials\n###\nbasicauth_credentials:\n - consumer: DASHBOARD\n username: \"${secret:dashboard-user}\"\n password: \"${secret:dashboard}\"\n\n###\n### API Routes\n###\nservices:\n ## Open Auth routes\n - name: auth-v1-open\n url: http://supabase-auth:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n url: http://supabase-auth:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n url: http://supabase-auth:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n\n ## Secure Auth routes\n - name: auth-v1\n _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*'\n url: http://supabase-auth:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: key-auth\n config:\n hide_credentials: false\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n - anon\n\n ## Secure REST routes\n - name: rest-v1\n _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'\n url: http://supabase-rest:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: key-auth\n config:\n hide_credentials: true\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n - anon\n\n ## Secure GraphQL routes\n - name: graphql-v1\n _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'\n url: http://supabase-rest:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: key-auth\n config:\n hide_credentials: true\n - name: request-transformer\n config:\n add:\n headers:\n - Content-Profile:graphql_public\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n - anon\n\n ## Secure Realtime routes\n - name: realtime-v1-ws\n _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'\n url: http://realtime-dev.supabase-realtime:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: key-auth\n config:\n hide_credentials: false\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n - anon\n - name: realtime-v1-rest\n _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'\n url: http://realtime-dev.supabase-realtime:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: key-auth\n config:\n hide_credentials: false\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n - anon\n ## Storage routes: the storage server manages its own auth\n - name: storage-v1\n _comment: 'Storage: /storage/v1/* -> http://storage:5000/*'\n url: http://supabase-storage:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n\n ## Edge Functions routes\n - name: functions-v1\n _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'\n url: http://supabase-edge-functions:9000/\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n\n ## Analytics routes\n - name: analytics-v1\n _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'\n url: http://supabase-analytics:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n\n ## Secure Database routes\n - name: meta\n _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'\n url: http://supabase-meta:8080/\n routes:\n - name: meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: key-auth\n config:\n hide_credentials: false\n - name: acl\n config:\n hide_groups_header: true\n allow:\n - admin\n\n ## Protected Dashboard - catch all remaining routes\n - name: dashboard\n _comment: 'Studio: /* -> http://studio:3000/*'\n url: http://supabase-studio:3000/\n routes:\n - name: dashboard-all\n strip_path: true\n paths:\n - /\n plugins:\n - name: cors\n - name: basic-auth\n config:\n hide_credentials: true\n" + }, + { + "id": "auth-conf", + "type": "file", + "path": "${dir:state}/gotrue.env", + "mode": "0600", + "owner": "1000:1000", + "content": "GOTRUE_API_HOST=0.0.0.0\nGOTRUE_API_PORT=9999\nAPI_EXTERNAL_URL=https://${bound:route:name}\nGOTRUE_DB_DRIVER=postgres\nGOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres\nGOTRUE_SITE_URL=https://${bound:route:name}\nGOTRUE_URI_ALLOW_LIST=\nGOTRUE_DISABLE_SIGNUP=true\nGOTRUE_JWT_ADMIN_ROLES=service_role\nGOTRUE_JWT_AUD=authenticated\nGOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated\nGOTRUE_JWT_EXP=3600\nGOTRUE_JWT_SECRET=${secret:jwt}\nGOTRUE_EXTERNAL_EMAIL_ENABLED=false\nGOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false\nGOTRUE_MAILER_AUTOCONFIRM=false\nGOTRUE_SMTP_ADMIN_EMAIL=admin@example.com\nGOTRUE_SMTP_HOST=supabase-mail\nGOTRUE_SMTP_PORT=2500\nGOTRUE_SMTP_USER=fake_mail_user\nGOTRUE_SMTP_PASS=fake_mail_password\nGOTRUE_SMTP_SENDER_NAME=fake_sender\nGOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify\nGOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify\nGOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify\nGOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify\nGOTRUE_EXTERNAL_PHONE_ENABLED=false\nGOTRUE_SMS_AUTOCONFIRM=true\n" + }, + { + "id": "rest-conf", + "type": "file", + "path": "${dir:state}/postgrest.conf", + "mode": "0600", + "owner": "1000:1000", + "content": "db-uri = \"postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres\"\ndb-schemas = \"public,storage,graphql_public\"\ndb-anon-role = \"anon\"\njwt-secret = \"${secret:jwt}\"\ndb-use-legacy-gucs = false\napp.settings.jwt_secret = \"${secret:jwt}\"\napp.settings.jwt_exp = \"3600\"\n" + }, + { + "id": "vector-conf", + "type": "file", + "path": "${dir:state}/vector.yml", + "mode": "0600", + "content": "# Written by the mesh (modules/supabase).\napi:\n enabled: true\n address: 0.0.0.0:9001\n\nsources:\n docker_host:\n type: docker_logs\n include_containers:\n - supabase-kong\n - supabase-auth\n - supabase-rest\n - realtime-dev.supabase-realtime\n - supabase-storage\n - supabase-edge-functions\n - supabase-db\n\ntransforms:\n project_logs:\n type: remap\n inputs:\n - docker_host\n source: |-\n .project = \"default\"\n .event_message = del(.message)\n .appname = del(.container_name)\n del(.container_created_at)\n del(.container_id)\n del(.source_type)\n del(.stream)\n del(.label)\n del(.image)\n del(.host)\n del(.stream)\n router:\n type: route\n inputs:\n - project_logs\n route:\n kong: '.appname == \"supabase-kong\"'\n auth: '.appname == \"supabase-auth\"'\n rest: '.appname == \"supabase-rest\"'\n realtime: '.appname == \"realtime-dev.supabase-realtime\"'\n storage: '.appname == \"supabase-storage\"'\n functions: '.appname == \"supabase-edge-functions\"'\n db: '.appname == \"supabase-db\"'\n # Ignores non nginx errors since they are related with kong booting up\n kong_logs:\n type: remap\n inputs:\n - router.kong\n source: |-\n req, err = parse_nginx_log(.event_message, \"combined\")\n if err == null {\n .timestamp = req.timestamp\n .metadata.request.headers.referer = req.referer\n .metadata.request.headers.user_agent = req.agent\n .metadata.request.headers.cf_connecting_ip = req.client\n .metadata.request.method = req.method\n .metadata.request.path = req.path\n .metadata.request.protocol = req.protocol\n .metadata.response.status_code = req.status\n }\n if err != null {\n abort\n }\n # Ignores non nginx errors since they are related with kong booting up\n kong_err:\n type: remap\n inputs:\n - router.kong\n source: |-\n .metadata.request.method = \"GET\"\n .metadata.response.status_code = 200\n parsed, err = parse_nginx_log(.event_message, \"error\")\n if err == null {\n .timestamp = parsed.timestamp\n .severity = parsed.severity\n .metadata.request.host = parsed.host\n .metadata.request.headers.cf_connecting_ip = parsed.client\n url, err = split(parsed.request, \" \")\n if err == null {\n .metadata.request.method = url[0]\n .metadata.request.path = url[1]\n .metadata.request.protocol = url[2]\n }\n }\n if err != null {\n abort\n }\n # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency.\n auth_logs:\n type: remap\n inputs:\n - router.auth\n source: |-\n parsed, err = parse_json(.event_message)\n if err == null {\n .metadata.timestamp = parsed.time\n .metadata = merge!(.metadata, parsed)\n }\n # PostgREST logs are structured so we separate timestamp from message using regex\n rest_logs:\n type: remap\n inputs:\n - router.rest\n source: |-\n parsed, err = parse_regex(.event_message, r'^(?P