{ config, lib, pkgs, ... }: let cfg = config.services.gebos.postgres; in { options.services.gebos.postgres = { enable = lib.mkEnableOption "Gebos Postgres 17 + TimescaleDB"; listenAddresses = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "127.0.0.1" ]; }; }; config = lib.mkIf cfg.enable { # TimescaleDB ships under the non-OSI "Timescale License" (TSL), which # nixpkgs flags as unfree. Allow just this package rather than opening the # whole config to unfree. nixpkgs.config.allowUnfreePredicate = pkg: lib.elem (lib.getName pkg) [ "timescaledb" ]; services.postgresql = { enable = true; package = pkgs.postgresql_17; # listen_addresses is managed explicitly below. The upstream postgresql # module also defines it (via its own default / enableTCPIP), so mkForce # makes this module the single source of truth and avoids the collision. settings = { listen_addresses = lib.mkForce (lib.concatStringsSep "," cfg.listenAddresses); shared_preload_libraries = "timescaledb,pg_stat_statements"; }; extensions = ps: with ps; [ timescaledb pgjwt pg_graphql pgsodium # supabase_vault — TODO: package or vendor ]; # Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor` # 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 ''; }; }; }