{ pkgs, services-flake }: { ... }: let # Dev-only credentials. Match the defaults baked into ingester/main.go and # frontend/src/supabase.ts, so `nix run .#dev` and `go run ./ingester` / # `npm --prefix frontend run dev` all interoperate without any setup. # # These values are public on purpose — they ONLY work against the local # process-compose stack. Real prod values come from sops-nix (see # nix/secrets/README.md). dev = { pgUser = "gebos_ingest"; pgDB = "gebos"; pgPassword = "dev"; pgHost = "127.0.0.1"; pgPort = 5432; # Unix socket dir, bind-mounted into the compose network's `db` proxy # (see composeDevOverride below). Relative to the process-compose cwd, # like services-flake's ./data/db data dir. pgSocketDir = "./data/db-socket"; mqttHost = "127.0.0.1"; mqttPort = 1883; # Pre-generated dev JWT pair, signed HS256 with jwtSecret. Regenerate # with any JWT tool if jwtSecret ever changes; payload is # {"role":"","iss":"supabase-dev","iat":1751328000,"exp":2082758400}. # Anyone with these tokens can read/write the local dev stack only — not prod. jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx"; anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU"; serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtZGV2IiwiaWF0IjoxNzUxMzI4MDAwLCJleHAiOjIwODI3NTg0MDB9.dNeWNshQ9e1pIjxTDYseURlbSPPArVIKa9OuTRZyIH8"; supabaseUrl = "http://127.0.0.1:8000"; frontendPort = 5173; # vite default }; ingesterEnv = { GEBOS_MQTT_BROKER = "tcp://${dev.mqttHost}:${toString dev.mqttPort}"; GEBOS_POSTGRES_URL = "postgres://${dev.pgUser}@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}?sslmode=disable"; GEBOS_POSTGRES_PASSWORD = dev.pgPassword; }; frontendEnv = { VITE_SUPABASE_URL = dev.supabaseUrl; VITE_SUPABASE_ANON_KEY = dev.anonKey; }; # services-flake ships no MQTT broker, so run mosquitto as a plain # process-compose process. Anonymous access on the dev listener only. mosquittoConf = pkgs.writeText "mosquitto-dev.conf" '' listener ${toString dev.mqttPort} ${dev.mqttHost} allow_anonymous true ''; # --------------------------------------------------------------------------- # Supabase — the vendored prod compose bundle (nix/supabase/), unchanged, # plus this dev overlay. # # The overlay's job is wiring the containers to the services-flake postgres. # Bridge→host TCP is a dead end on a typical NixOS dev machine (the host # firewall's INPUT chain rejects it), so instead a socat `db` service # forwards TCP 5432 inside the compose network onto the host postgres unix # socket, bind-mounted from pgSocketDir. Conveniently that restores the # upstream compose convention of a service literally named `db`, so the # vendored file works with just POSTGRES_HOST=db. Socket connections match # pg_hba's `local … trust`, so the dev password is never actually checked. # --------------------------------------------------------------------------- composeDevOverride = pkgs.writeText "docker-compose.dev-override.yml" '' services: db: container_name: supabase-dev-db # Unpinned minor: dev-only TCP→unix-socket shim, nothing depends on # socat internals. image: alpine/socat:1.8.0.3 restart: unless-stopped command: TCP-LISTEN:${toString dev.pgPort},fork,reuseaddr UNIX-CONNECT:/host-postgres/.s.PGSQL.${toString dev.pgPort} volumes: - ''${PG_SOCKET_DIR:?set by gebos-dev-supabase-compose}:/host-postgres studio: environment: SUPABASE_PUBLIC_URL: ${dev.supabaseUrl} auth: environment: API_EXTERNAL_URL: ${dev.supabaseUrl} GOTRUE_SITE_URL: http://localhost:${toString dev.frontendPort} GOTRUE_JWT_ISSUER: ${dev.supabaseUrl} # No SMTP locally, so magic-link invites can't send. Allow plain # self-signup with auto-confirm instead; prod keeps signup disabled. GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_MAILER_AUTOCONFIRM: "true" ''; # One wrapper for `up`/`down` so both resolve the socket-dir bind mount the # same way (compose needs an absolute path). supabaseCompose = pkgs.writeShellApplication { name = "gebos-dev-supabase-compose"; runtimeInputs = [ pkgs.docker pkgs.coreutils ]; text = '' PG_SOCKET_DIR="$(readlink -f ${dev.pgSocketDir})" export PG_SOCKET_DIR exec docker compose \ --project-name gebos-supabase-dev \ -f nix/supabase/docker-compose.yml \ -f ${composeDevOverride} \ "$@" ''; }; supabaseEnv = { POSTGRES_HOST = "db"; # the socat proxy above POSTGRES_PORT = toString dev.pgPort; POSTGRES_DB = dev.pgDB; POSTGRES_PASSWORD = dev.pgPassword; # unchecked — see overlay comment JWT_SECRET = dev.jwtSecret; ANON_KEY = dev.anonKey; SERVICE_ROLE_KEY = dev.serviceRoleKey; STUDIO_BIND = "127.0.0.1"; }; # Everything prod splits across nix/supabase/init.sql (first initdb), # the gebos-postgres-passwords oneshot, and a manual `supabase db push`, # collapsed into one idempotent script that runs on every stack start. dbMigrate = pkgs.writeShellApplication { name = "gebos-dev-db-migrate"; runtimeInputs = [ pkgs.supabase-cli pkgs.postgresql_17 ]; text = '' export PGHOST=${dev.pgHost} PGPORT=${toString dev.pgPort} PGDATABASE=${dev.pgDB} PGSSLMODE=disable # 1. Cluster bootstrap: Supabase roles/schemas the compose services # expect (mirrors nix/supabase/init.sql, but idempotent because the # dev data dir may predate this script). Password 'dev' everywhere. psql -v ON_ERROR_STOP=1 --quiet <<'SQL' DO $$ BEGIN CREATE ROLE anon NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE ROLE authenticated NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE ROLE authenticator LOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; GRANT anon, authenticated, service_role TO authenticator; DO $$ BEGIN CREATE ROLE supabase_admin LOGIN CREATEROLE CREATEDB BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE ROLE supabase_auth_admin LOGIN CREATEROLE; EXCEPTION WHEN duplicate_object THEN NULL; END $$; -- On db-host the NixOS postgresql module creates the `postgres` -- superuser; the services-flake cluster's superuser is the OS user -- instead, and GoTrue's own migrations GRANT to `postgres` by name. DO $$ BEGIN CREATE ROLE postgres LOGIN SUPERUSER; EXCEPTION WHEN duplicate_object THEN NULL; END $$; ALTER ROLE postgres PASSWORD 'dev'; ALTER ROLE supabase_admin SUPERUSER PASSWORD 'dev'; ALTER ROLE supabase_auth_admin PASSWORD 'dev'; ALTER ROLE authenticator PASSWORD 'dev'; -- GoTrue owns `auth` and runs its own migrations there on startup. Its -- migrator creates tables in the role's default schema, so without the -- search_path it lands in `public` and dies on PG15+'s revoked CREATE -- (upstream's supabase/postgres image sets the same role search_path). CREATE SCHEMA IF NOT EXISTS auth; ALTER SCHEMA auth OWNER TO supabase_auth_admin; ALTER ROLE supabase_auth_admin SET search_path TO auth, extensions; -- The upstream supabase/postgres image keeps shared extensions in an -- `extensions` schema on the search path; the services assume that. CREATE SCHEMA IF NOT EXISTS extensions; CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role, authenticator, supabase_auth_admin; ALTER DATABASE gebos SET search_path TO "$user", public, extensions; SQL # 2. Schema migrations — same mechanism as prod (supabase/README.md), # non-interactive, as the local superuser ($USER). supabase db push --yes --db-url "postgres://$USER@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}" # 3. The schema-v1 migration created gebos_ingest; in prod its password # comes from the gebos-postgres-passwords oneshot. psql -v ON_ERROR_STOP=1 --quiet -c "ALTER ROLE ${dev.pgUser} PASSWORD '${dev.pgPassword}'" # 4. Fixture data — idempotent, dev/tests only (supabase/README.md). psql -v ON_ERROR_STOP=1 --quiet -f supabase/seed.sql ''; }; in { imports = [ services-flake.processComposeModules.default ]; services = { postgres."db" = { enable = true; port = dev.pgPort; # TCP stays loopback-only; the compose containers come in through the # unix socket instead (see composeDevOverride). socketDir = dev.pgSocketDir; initialDatabases = [{ name = dev.pgDB; }]; # The schema-v1 migration calls create_hypertable; without the # extension `supabase db push` rolls back entirely on the dev db. # The apache variant lacks only TSL features (compression, CAggs # policies) — hypertables are enough here, and it keeps dev free # of unfree packages. extensions = extensions: [ extensions.timescaledb-apache ]; settings.shared_preload_libraries = "timescaledb"; }; }; settings.processes = { broker = { command = "${pkgs.mosquitto}/bin/mosquitto -c ${mosquittoConf}"; readiness_probe = { exec.command = "${pkgs.mosquitto}/bin/mosquitto_sub -h ${dev.mqttHost} -p ${toString dev.mqttPort} -t '$$SYS/#' -C 1 -W 2"; initial_delay_seconds = 1; period_seconds = 2; }; }; # Oneshot: bootstrap roles/schemas, apply migrations, seed. Idempotent, # so it simply re-runs on every stack start. db-migrate = { command = "${dbMigrate}/bin/gebos-dev-db-migrate"; depends_on."db".condition = "process_healthy"; }; # Auth (GoTrue) + REST (PostgREST) + Studio + postgres-meta behind Kong # on ${dev.supabaseUrl} — the vendored prod bundle, see composeDevOverride. # Needs a running docker daemon on the dev machine. supabase = { command = "${supabaseCompose}/bin/gebos-dev-supabase-compose up --remove-orphans"; environment = supabaseEnv; # `restart: unless-stopped` containers outlive a hard-killed # process-compose; an explicit `down` covers the clean path. shutdown.command = "${supabaseCompose}/bin/gebos-dev-supabase-compose down --remove-orphans"; depends_on = { # GoTrue's own migrations need the auth schema + roles from db-migrate. "db".condition = "process_healthy"; "db-migrate".condition = "process_completed_successfully"; }; readiness_probe = { # Kong → key-auth → GoTrue → Postgres: healthy = the whole API path works. exec.command = "${pkgs.curl}/bin/curl -fsS -H 'apikey: ${dev.anonKey}' ${dev.supabaseUrl}/auth/v1/health"; initial_delay_seconds = 5; period_seconds = 5; failure_threshold = 60; # first start pulls the images }; }; ingester = { # -C: the go module root is ingester/, not the repo root. command = "${pkgs.go}/bin/go run -C ingester ./cmd/gebos-ingester"; environment = ingesterEnv; depends_on = { # db-migrate (implies db healthy) creates the gebos_ingest role and # the telemetry tables. "db-migrate".condition = "process_completed_successfully"; "broker".condition = "process_started"; }; }; frontend = { command = "${pkgs.nodejs_24}/bin/npm --prefix frontend install && ${pkgs.nodejs_24}/bin/npm --prefix frontend run dev"; environment = frontendEnv; }; # No caddy locally: prod's Caddy config is generated by the gebos-caddy # NixOS module, so a hand-mirrored dev caddyfile wouldn't test it — the # frontend talks to Kong directly (dev.supabaseUrl). }; }