Wire schema v1 into a manual Supabase migration and vendor the real stack
- Move db/schema.sql to supabase/migrations/ as the first supabase CLI migration (manual `db push` only, no automated runner); re-add the gebos_ingest role + grants there since init.sql never re-runs - Add gebos-postgres-passwords oneshot on db-host: syncs role passwords from sops (LoadCredential, journal-safe), makes supabase_admin SUPERUSER and hands the auth schema to supabase_auth_admin to match the upstream supabase/postgres image; add pg_hba rule for 10.0.0.0/8 - Vendor the official docker-compose (studio/kong/auth/rest/meta only, external Postgres, loopback Studio with no Kong dashboard route) plus kong.yml (trimmed) and kong-entrypoint.sh (verbatim); tested: compose config, Kong config parse, migration applied on TimescaleDB pg17 - Document decisions as ADRs 0001-0004 (migrations, passwords, vendored stack, JWT API keys incl. verify/mint procedure) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
networking.hostName = "db-host";
|
||||
|
||||
services.gebos.secrets.postgresAdmin = true;
|
||||
# The gebos-postgres-passwords oneshot (gebos-postgres.nix) also syncs the
|
||||
# shared Supabase service-role password and the ingester's password, so
|
||||
# db-host needs those keys rendered into its env file too.
|
||||
services.gebos.secrets.supabase = true;
|
||||
services.gebos.secrets.ingester = true;
|
||||
|
||||
services.gebos.postgres = {
|
||||
enable = true;
|
||||
|
||||
@@ -37,8 +37,98 @@ in
|
||||
# supabase_vault — TODO: package or vendor
|
||||
];
|
||||
# Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor`
|
||||
# schemas, the gebos_ingest role (BYPASSRLS), and telemetry hypertables.
|
||||
# schemas and the Supabase admin/API roles. It only runs at first initdb;
|
||||
# everything else arrives via manual supabase migrations (see
|
||||
# supabase/README.md) and the password oneshot below.
|
||||
initialScript = ../supabase/init.sql;
|
||||
|
||||
# The firewall limits 5432 to 10.0.0.0/8, but NixOS's default pg_hba
|
||||
# only covers local sockets and loopback — without this line every
|
||||
# connection from app-host / mqtt-ingest is rejected before password
|
||||
# auth even starts.
|
||||
authentication = ''
|
||||
host all all 10.0.0.0/8 scram-sha-256
|
||||
'';
|
||||
};
|
||||
|
||||
# Role passwords are data, not configuration: NixOS has no declarative
|
||||
# option for them (anything reachable from the config lands world-readable
|
||||
# in /nix/store, and initialScript only runs at first initdb). This
|
||||
# oneshot re-applies them from the sops-rendered env file on every boot /
|
||||
# deploy, idempotently. It also enforces two attributes the Supabase
|
||||
# services expect because the upstream supabase/postgres image ships them:
|
||||
# supabase_admin is SUPERUSER (Studio/postgres-meta connect as it) and
|
||||
# supabase_auth_admin owns the auth schema (GoTrue runs its own migrations
|
||||
# there on startup).
|
||||
#
|
||||
# After rotating a password in secrets.yaml, re-run with
|
||||
# `systemctl restart gebos-postgres-passwords` (deploys re-run it only
|
||||
# when the unit definition itself changed).
|
||||
systemd.services.gebos-postgres-passwords =
|
||||
lib.mkIf config.services.gebos.secrets.postgresAdmin {
|
||||
description = "Gebos — sync Postgres role passwords from sops secrets";
|
||||
after = [ "postgresql.service" ];
|
||||
requires = [ "postgresql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [ config.services.postgresql.package ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "postgres";
|
||||
# LoadCredential hands the root-owned 0400 env file to the postgres
|
||||
# user without widening its permissions.
|
||||
LoadCredential = "gebos-env:${config.services.gebos.secrets.envFile}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
set -a; . "$CREDENTIALS_DIRECTORY/gebos-env"; set +a
|
||||
|
||||
# All three come from the gebos-env template; the toggles in
|
||||
# db-host.nix (postgresAdmin + supabase + ingester) must be on.
|
||||
# --output=/dev/null: the set_config() SELECTs would otherwise echo
|
||||
# the passwords into the journal.
|
||||
psql -v ON_ERROR_STOP=1 --output=/dev/null \
|
||||
-v admin_pw="''${POSTGRES_ADMIN_PASSWORD:?}" \
|
||||
-v supabase_pw="''${POSTGRES_PASSWORD:?}" \
|
||||
-v ingest_pw="''${GEBOS_POSTGRES_PASSWORD:?}" \
|
||||
--dbname postgres <<'SQL'
|
||||
-- psql :'var' interpolation does not reach inside DO bodies, so
|
||||
-- stash the values in session GUCs first.
|
||||
SELECT set_config('gebos.supabase_pw', :'supabase_pw', false);
|
||||
SELECT set_config('gebos.ingest_pw', :'ingest_pw', false);
|
||||
|
||||
ALTER ROLE postgres PASSWORD :'admin_pw';
|
||||
|
||||
DO $do$
|
||||
DECLARE r text;
|
||||
BEGIN
|
||||
-- One shared password for the roles the compose services log in
|
||||
-- as, mirroring upstream's POSTGRES_PASSWORD convention.
|
||||
FOREACH r IN ARRAY ARRAY['supabase_admin','supabase_auth_admin','authenticator'] LOOP
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = r) THEN
|
||||
EXECUTE format('ALTER ROLE %I PASSWORD %L',
|
||||
r, current_setting('gebos.supabase_pw'));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN
|
||||
EXECUTE 'ALTER ROLE supabase_admin SUPERUSER';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_auth_admin')
|
||||
AND EXISTS (SELECT FROM pg_namespace WHERE nspname = 'auth') THEN
|
||||
EXECUTE 'ALTER SCHEMA auth OWNER TO supabase_auth_admin';
|
||||
END IF;
|
||||
|
||||
-- Created by the schema-v1 migration, so tolerate its absence on
|
||||
-- a cluster the migration has not reached yet.
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gebos_ingest') THEN
|
||||
EXECUTE format('ALTER ROLE gebos_ingest PASSWORD %L',
|
||||
current_setting('gebos.ingest_pw'));
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
SQL
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,10 +30,16 @@ in
|
||||
"d /var/lib/gebos-supabase 0750 root root - -"
|
||||
];
|
||||
|
||||
# Copy the vendored compose file into place at activation time so changes
|
||||
# to nix/supabase/docker-compose.yml are deployed atomically.
|
||||
# Copy the vendored compose bundle into place at activation time so
|
||||
# changes under nix/supabase/ are deployed atomically. Kong's declarative
|
||||
# config is mounted from here by the compose file (relative ./kong.yml
|
||||
# resolves against WorkingDirectory).
|
||||
environment.etc."gebos/supabase/docker-compose.yml".source =
|
||||
"${composeDir}/docker-compose.yml";
|
||||
environment.etc."gebos/supabase/kong.yml".source =
|
||||
"${composeDir}/kong.yml";
|
||||
environment.etc."gebos/supabase/kong-entrypoint.sh".source =
|
||||
"${composeDir}/kong-entrypoint.sh";
|
||||
|
||||
systemd.services.gebos-supabase = {
|
||||
description = "Gebos — Supabase compose stack";
|
||||
@@ -41,6 +47,14 @@ in
|
||||
wants = [ "docker.service" "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
# `docker compose up -d` only recreates containers whose definition
|
||||
# changed, but the unit must re-run for it to notice at all.
|
||||
restartTriggers = [
|
||||
"${composeDir}/docker-compose.yml"
|
||||
"${composeDir}/kong.yml"
|
||||
"${composeDir}/kong-entrypoint.sh"
|
||||
];
|
||||
|
||||
environment = {
|
||||
POSTGRES_HOST = cfg.postgresHost;
|
||||
POSTGRES_PORT = toString cfg.postgresPort;
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# Supabase compose stack (consumed on app-host by gebos-supabase.service)
|
||||
supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str]
|
||||
supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str]
|
||||
supabase_anon_key: ENC[AES256_GCM,data:ReE4g5pNUHqi9M9ejnR88XUjwgaibcgZRM5GCMl+Ds7pzKUY442nqEtc2xVO2vQ=,iv:+lp/a/JshLZfdkxm4uQrgOKxBFmIduLtu3aUAhWrKlE=,tag:ft9swD2SeFJ/jMyL07kmGQ==,type:str]
|
||||
supabase_service_role_key: ENC[AES256_GCM,data:QZQbvJ6rkyoHIpxIvjVDFwxeZ4oIb7Bt2h2vgtW8/sp6swSxtW/oAeVfLkdeduir+2BC5mFAhA==,iv:/F5F3MRC5Yebn68g45F1sj4d18+60JZupneHa3ipExs=,tag:fuzDuSj2npxKQxP2Pz+cjQ==,type:str]
|
||||
supabase_anon_key: ENC[AES256_GCM,data:piGlqQFvF2pVeL/IedL+/PS+5WO2k90zQooNTk9Q9RPUWtkyIYkZxe5kU8OqmharPO6RJbm6Ez5ly2Gk5NyzodQiEUW58wyCK+iXkduUThvtihQS1fh63ijuIPLwzZAdAjRDTY6vYrWWBnUpRELo5A0QCY4SF7KZUkZhCFN0KUHie2PHHzOPVb0koaacqc+K7A2L6Q/Ay0miLG7MXKNDkHsXdXDh9fhP6A==,iv:kq9wti3d1tugHj2p9BPKG42ZIRvvtvU6eGZhh9Ywq1w=,tag:qBMJbFJ1v95uUDWQ9kLb7g==,type:str]
|
||||
supabase_service_role_key: ENC[AES256_GCM,data:jGIHQVni8Cc7trwl89mQzN2p2g3ry3mNHxYtXHBcmSkpHp7AjsuypxS3W4yEdq96nNLCkbFeFdw+0X4vC8JIhHhl0NHbVb1eKOM/k9fL7q/nEWGqJGdMxoOytF/2L/NjQgzIFfK2L+YDZYUPaf6Ve0FKJ/awnRi8Csrof73PY5rtBrgrndbobYHEBlmzjv25l28kT3lwD4kaKW6ThnxUFmSAUFMwUd9maAdWm+47Vb1G4rTN,iv:N+XbGLnWqiDUfRl53KrFkY37kyyLCuU5sSU9aNHBYXI=,tag:qu9koOkum0m0F83/OgGC2g==,type:str]
|
||||
supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str]
|
||||
# Ingester (consumed on mqtt-ingest by gebos-ingester.service)
|
||||
ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str]
|
||||
@@ -71,6 +71,6 @@ sops:
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
|
||||
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_)
|
||||
lastmodified: "2026-07-02T13:11:42Z"
|
||||
mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str]
|
||||
lastmodified: "2026-07-08T20:32:18Z"
|
||||
mac: ENC[AES256_GCM,data:56m8mOIai7eRKBXwcvWa0SLH6pH2LkL0zrRQ3nbfqe2qqn2qiji5dh4fzVVHmAKnXyob9HaRgRGBTnoi0L357MX+rzefSb0RTDXItsHkYSURYDZnRegEtszPpE5tlff/AlxtLXjs43P6nErkE263gzoFD2EFBwVlH6aufcX3fRo=,iv:eeGYKLRJqDq/ODHeyb6Ucn/K/drFNEJzKZGuAMEGGAU=,tag:qNo4uY4PV7C4MsYThvol3w==,type:str]
|
||||
version: 3.13.1
|
||||
|
||||
+50
-24
@@ -1,40 +1,66 @@
|
||||
# Supabase compose stack
|
||||
|
||||
Vendored from the official Supabase self-hosting bundle, with two deliberate
|
||||
changes:
|
||||
Vendored from the official self-hosting bundle
|
||||
(`docker/docker-compose.yml` in supabase/supabase), with deliberate changes:
|
||||
|
||||
1. The bundled `db` service is removed. Every service that talks to Postgres
|
||||
reads `POSTGRES_HOST` / `POSTGRES_PORT` from the environment and connects to
|
||||
the external `db-host`. The systemd unit (`nix/modules/gebos-supabase.nix`)
|
||||
injects these from `/etc/gebos/secrets.env`.
|
||||
1. **No `db` service.** Every service reads `POSTGRES_HOST` / `POSTGRES_PORT`
|
||||
from the environment and connects to the external `db-host`. The systemd
|
||||
unit (`nix/modules/gebos-supabase.nix`) injects these; secrets come from
|
||||
the sops-rendered env file (see `gebos-secrets.nix`).
|
||||
|
||||
2. Studio is bound to `127.0.0.1` only. There is **no public route** to it.
|
||||
To use it from your laptop:
|
||||
2. **Only auth + rest + studio (+ kong, meta).** Realtime, storage, imgproxy,
|
||||
edge functions, supavisor and the Logflare analytics stack are not
|
||||
vendored — re-add from upstream when actually needed.
|
||||
|
||||
3. **Studio is bound to `127.0.0.1` only, with no Kong dashboard route.**
|
||||
Upstream protects Studio with Kong basic-auth on a catch-all `/` route; we
|
||||
drop that route entirely (`kong.yml` here), so there is **no public route**
|
||||
to Studio and the api.gebos.online surface is only `/auth/v1`, `/rest/v1`,
|
||||
`/graphql/v1` and the two `.well-known` endpoints. To use Studio:
|
||||
|
||||
```
|
||||
ssh -L 3000:127.0.0.1:3000 app-host
|
||||
ssh -L 3000:127.0.0.1:3000 deploy@app.gebos.online
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
This is intentional — Studio runs with the `service_role` JWT and bypasses
|
||||
RLS. Putting it on the public internet behind only HTTP basic auth (the
|
||||
default) is too thin.
|
||||
Studio itself has no login in this topology — possession of the SSH key is
|
||||
the credential. (`supabase_dashboard_password` in secrets.yaml is currently
|
||||
unused; it becomes relevant only if Studio ever gets fronted by basic
|
||||
auth again.)
|
||||
|
||||
4. **meta connects as `supabase_admin`, not `postgres`.** In the upstream
|
||||
image `postgres`/`supabase_admin` is the superuser; on db-host the
|
||||
`postgres` superuser keeps its own password (`postgres_admin_password`),
|
||||
and the `gebos-postgres-passwords` oneshot makes `supabase_admin`
|
||||
SUPERUSER with the shared `POSTGRES_PASSWORD` to match upstream semantics.
|
||||
|
||||
## What's in here
|
||||
|
||||
- `docker-compose.yml` — the stack itself
|
||||
- `init.sql` — schemas, roles, and extensions Postgres needs before the
|
||||
compose services come up. Loaded by `nix/modules/gebos-postgres.nix`.
|
||||
- `kong.yml` — Kong's declarative routing (to be vendored alongside the compose
|
||||
file when we copy it in).
|
||||
- `init.sql` — Supabase schemas, roles, and extensions Postgres needs before
|
||||
the compose services come up. Runs once at first initdb via
|
||||
`nix/modules/gebos-postgres.nix`. Application schema lives in
|
||||
`supabase/migrations/` (applied manually — see `supabase/README.md`).
|
||||
- `kong.yml` — Kong's declarative routing, trimmed to the services we run
|
||||
- `kong-entrypoint.sh` — upstream helper, verbatim: substitutes `$VARS` into
|
||||
kong.yml (Kong has no native env interpolation) and builds the
|
||||
request-transformer Lua expressions
|
||||
|
||||
## Passwords
|
||||
|
||||
No service role has a password in any SQL file. The
|
||||
`gebos-postgres-passwords` oneshot on db-host (see `gebos-postgres.nix`)
|
||||
syncs them from sops on every boot/deploy:
|
||||
|
||||
| role | secret |
|
||||
|-----------------------------------------------------|--------|
|
||||
| `postgres` | `postgres_admin_password` |
|
||||
| `supabase_admin`, `supabase_auth_admin`, `authenticator` | `supabase_postgres_password` (= `POSTGRES_PASSWORD` in the compose env) |
|
||||
| `gebos_ingest` | `ingester_postgres_password` |
|
||||
|
||||
## Updating the vendored compose
|
||||
|
||||
When upstream Supabase ships a new compose layout, re-vendor by:
|
||||
|
||||
1. Copy upstream `docker/docker-compose.yml` over `docker-compose.yml`.
|
||||
2. Remove the `db:` service block.
|
||||
3. Replace every `db:5432` / `postgres:5432` reference with
|
||||
`${POSTGRES_HOST}:${POSTGRES_PORT}`.
|
||||
4. Bind Studio to `${STUDIO_BIND}:3000` instead of `0.0.0.0:3000`.
|
||||
5. Commit, deploy, smoke-test through the SSH tunnel.
|
||||
When upstream ships a new layout, re-vendor by copying the upstream files and
|
||||
re-applying the deviations listed at the top of `docker-compose.yml` and
|
||||
`kong.yml` (both carry the list in their header comments). Then commit,
|
||||
deploy, and smoke-test Studio through the SSH tunnel.
|
||||
|
||||
+179
-35
@@ -1,65 +1,209 @@
|
||||
# Gebos — vendored Supabase compose stack.
|
||||
#
|
||||
# TODO: copy the full official compose from
|
||||
# Vendored from the official self-hosting bundle
|
||||
# https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml
|
||||
# and apply the three modifications described in ./README.md:
|
||||
# - drop the `db` service
|
||||
# - replace `db:5432` / `postgres:5432` with ${POSTGRES_HOST}:${POSTGRES_PORT}
|
||||
# - bind Studio to ${STUDIO_BIND}:3000 (default 127.0.0.1)
|
||||
# with the deliberate deviations documented in ./README.md:
|
||||
#
|
||||
# The skeleton below names the services we'll keep and the env vars each one
|
||||
# reads, so reviewers can sanity-check the topology before the real compose
|
||||
# lands.
|
||||
# * no `db` service — Postgres runs on db-host; every service connects to
|
||||
# ${POSTGRES_HOST}:${POSTGRES_PORT} and the `depends_on: db` blocks are
|
||||
# dropped
|
||||
# * no realtime / storage / imgproxy / functions / supavisor / analytics —
|
||||
# the pilot needs auth + rest + studio only; re-vendor from upstream when
|
||||
# one of them becomes needed
|
||||
# * Studio binds to ${STUDIO_BIND}:3000 (loopback; reach it via SSH tunnel)
|
||||
# * meta connects as supabase_admin, not `postgres`: upstream's `postgres`
|
||||
# is its cluster superuser, but on db-host that role carries a different
|
||||
# password. gebos-postgres-passwords makes supabase_admin SUPERUSER to
|
||||
# match the upstream image's semantics.
|
||||
#
|
||||
# Where variables come from (see nix/modules/gebos-supabase.nix):
|
||||
# * POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB / STUDIO_BIND — systemd
|
||||
# unit `environment` (non-secret deployment config, nix-managed)
|
||||
# * POSTGRES_PASSWORD / JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY —
|
||||
# sops-rendered EnvironmentFile (secrets)
|
||||
# * gebos constants (public URLs, org name) — hardcoded right here
|
||||
|
||||
name: gebos-supabase
|
||||
|
||||
services:
|
||||
kong:
|
||||
image: kong:2.8.1
|
||||
|
||||
studio:
|
||||
container_name: supabase-studio
|
||||
image: supabase/studio:2026.07.07-sha-a6a04f2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000/tcp" # Caddy proxies api.gebos.online here
|
||||
# Never 0.0.0.0 — Studio performs no authentication of its own in this
|
||||
# topology (the upstream basic-auth lives on Kong's dashboard route,
|
||||
# which we do not expose). Access = SSH tunnel to app-host.
|
||||
- ${STUDIO_BIND}:3000:3000/tcp
|
||||
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:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USER_READ_WRITE: supabase_admin
|
||||
|
||||
PGRST_DB_SCHEMAS: public
|
||||
|
||||
DEFAULT_ORGANIZATION_NAME: Gebos
|
||||
DEFAULT_PROJECT_NAME: Gebos
|
||||
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_PUBLIC_URL: https://api.gebos.online
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
# No Logflare/analytics stack in this deployment.
|
||||
ENABLED_FEATURES_LOGS_ALL: "false"
|
||||
|
||||
kong:
|
||||
container_name: supabase-kong
|
||||
image: kong/kong:3.9.1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000/tcp # Caddy proxies api.gebos.online here
|
||||
healthcheck:
|
||||
test: ["CMD", "kong", "health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
# The entrypoint substitutes $ENV_VARS in temp.yml and writes the result
|
||||
# to KONG_DECLARATIVE_CONFIG (Kong has no native env interpolation).
|
||||
- ./kong.yml:/home/kong/temp.yml:ro
|
||||
- ./kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro
|
||||
environment:
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
||||
volumes:
|
||||
- ./kong.yml:/home/kong/kong.yml:ro
|
||||
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
|
||||
KONG_ROUTER_FLAVOR: expressions
|
||||
# https://github.com/supabase/cli/issues/14
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_DNS_NOT_FOUND_TTL: 1
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
# Opaque sb_* API keys — not used yet. Empty values make the entrypoint
|
||||
# fall back to legacy apikey pass-through and strip the empty
|
||||
# credentials from the declarative config.
|
||||
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:-}
|
||||
entrypoint: ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
|
||||
|
||||
auth:
|
||||
image: supabase/gotrue:v2.158.1
|
||||
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
|
||||
environment:
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
GOTRUE_SITE_URL: https://app.gebos.online
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
GOTRUE_JWT_EXP: "3600"
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: https://api.gebos.online
|
||||
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
# GoTrue owns the auth schema and runs its own migrations in it on
|
||||
# startup — supabase_auth_admin's ownership of that schema is enforced
|
||||
# by the gebos-postgres-passwords oneshot on db-host.
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
|
||||
GOTRUE_SITE_URL: https://app.gebos.online
|
||||
GOTRUE_URI_ALLOW_LIST: ""
|
||||
# Tenants are invited by an admin (magic link), never self-signed-up.
|
||||
GOTRUE_DISABLE_SIGNUP: "true"
|
||||
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: "3600"
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
GOTRUE_JWT_ISSUER: https://api.gebos.online
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false"
|
||||
GOTRUE_MAILER_AUTOCONFIRM: "false"
|
||||
|
||||
# TODO: no SMTP provider configured yet. Magic links (UVI-NFR-08,
|
||||
# passwordless-only) cannot send until these are filled in — add the
|
||||
# SMTP secrets to nix/secrets/secrets.yaml and wire them through
|
||||
# gebos-secrets.nix like the other supabase keys.
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-}
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Gebos}
|
||||
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
|
||||
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
|
||||
GOTRUE_SMS_AUTOCONFIRM: "false"
|
||||
|
||||
rest:
|
||||
image: postgrest/postgrest:v12.0.2
|
||||
container_name: supabase-rest
|
||||
image: postgrest/postgrest:v14.12
|
||||
restart: unless-stopped
|
||||
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: public
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_MAX_ROWS: 1000
|
||||
PGRST_DB_EXTRA_SEARCH_PATH: public
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
|
||||
studio:
|
||||
image: supabase/studio:20240326-5e5586d
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STUDIO_BIND}:3000:3000/tcp" # never bind to 0.0.0.0
|
||||
environment:
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DEFAULT_ORGANIZATION_NAME: Gebos
|
||||
SUPABASE_URL: https://api.gebos.online
|
||||
DASHBOARD_USERNAME: gebos
|
||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
PGRST_ADMIN_SERVER_PORT: 3001
|
||||
PGRST_ADMIN_SERVER_HOST: localhost
|
||||
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: "3600"
|
||||
command:
|
||||
[
|
||||
"postgrest"
|
||||
]
|
||||
|
||||
meta:
|
||||
image: supabase/postgres-meta:v0.83.2
|
||||
container_name: supabase-meta
|
||||
image: supabase/postgres-meta:v0.96.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: ${POSTGRES_HOST}
|
||||
PG_META_DB_PORT: ${POSTGRES_PORT}
|
||||
PG_META_DB_NAME: ${POSTGRES_DB}
|
||||
|
||||
+1
-32
@@ -47,35 +47,4 @@ exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
create role supabase_auth_admin login createrole;
|
||||
grant usage on schema auth to supabase_auth_admin;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- Ingester writes telemetry; bypasses RLS because at write time no user is
|
||||
-- authenticated. Trust boundary is the broker ACL ("who can publish to what
|
||||
-- tenant's topic"), not the database.
|
||||
do $$ begin
|
||||
create role gebos_ingest login bypassrls;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- Telemetry hypertable. Tenant lives on the row so standard RLS works.
|
||||
create table if not exists public.telemetry (
|
||||
ts timestamptz not null,
|
||||
tenant_id uuid not null,
|
||||
device_id uuid not null,
|
||||
metric text not null,
|
||||
value double precision,
|
||||
payload jsonb
|
||||
);
|
||||
|
||||
select create_hypertable('public.telemetry', 'ts', if_not_exists => true);
|
||||
|
||||
create index if not exists telemetry_tenant_device_ts_idx
|
||||
on public.telemetry (tenant_id, device_id, ts desc);
|
||||
|
||||
alter table public.telemetry enable row level security;
|
||||
|
||||
create policy telemetry_tenant_isolation on public.telemetry
|
||||
for select
|
||||
using (tenant_id = current_setting('app.tenant_id', true)::uuid);
|
||||
|
||||
grant select on public.telemetry to authenticated;
|
||||
grant insert on public.telemetry to gebos_ingest;
|
||||
exception when duplicate_object then null; end $$;
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
|
||||
# and performs environment variable substitution in the declarative config.
|
||||
|
||||
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
|
||||
# When opaque keys are not configured (empty env vars), expressions fall through
|
||||
# to legacy-only behavior - just passing apikey as-is.
|
||||
#
|
||||
# Full expression logic (when opaque keys are configured):
|
||||
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
|
||||
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
|
||||
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
|
||||
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
|
||||
|
||||
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
# Opaque keys configured -> full translation expressions
|
||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
|
||||
|
||||
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
|
||||
# via query string), outputs to x-api-key header which Realtime checks first.
|
||||
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
|
||||
else
|
||||
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
|
||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
|
||||
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
|
||||
fi
|
||||
|
||||
# Substitute environment variables in the Kong declarative config.
|
||||
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
|
||||
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
|
||||
awk '{
|
||||
result = ""
|
||||
rest = $0
|
||||
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
|
||||
varname = substr(rest, RSTART + 1, RLENGTH - 1)
|
||||
if (varname in ENVIRON) {
|
||||
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
|
||||
} else {
|
||||
result = result substr(rest, 1, RSTART + RLENGTH - 1)
|
||||
}
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print result rest
|
||||
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
|
||||
|
||||
# Remove empty key-auth credentials (unconfigured opaque keys)
|
||||
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
|
||||
|
||||
exec /entrypoint.sh kong docker-start
|
||||
+176
-18
@@ -1,48 +1,206 @@
|
||||
_format_version: "2.1"
|
||||
_format_version: '2.1'
|
||||
_transform: true
|
||||
|
||||
# Declarative Kong config — vanilla Supabase routing, no surprises.
|
||||
# Lives at /home/kong/kong.yml inside the container.
|
||||
# Vendored from the official bundle (docker/volumes/api/kong.yml), with the
|
||||
# routes for services we don't run removed: realtime, storage, functions,
|
||||
# analytics, pg-meta (/pg/), MCP, and — deliberately — the catch-all
|
||||
# dashboard route + DASHBOARD basic-auth consumer. Studio is loopback-only
|
||||
# behind an SSH tunnel and must never be reachable via api.gebos.online.
|
||||
#
|
||||
# $VARS are substituted by kong-entrypoint.sh at container start; empty
|
||||
# credential lines (unconfigured opaque keys) are stripped there too.
|
||||
|
||||
###
|
||||
### Consumers / Users
|
||||
###
|
||||
consumers:
|
||||
- username: anon
|
||||
keyauth_credentials:
|
||||
- key: ${ANON_KEY}
|
||||
- key: $SUPABASE_ANON_KEY
|
||||
- key: $SUPABASE_PUBLISHABLE_KEY
|
||||
- username: service_role
|
||||
keyauth_credentials:
|
||||
- key: ${SERVICE_ROLE_KEY}
|
||||
- key: $SUPABASE_SERVICE_KEY
|
||||
- key: $SUPABASE_SECRET_KEY
|
||||
|
||||
###
|
||||
### Access Control List
|
||||
###
|
||||
acls:
|
||||
- consumer: anon
|
||||
group: anon
|
||||
- consumer: service_role
|
||||
group: admin
|
||||
|
||||
###
|
||||
### API Routes
|
||||
###
|
||||
services:
|
||||
## Open Auth routes
|
||||
- name: auth-v1-open
|
||||
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
|
||||
url: http://auth:9999/verify
|
||||
routes:
|
||||
- name: auth-v1-open
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/verify
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-callback
|
||||
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
|
||||
url: http://auth:9999/callback
|
||||
routes:
|
||||
- name: auth-v1-open-callback
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/callback
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-authorize
|
||||
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
|
||||
url: http://auth:9999/authorize
|
||||
routes:
|
||||
- name: auth-v1-open-authorize
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/authorize
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-jwks
|
||||
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
|
||||
url: http://auth:9999/.well-known/jwks.json
|
||||
routes:
|
||||
- name: auth-v1-open-jwks
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/.well-known/jwks.json
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
## Secure Auth routes
|
||||
- name: auth-v1
|
||||
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
|
||||
url: http://auth:9999/
|
||||
routes:
|
||||
- name: auth-v1-route
|
||||
- name: auth-v1-all
|
||||
strip_path: true
|
||||
paths: [ /auth/v1/ ]
|
||||
paths:
|
||||
- /auth/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
- name: rest-v1
|
||||
## OpenAPI root - admin only
|
||||
- name: rest-v1-openapi
|
||||
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
|
||||
url: http://rest:3000/
|
||||
routes:
|
||||
- name: rest-v1-route
|
||||
- name: rest-v1-openapi-root
|
||||
strip_path: true
|
||||
paths: [ /rest/v1/ ]
|
||||
expression: 'http.path == "/rest/v1/"'
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: true
|
||||
key_names: [ apikey ]
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
|
||||
- name: rpc
|
||||
url: http://rest:3000/rpc/
|
||||
## Secure PostgREST routes
|
||||
- name: rest-v1
|
||||
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
|
||||
url: http://rest:3000/
|
||||
routes:
|
||||
- name: rpc-route
|
||||
- name: rest-v1-all
|
||||
strip_path: true
|
||||
paths: [ /rpc/ ]
|
||||
paths:
|
||||
- /rest/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: true
|
||||
key_names: [ apikey ]
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Secure GraphQL routes
|
||||
- name: graphql-v1
|
||||
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
|
||||
url: http://rest:3000/rpc/graphql
|
||||
routes:
|
||||
- name: graphql-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /graphql/v1
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Content-Profile: graphql_public"
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
- name: well-known-oauth
|
||||
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
|
||||
url: http://auth:9999/.well-known/oauth-authorization-server
|
||||
routes:
|
||||
- name: well-known-oauth
|
||||
strip_path: true
|
||||
paths:
|
||||
- /.well-known/oauth-authorization-server
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
Reference in New Issue
Block a user