Wire schema v1 into a manual Supabase migration and vendor the real stack
deploy / deploy (push) Successful in 5m57s
check / flake-check (push) Failing after 35m52s

- 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:
Lars Nolden
2026-07-08 22:33:50 +02:00
parent b2a1056255
commit 2ce68cb098
17 changed files with 967 additions and 118 deletions
@@ -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).
+103
View File
@@ -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.
+12
View File
@@ -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 |
+5
View File
@@ -8,6 +8,11 @@
networking.hostName = "db-host"; networking.hostName = "db-host";
services.gebos.secrets.postgresAdmin = true; 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 = { services.gebos.postgres = {
enable = true; enable = true;
+91 -1
View File
@@ -37,8 +37,98 @@ in
# supabase_vault — TODO: package or vendor # supabase_vault — TODO: package or vendor
]; ];
# Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor` # 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; 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
'';
};
}; };
} }
+16 -2
View File
@@ -30,10 +30,16 @@ in
"d /var/lib/gebos-supabase 0750 root root - -" "d /var/lib/gebos-supabase 0750 root root - -"
]; ];
# Copy the vendored compose file into place at activation time so changes # Copy the vendored compose bundle into place at activation time so
# to nix/supabase/docker-compose.yml are deployed atomically. # 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 = environment.etc."gebos/supabase/docker-compose.yml".source =
"${composeDir}/docker-compose.yml"; "${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 = { systemd.services.gebos-supabase = {
description = "Gebos Supabase compose stack"; description = "Gebos Supabase compose stack";
@@ -41,6 +47,14 @@ in
wants = [ "docker.service" "network-online.target" ]; wants = [ "docker.service" "network-online.target" ];
wantedBy = [ "multi-user.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 = { environment = {
POSTGRES_HOST = cfg.postgresHost; POSTGRES_HOST = cfg.postgresHost;
POSTGRES_PORT = toString cfg.postgresPort; POSTGRES_PORT = toString cfg.postgresPort;
+4 -4
View File
@@ -17,8 +17,8 @@
# Supabase compose stack (consumed on app-host by gebos-supabase.service) # 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_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_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_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:QZQbvJ6rkyoHIpxIvjVDFwxeZ4oIb7Bt2h2vgtW8/sp6swSxtW/oAeVfLkdeduir+2BC5mFAhA==,iv:/F5F3MRC5Yebn68g45F1sj4d18+60JZupneHa3ipExs=,tag:fuzDuSj2npxKQxP2Pz+cjQ==,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] 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 (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] 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----- -----END AGE ENCRYPTED FILE-----
recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_) encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_)
lastmodified: "2026-07-02T13:11:42Z" lastmodified: "2026-07-08T20:32:18Z"
mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str] mac: ENC[AES256_GCM,data:56m8mOIai7eRKBXwcvWa0SLH6pH2LkL0zrRQ3nbfqe2qqn2qiji5dh4fzVVHmAKnXyob9HaRgRGBTnoi0L357MX+rzefSb0RTDXItsHkYSURYDZnRegEtszPpE5tlff/AlxtLXjs43P6nErkE263gzoFD2EFBwVlH6aufcX3fRo=,iv:eeGYKLRJqDq/ODHeyb6Ucn/K/drFNEJzKZGuAMEGGAU=,tag:qNo4uY4PV7C4MsYThvol3w==,type:str]
version: 3.13.1 version: 3.13.1
+50 -24
View File
@@ -1,40 +1,66 @@
# Supabase compose stack # Supabase compose stack
Vendored from the official Supabase self-hosting bundle, with two deliberate Vendored from the official self-hosting bundle
changes: (`docker/docker-compose.yml` in supabase/supabase), with deliberate changes:
1. The bundled `db` service is removed. Every service that talks to Postgres 1. **No `db` service.** Every service reads `POSTGRES_HOST` / `POSTGRES_PORT`
reads `POSTGRES_HOST` / `POSTGRES_PORT` from the environment and connects to from the environment and connects to the external `db-host`. The systemd
the external `db-host`. The systemd unit (`nix/modules/gebos-supabase.nix`) unit (`nix/modules/gebos-supabase.nix`) injects these; secrets come from
injects these from `/etc/gebos/secrets.env`. 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. 2. **Only auth + rest + studio (+ kong, meta).** Realtime, storage, imgproxy,
To use it from your laptop: 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 open http://localhost:3000
``` ```
This is intentional — Studio runs with the `service_role` JWT and bypasses Studio itself has no login in this topology — possession of the SSH key is
RLS. Putting it on the public internet behind only HTTP basic auth (the the credential. (`supabase_dashboard_password` in secrets.yaml is currently
default) is too thin. 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 ## What's in here
- `docker-compose.yml` — the stack itself - `docker-compose.yml` — the stack itself
- `init.sql` — schemas, roles, and extensions Postgres needs before the - `init.sql` — Supabase schemas, roles, and extensions Postgres needs before
compose services come up. Loaded by `nix/modules/gebos-postgres.nix`. the compose services come up. Runs once at first initdb via
- `kong.yml` — Kong's declarative routing (to be vendored alongside the compose `nix/modules/gebos-postgres.nix`. Application schema lives in
file when we copy it 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 ## Updating the vendored compose
When upstream Supabase ships a new compose layout, re-vendor by: 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
1. Copy upstream `docker/docker-compose.yml` over `docker-compose.yml`. `kong.yml` (both carry the list in their header comments). Then commit,
2. Remove the `db:` service block. deploy, and smoke-test Studio through the SSH tunnel.
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.
+179 -35
View File
@@ -1,65 +1,209 @@
# Gebos — vendored Supabase compose stack. # 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 # https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml
# and apply the three modifications described in ./README.md: # with the deliberate deviations documented 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)
# #
# The skeleton below names the services we'll keep and the env vars each one # * no `db` service — Postgres runs on db-host; every service connects to
# reads, so reviewers can sanity-check the topology before the real compose # ${POSTGRES_HOST}:${POSTGRES_PORT} and the `depends_on: db` blocks are
# lands. # 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: services:
kong:
image: kong:2.8.1 studio:
container_name: supabase-studio
image: supabase/studio:2026.07.07-sha-a6a04f2
restart: unless-stopped restart: unless-stopped
ports: 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: environment:
KONG_DATABASE: "off" KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
volumes: KONG_ROUTER_FLAVOR: expressions
- ./kong.yml:/home/kong/kong.yml:ro # 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: auth:
image: supabase/gotrue:v2.158.1 container_name: supabase-auth
image: supabase/gotrue:v2.189.0
restart: unless-stopped restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
environment: environment:
GOTRUE_DB_DRIVER: postgres GOTRUE_API_HOST: 0.0.0.0
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} GOTRUE_API_PORT: 9999
GOTRUE_SITE_URL: https://app.gebos.online
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: "3600"
API_EXTERNAL_URL: https://api.gebos.online 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: rest:
image: postgrest/postgrest:v12.0.2 container_name: supabase-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped restart: unless-stopped
healthcheck:
test: [ "CMD", "postgrest", "--ready" ]
interval: 5s
timeout: 5s
retries: 3
environment: environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: public 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 PGRST_DB_ANON_ROLE: anon
studio: PGRST_ADMIN_SERVER_PORT: 3001
image: supabase/studio:20240326-5e5586d PGRST_ADMIN_SERVER_HOST: localhost
restart: unless-stopped
ports: PGRST_JWT_SECRET: ${JWT_SECRET}
- "${STUDIO_BIND}:3000:3000/tcp" # never bind to 0.0.0.0 PGRST_DB_USE_LEGACY_GUCS: "false"
environment: PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
STUDIO_PG_META_URL: http://meta:8080 PGRST_APP_SETTINGS_JWT_EXP: "3600"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} command:
DEFAULT_ORGANIZATION_NAME: Gebos [
SUPABASE_URL: https://api.gebos.online "postgrest"
DASHBOARD_USERNAME: gebos ]
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
meta: meta:
image: supabase/postgres-meta:v0.83.2 container_name: supabase-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped restart: unless-stopped
environment: environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST} PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT} PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB} PG_META_DB_NAME: ${POSTGRES_DB}
-31
View File
@@ -48,34 +48,3 @@ do $$ begin
create role supabase_auth_admin login createrole; create role supabase_auth_admin login createrole;
grant usage on schema auth to supabase_auth_admin; grant usage on schema auth to supabase_auth_admin;
exception when duplicate_object then null; end $$; 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;
+49
View File
@@ -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
View File
@@ -1,48 +1,206 @@
_format_version: "2.1" _format_version: '2.1'
_transform: true
# Declarative Kong config — vanilla Supabase routing, no surprises. # Vendored from the official bundle (docker/volumes/api/kong.yml), with the
# Lives at /home/kong/kong.yml inside the container. # 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: consumers:
- username: anon - username: anon
keyauth_credentials: keyauth_credentials:
- key: ${ANON_KEY} - key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role - username: service_role
keyauth_credentials: 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: 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 - name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/ url: http://auth:9999/
routes: routes:
- name: auth-v1-route - name: auth-v1-all
strip_path: true strip_path: true
paths: [ /auth/v1/ ] paths:
- /auth/v1/
plugins: plugins:
- name: cors - 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/ url: http://rest:3000/
routes: routes:
- name: rest-v1-route - name: rest-v1-openapi-root
strip_path: true strip_path: true
paths: [ /rest/v1/ ] expression: 'http.path == "/rest/v1/"'
plugins: plugins:
- name: cors - name: cors
- name: key-auth - name: key-auth
config: config:
hide_credentials: true hide_credentials: false
key_names: [ apikey ] - 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 ## Secure PostgREST routes
url: http://rest:3000/rpc/ - name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes: routes:
- name: rpc-route - name: rest-v1-all
strip_path: true strip_path: true
paths: [ /rpc/ ] paths:
- /rest/v1/
plugins: plugins:
- name: cors - name: cors
- name: key-auth - name: key-auth
config: config:
hide_credentials: true hide_credentials: false
key_names: [ apikey ] - 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
+55
View File
@@ -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.)
+4
View File
@@ -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 -- 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 -- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate -- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
-- epic and deliberately not modeled here. -- epic and deliberately not modeled here.
@@ -598,3 +606,37 @@ CREATE TABLE saving_tip (
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display -- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
-- logic; the data model is season-agnostic. -- 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;