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:
@@ -0,0 +1,51 @@
|
||||
# ADR-0001: Database schema changes are manual Supabase CLI migrations
|
||||
|
||||
Date: 2026-07-08
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Schema v1 (`db/schema.sql`, ~600 lines) was committed but wired to nothing.
|
||||
The only SQL that ran automatically was `nix/supabase/init.sql` via
|
||||
`services.postgresql.initialScript` — which executes **only at first initdb**,
|
||||
so on the already-initialized db-host cluster neither it nor any schema change
|
||||
would ever apply again. A delivery mechanism was needed.
|
||||
|
||||
Options considered:
|
||||
|
||||
1. **Automated runner** — a systemd oneshot on db-host applying migrations on
|
||||
every deploy (dbmate/atlas or plain psql with a tracking table).
|
||||
2. **Manual Supabase CLI migrations** — plain-SQL files in
|
||||
`supabase/migrations/`, applied by a human with `supabase db push`.
|
||||
3. **Extending initialScript** — rejected outright: never re-runs on an
|
||||
existing cluster.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2. Schema changes are rare, deliberate, admin-level operations; a
|
||||
human applies them on purpose, and no automation exists to break or to apply
|
||||
a half-reviewed migration as a side effect of a deploy.
|
||||
|
||||
* Migrations live in `supabase/migrations/<timestamp>_<name>.sql`; schema v1
|
||||
moved there as the first one. `supabase/config.toml` is the minimal CLI
|
||||
project marker.
|
||||
* Applied via SSH tunnel to db-host as the `postgres` superuser
|
||||
(see `supabase/README.md` for the exact commands). The CLI records applied
|
||||
files in `supabase_migrations.schema_migrations`, so pushes are incremental
|
||||
and re-running is safe. Individual migrations need **not** be idempotent.
|
||||
* Layering rule: `nix/supabase/init.sql` holds only cluster bootstrap
|
||||
(Supabase schemas, admin/API roles, extensions needing
|
||||
`shared_preload_libraries`); everything else — including the `gebos_ingest`
|
||||
role and its grants, which are coupled to application tables and must reach
|
||||
existing clusters — lives in migrations. Passwords live in neither
|
||||
(see ADR-0002).
|
||||
|
||||
## Consequences
|
||||
|
||||
* Deploys (`deploy-rs`) never touch the schema; a deploy and a migration are
|
||||
two separate, independently reversible acts.
|
||||
* A human must remember to push after merging a migration. Accepted for a
|
||||
pilot with one operator.
|
||||
* The local `nix run .#dev` Postgres has no TimescaleDB yet, so the v1
|
||||
migration's `create_hypertable` fails there until the dev stack gains the
|
||||
extension.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-0002: Postgres role passwords are synced by a one-shot systemd unit
|
||||
|
||||
Date: 2026-07-08
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Supabase services on app-host and the ingester on mqtt-ingest connect to
|
||||
db-host over TCP and need password auth, but every login role
|
||||
(`supabase_admin`, `supabase_auth_admin`, `authenticator`, `gebos_ingest`)
|
||||
was created passwordless.
|
||||
|
||||
NixOS deliberately offers **no declarative option** for Postgres passwords:
|
||||
anything the configuration references lands world-readable in `/nix/store`,
|
||||
and `initialScript` — the only built-in hook that could set one — runs solely
|
||||
at first initdb. Passwords are data, not configuration.
|
||||
|
||||
Two supporting gaps surfaced at the same time:
|
||||
|
||||
* NixOS's default `pg_hba.conf` covers only local sockets and loopback, so
|
||||
connections from 10.0.0.0/8 were rejected before password auth even
|
||||
started, firewall rule notwithstanding.
|
||||
* The upstream `supabase/postgres` image ships role *attributes* our vanilla
|
||||
Postgres lacked: `supabase_admin` is a SUPERUSER there (Studio/postgres-meta
|
||||
connect as it), and `supabase_auth_admin` owns the `auth` schema (GoTrue
|
||||
runs its own migrations in it at startup).
|
||||
|
||||
## Decision
|
||||
|
||||
A oneshot unit, `gebos-postgres-passwords` (`nix/modules/gebos-postgres.nix`),
|
||||
runs on db-host after `postgresql.service` on every boot/deploy:
|
||||
|
||||
* Runs as the `postgres` OS user (local peer auth); reads the sops-rendered
|
||||
env file via systemd `LoadCredential`, so the root-owned 0400 file needs no
|
||||
permission widening.
|
||||
* Idempotently syncs: `postgres` ← `postgres_admin_password`;
|
||||
`supabase_admin` / `supabase_auth_admin` / `authenticator` ← the shared
|
||||
`supabase_postgres_password` (mirroring upstream's single
|
||||
`POSTGRES_PASSWORD` convention); `gebos_ingest` ←
|
||||
`ingester_postgres_password`.
|
||||
* Enforces the two upstream attributes: `supabase_admin SUPERUSER` and
|
||||
`ALTER SCHEMA auth OWNER TO supabase_auth_admin`.
|
||||
* Guards every role for existence (tolerates a cluster the schema-v1
|
||||
migration hasn't reached) and discards query output
|
||||
(`--output=/dev/null`) so passwords never reach the journal.
|
||||
* db-host enables the `supabase` and `ingester` secret toggles solely to get
|
||||
those keys into its env file; `.sops.yaml`'s single key group already
|
||||
permits this.
|
||||
* `pg_hba` gains `host all all 10.0.0.0/8 scram-sha-256`.
|
||||
|
||||
## Consequences
|
||||
|
||||
* No password ever exists in git, the nix store, or SQL files; rotation is
|
||||
`sops nix/secrets/secrets.yaml` + deploy + `systemctl restart
|
||||
gebos-postgres-passwords` (the unit does not auto-rerun on secret-content
|
||||
changes alone).
|
||||
* The three Supabase roles share one password by design — per-role passwords
|
||||
would diverge from the vendored compose, which interpolates a single
|
||||
`${POSTGRES_PASSWORD}` everywhere.
|
||||
* db-host can decrypt the Supabase JWT/API-key secrets it doesn't strictly
|
||||
need; acceptable until `.sops.yaml` moves to per-prefix rules (noted there).
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-0003: Vendor a trimmed Supabase compose stack against external Postgres
|
||||
|
||||
Date: 2026-07-08
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Supabase stack on app-host was a placeholder skeleton. The official
|
||||
self-hosting bundle assumes its own bundled `db` container and ships nine+
|
||||
services (realtime, storage, imgproxy, edge functions, supavisor pooler,
|
||||
Logflare analytics, …), most of which the UVI pilot does not use. Meanwhile
|
||||
our Postgres lives on a separate NixOS host (db-host) with TimescaleDB.
|
||||
|
||||
Kong deserves a note, since "why is it even there" came up: Kong is the
|
||||
single front door for `api.gebos.online` (Caddy → Kong on loopback :8000).
|
||||
It routes paths to internal services (`/auth/v1` → GoTrue, `/rest/v1` →
|
||||
PostgREST) and enforces the perimeter (key-auth + ACLs), so unauthenticated
|
||||
internet traffic never reaches the backends. Replacing it would mean
|
||||
re-implementing routing/auth/CORS in Caddy by hand instead of inheriting
|
||||
Supabase's tested config.
|
||||
|
||||
## Decision
|
||||
|
||||
Vendor the upstream `docker/docker-compose.yml` (and `kong.yml` +
|
||||
`kong-entrypoint.sh`) into `nix/supabase/`, keeping only
|
||||
**studio, kong, auth (GoTrue), rest (PostgREST), meta (postgres-meta)**, with
|
||||
these deviations — each also documented in the files' header comments:
|
||||
|
||||
1. **No `db` service.** Everything connects to
|
||||
`${POSTGRES_HOST}:${POSTGRES_PORT}` (db-host); `depends_on: db` dropped.
|
||||
2. **No realtime / storage / imgproxy / functions / supavisor / analytics.**
|
||||
Re-vendor from upstream when actually needed.
|
||||
3. **Studio binds to loopback only, and Kong's catch-all dashboard route is
|
||||
removed.** Upstream fronts Studio with Kong basic-auth on `/`; we expose
|
||||
no route to it at all. Access is `ssh -L 3000:127.0.0.1:3000` — possession
|
||||
of the SSH key is the credential. `supabase_dashboard_password` is
|
||||
therefore currently unused. The public API surface is exactly
|
||||
`/auth/v1`, `/rest/v1`, `/graphql/v1` and two `.well-known` endpoints.
|
||||
4. **meta connects as `supabase_admin`, not `postgres`.** Upstream's
|
||||
`postgres` role *is* its cluster superuser; on db-host `postgres` keeps a
|
||||
separate admin password, so `supabase_admin` (made SUPERUSER by ADR-0002)
|
||||
fills that role with the shared service password.
|
||||
5. **`kong-entrypoint.sh` is vendored byte-identical.** Kong's declarative
|
||||
YAML cannot read env vars, so the entrypoint substitutes `$VARS` (the API
|
||||
keys) into the config at container start; keeping it unmodified makes
|
||||
re-vendoring a plain copy.
|
||||
|
||||
Variable layering (the answer to "where does `POSTGRES_HOST` come from"):
|
||||
|
||||
* non-secret deployment config (`POSTGRES_HOST/PORT/DB`, `STUDIO_BIND`) —
|
||||
systemd unit `environment` in `gebos-supabase.nix`, fed by module options;
|
||||
* secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`,
|
||||
`SERVICE_ROLE_KEY`) — sops-rendered `EnvironmentFile`;
|
||||
* gebos constants (public URLs, org name) — hardcoded in the vendored
|
||||
compose.
|
||||
|
||||
## Consequences
|
||||
|
||||
* Deliberately small attack surface and dependency set; Studio reachable only
|
||||
through SSH.
|
||||
* Upstream updates are a re-copy plus re-applying the five listed deviations;
|
||||
all drift is concentrated in `docker-compose.yml` and `kong.yml` headers.
|
||||
* GoTrue has **no SMTP configured yet** — magic-link login cannot send email
|
||||
until the `SMTP_*` secrets are added (TODO marked in the compose file).
|
||||
* `anon`/`authenticated` still hold zero grants; PostgREST serves nothing
|
||||
until the RLS/grants migration lands (future ADR).
|
||||
@@ -0,0 +1,103 @@
|
||||
# ADR-0004: Legacy JWT-based API keys, one signing secret
|
||||
|
||||
Date: 2026-07-08
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Supabase's API tier knows two credentials, and they are easy to confuse
|
||||
because in the legacy scheme **both are JWTs signed with the same
|
||||
`JWT_SECRET`**:
|
||||
|
||||
| | `apikey` header | `Authorization: Bearer …` header |
|
||||
|---|---|---|
|
||||
| identifies | the application | a logged-in person |
|
||||
| minted by | a human, once, at project setup | GoTrue, at every login |
|
||||
| payload | `{"role":"anon"}` / `{"role":"service_role"}` | `{"role":"authenticated","sub":"<user uuid>",…}` |
|
||||
| lifetime | ~10 years | 1 h (`GOTRUE_JWT_EXP`), refreshed |
|
||||
| checked by | Kong: exact string match against its consumer list | PostgREST: signature verification, then `SET ROLE` on the claim |
|
||||
|
||||
The `role` claim is what binds tokens to the database: PostgREST executes
|
||||
each request as the Postgres role the (verified) `Authorization` JWT names —
|
||||
`anon` for pre-login traffic, `authenticated` for sessions (with `auth.uid()`
|
||||
= the `sub` claim, feeding RLS), `service_role` for server-side admin work.
|
||||
`service_role` exists because some work legitimately spans all tenants
|
||||
(admin invites, cross-apartment aggregation jobs, backfills); it carries
|
||||
`BYPASSRLS` and its key must never leave the server side, while the anon key
|
||||
is public by design (it ships in the frontend bundle).
|
||||
|
||||
Supabase is migrating to opaque `sb_publishable_*`/`sb_secret_*` keys that
|
||||
separate the two concepts; the vendored `kong-entrypoint.sh` contains the
|
||||
translation shim for that scheme.
|
||||
|
||||
## Decision
|
||||
|
||||
Stay on the **legacy scheme** for the pilot: `supabase_anon_key` and
|
||||
`supabase_service_role_key` are long-lived HS256 JWTs signed with
|
||||
`supabase_jwt_secret`. The opaque-key shim stays dormant (its env vars are
|
||||
empty, which switches the Kong entrypoint to plain apikey pass-through).
|
||||
|
||||
This has a non-obvious integrity requirement: **the three secrets form one
|
||||
cryptographic family.** The API keys are only valid if they were signed with
|
||||
the exact `JWT_SECRET` stored alongside them. They are minted offline by us —
|
||||
no service issues them — and rotating `JWT_SECRET` silently invalidates both
|
||||
API keys *and* every active user session; all three must always be rotated
|
||||
together.
|
||||
|
||||
## Verifying / minting the keys
|
||||
|
||||
The keys currently in `nix/secrets/secrets.yaml` must be checked once against
|
||||
the stored secret (they predate this ADR). From the repo root, with sops
|
||||
access — `openssl` is not in the dev shell, so wrap in
|
||||
`nix shell nixpkgs#openssl` if needed:
|
||||
|
||||
```sh
|
||||
SECRET=$(sops -d --extract '["supabase_jwt_secret"]' nix/secrets/secrets.yaml)
|
||||
|
||||
verify() { # verify <jwt> — checks HS256 signature against $SECRET
|
||||
local hp=${1%.*} sig=${1##*.}
|
||||
local expect=$(printf '%s' "$hp" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" -binary \
|
||||
| basenc --base64url -w0 | tr -d '=')
|
||||
if [ -n "$expect" ] && [ "$sig" = "$expect" ]; then
|
||||
echo "valid ($(printf '%s' "${hp#*.}" | tr '_-' '/+' | base64 -d 2>/dev/null))"
|
||||
else
|
||||
echo "INVALID SIGNATURE"
|
||||
fi
|
||||
}
|
||||
|
||||
verify "$(sops -d --extract '["supabase_anon_key"]' nix/secrets/secrets.yaml)"
|
||||
verify "$(sops -d --extract '["supabase_service_role_key"]' nix/secrets/secrets.yaml)"
|
||||
```
|
||||
|
||||
Each should print `valid` with a payload naming the right role. If either
|
||||
prints `INVALID SIGNATURE`, re-mint and store both:
|
||||
|
||||
```sh
|
||||
mint() { # mint <role> — 10-year HS256 JWT signed with $SECRET
|
||||
local iat=$(date +%s) b64='basenc --base64url -w0'
|
||||
local h=$(printf '{"alg":"HS256","typ":"JWT"}' | $b64 | tr -d '=')
|
||||
local p=$(printf '{"role":"%s","iss":"supabase","iat":%s,"exp":%s}' \
|
||||
"$1" "$iat" $((iat + 315360000)) | $b64 | tr -d '=')
|
||||
local s=$(printf '%s.%s' "$h" "$p" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" -binary | $b64 | tr -d '=')
|
||||
printf '%s.%s.%s\n' "$h" "$p" "$s"
|
||||
}
|
||||
|
||||
mint anon # → sops set as supabase_anon_key
|
||||
mint service_role # → sops set as supabase_service_role_key
|
||||
```
|
||||
|
||||
(Equivalently: the generator on Supabase's self-hosting docs page produces
|
||||
the same thing — paste in the stored `JWT_SECRET`, don't let it invent a new
|
||||
one.)
|
||||
|
||||
## Consequences
|
||||
|
||||
* Matches what supabase-js sends by default; no client-side configuration
|
||||
beyond URL + anon key.
|
||||
* One secret to protect (`JWT_SECRET`) — and one blast radius: leak it and an
|
||||
attacker can mint `service_role` tokens; rotate it and keys + sessions die
|
||||
together.
|
||||
* Moving to opaque keys later is config-only: fill the four `sb_*`/asymmetric
|
||||
env vars and the already-vendored Kong entrypoint starts translating.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
Numbered, immutable-once-accepted records of the significant decisions in
|
||||
this repo. Supersede by adding a new ADR and flipping the old one's status to
|
||||
`Superseded by ADR-NNNN`, not by editing history.
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| [0001](0001-manual-supabase-cli-migrations.md) | Database schema changes are manual Supabase CLI migrations | Accepted |
|
||||
| [0002](0002-role-passwords-via-oneshot-unit.md) | Postgres role passwords are synced by a one-shot systemd unit | Accepted |
|
||||
| [0003](0003-vendored-supabase-compose-subset.md) | Vendor a trimmed Supabase compose stack against external Postgres | Accepted |
|
||||
| [0004](0004-legacy-jwt-api-keys.md) | Legacy JWT-based API keys, one signing secret | Accepted |
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Database migrations
|
||||
|
||||
Plain-SQL migrations in `migrations/`, applied **manually** with the supabase
|
||||
CLI (available in the dev shell). There is deliberately no automated runner:
|
||||
schema changes are rare, deliberate, admin-level operations.
|
||||
|
||||
The CLI records what has been applied in
|
||||
`supabase_migrations.schema_migrations` on the target database, so pushing is
|
||||
incremental and re-running it is safe.
|
||||
|
||||
## Applying to prod (db-host)
|
||||
|
||||
Postgres on db-host only accepts connections from the private network, so go
|
||||
through an SSH tunnel:
|
||||
|
||||
```sh
|
||||
# terminal 1 — tunnel to db-host's loopback
|
||||
ssh -L 15432:127.0.0.1:5432 deploy@db.gebos.online
|
||||
|
||||
# terminal 2 — from the repo root, inside `nix develop`
|
||||
PGPASS=$(sops -d --extract '["postgres_admin_password"]' nix/secrets/secrets.yaml)
|
||||
supabase db push --db-url "postgres://postgres:${PGPASS}@127.0.0.1:15432/postgres"
|
||||
```
|
||||
|
||||
The `postgres` superuser password is set from the same sops secret by the
|
||||
`gebos-postgres-passwords` oneshot on db-host (see
|
||||
`nix/modules/gebos-postgres.nix`), so it only works after the first deploy of
|
||||
that unit.
|
||||
|
||||
## Adding a migration
|
||||
|
||||
```sh
|
||||
supabase migration new <short_name> # creates migrations/<timestamp>_<short_name>.sql
|
||||
```
|
||||
|
||||
Write plain SQL. Conventions:
|
||||
|
||||
* Migrations are ordered and applied exactly once — they do NOT need to be
|
||||
idempotent (the schema-v1 file's `CREATE TYPE`s aren't).
|
||||
* Cluster bootstrap (Supabase schemas/roles, extensions that need
|
||||
`shared_preload_libraries`) belongs in `nix/supabase/init.sql`, not here.
|
||||
* Role passwords never go in migrations — they are synced from sops by the
|
||||
`gebos-postgres-passwords` unit.
|
||||
|
||||
## Local dev
|
||||
|
||||
`nix run .#dev` starts a plain Postgres via process-compose; apply migrations
|
||||
to it the same way:
|
||||
|
||||
```sh
|
||||
supabase db push --db-url "postgres://gebos_ingest:dev@127.0.0.1:5432/gebos"
|
||||
```
|
||||
|
||||
(Note: the dev database has no TimescaleDB unless the process-compose postgres
|
||||
gains the extension — `create_hypertable` calls will fail there until then.)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Minimal supabase CLI project marker. Only `supabase db push --db-url ...`
|
||||
# is used here (manual migrations against db-host); local `supabase start`
|
||||
# is not — the local dev stack is `nix run .#dev` (process-compose).
|
||||
project_id = "gebos"
|
||||
@@ -1,7 +1,15 @@
|
||||
-- ============================================================================
|
||||
-- GebOS – Schema V1
|
||||
-- GebOS – Schema V1 (initial Supabase migration)
|
||||
-- Target: Postgres 17 + TimescaleDB
|
||||
--
|
||||
-- Applied MANUALLY via `supabase db push` — see supabase/README.md. There is
|
||||
-- deliberately no automated migration runner. The CLI records applied
|
||||
-- migrations in supabase_migrations.schema_migrations, so re-running push is
|
||||
-- safe; the file itself is NOT idempotent (CREATE TYPE has no IF NOT EXISTS).
|
||||
--
|
||||
-- Cluster-level bootstrap (Supabase schemas/roles, extensions requiring
|
||||
-- shared_preload_libraries) lives in nix/supabase/init.sql, not here.
|
||||
--
|
||||
-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
|
||||
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
|
||||
-- epic and deliberately not modeled here.
|
||||
@@ -597,4 +605,38 @@ CREATE TABLE saving_tip (
|
||||
-- query, driven by count(apartment) per building at query time.
|
||||
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
|
||||
-- logic; the data model is season-agnostic.
|
||||
-- ===========================================================================
|
||||
-- ===========================================================================
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 6. INGEST ROLE & GRANTS
|
||||
--
|
||||
-- gebos_ingest is the MQTT ingester's login role — gebos-specific, NOT a
|
||||
-- Supabase role. It bypasses RLS because at write time no end user is
|
||||
-- authenticated; the trust boundary is the broker ACL ("who may publish to
|
||||
-- which tenant's topic"), not the database. Created here rather than in
|
||||
-- init.sql because its grants are coupled to the tables above, and because
|
||||
-- init.sql only runs at first initdb while migrations always apply.
|
||||
--
|
||||
-- Its password is set out-of-band by the gebos-postgres-passwords oneshot
|
||||
-- on db-host (sops secret: ingester_postgres_password).
|
||||
-- ===========================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE ROLE gebos_ingest LOGIN BYPASSRLS;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO gebos_ingest;
|
||||
|
||||
-- Lookups the ingest/parse pipeline performs: gateway by IMEI, sensor by
|
||||
-- oms_id, parser configuration by device type.
|
||||
GRANT SELECT ON gateway, sensor, device_type, value_parser TO gebos_ingest;
|
||||
|
||||
-- Frame log: INSERT on arrival; SELECT + UPDATE for the parse worker's
|
||||
-- queue query and its parse_status transitions.
|
||||
GRANT SELECT, INSERT, UPDATE ON received_payload TO gebos_ingest;
|
||||
|
||||
-- Measurements are append-only from the ingester's point of view — dedup is
|
||||
-- INSERT .. ON CONFLICT DO NOTHING against the composite PK, which needs no
|
||||
-- UPDATE privilege.
|
||||
GRANT SELECT, INSERT ON measurement TO gebos_ingest;
|
||||
Reference in New Issue
Block a user