Add infrastructure skeleton for review (refs #21)
check / flake-check (pull_request) Has been cancelled

Lays out the agreed shape from the design discussion: one flake at the
root with three nixosConfigurations (mqtt-ingest, db-host, app-host),
vendored Supabase docker-compose pointed at the external db-host,
Caddy → Kong → PostgREST/GoTrue, a Go MQTT→Postgres ingester stub,
deploy-rs node map, and a Gitea Actions workflow that deploys in
db → app → ingest order on push to main.

No real implementation yet — every host module has TODOs marking the
gaps (real hardware config, actual hostnames, HiveMQ packaging,
vendor the full upstream Supabase compose, etc.).
This commit is contained in:
Klaus
2026-05-27 17:23:02 +00:00
parent 71d74d3b83
commit 2d57db8182
34 changed files with 1005 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{ inputs }:
let
mkNode = name: hostname: {
inherit hostname;
sshUser = "deploy";
user = "root";
profiles.system = {
path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos
inputs.self.nixosConfigurations.${name};
};
};
in
{
sshOpts = [ "-o" "StrictHostKeyChecking=accept-new" ];
autoRollback = true;
magicRollback = true;
nodes = {
# Order matters: the Gitea Actions workflow invokes them in this sequence.
db-host = mkNode "db-host" "db-host.gebos.internal"; # TODO: real address
app-host = mkNode "app-host" "app-host.gebos.internal"; # TODO: real address
mqtt-ingest = mkNode "mqtt-ingest" "ingest.ge-bos.de";
};
}
+30
View File
@@ -0,0 +1,30 @@
{ pkgs, services-flake }:
{ ... }:
{
imports = [ services-flake.processComposeModules.default ];
# Skeleton — services-flake gives us postgres, plus we'd add Supabase via
# docker-compose run, Caddy, Kong (or skip Kong locally and hit services
# directly), the ingester, and `vite dev`. Fill in as we go.
services = {
postgres."db" = {
enable = true;
port = 5432;
initialDatabases = [{ name = "gebos"; }];
};
};
settings.processes = {
ingester = {
command = "${pkgs.go}/bin/go run ./ingester";
depends_on."db".condition = "process_healthy";
};
frontend = {
command = "${pkgs.nodejs_20}/bin/npm --prefix frontend run dev";
};
# TODO: supabase compose (point POSTGRES_HOST at the services-flake db)
# TODO: kong (use the vendored kong.yml from nix/supabase/)
# TODO: caddy (mirror prod routes locally, no TLS)
};
}
+27
View File
@@ -0,0 +1,27 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "app-host";
services.gebos.supabase = {
enable = true;
# Compose stack points at external db-host instead of the bundled `db` service.
postgresHost = "db-host.gebos.internal"; # TODO: real internal hostname
postgresPort = 5432;
# Studio bound to loopback only — reach via `ssh -L 3000:127.0.0.1:3000 app-host`.
studioBindAddress = "127.0.0.1";
};
services.gebos.caddy = {
enable = true;
# Caddy terminates TLS, proxies to Kong on 127.0.0.1:8000.
sites = {
"api.ge-bos.de" = { upstream = "127.0.0.1:8000"; }; # → Kong → PostgREST/GoTrue
"app.ge-bos.de" = { staticRoot = "${config.services.gebos.frontend.package}/share/frontend"; };
};
};
services.gebos.frontend.enable = true;
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
+31
View File
@@ -0,0 +1,31 @@
{ config, lib, pkgs, ... }:
{
system.stateVersion = "25.05";
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
trusted-users = [ "@wheel" "deploy" ];
};
# deploy-rs SSHes in as `deploy` and uses sudo to activate.
users.users.deploy = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
# TODO: paste the Gitea runner's deploy ed25519 pubkey here
];
};
security.sudo.wheelNeedsPassword = false;
services.openssh = {
enable = true;
settings.PasswordAuthentication = false;
};
networking.firewall.enable = true;
time.timeZone = "UTC";
environment.systemPackages = with pkgs; [ vim curl jq ];
}
+21
View File
@@ -0,0 +1,21 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "db-host";
# Skeleton: real hardware-configuration.nix + boot/fs lives next to this file
# once we have the actual box. Marked here so the structure is visible.
# imports = [ ./db-host.hardware.nix ];
services.gebos.postgres = {
enable = true;
listenAddresses = [ "0.0.0.0" ];
# Telemetry, auth, _supavisor, _analytics live in the same cluster.
# See nix/supabase/init.sql for schema/role bootstrap.
};
# Postgres exposed only to the app-host and mqtt-ingest private addresses.
networking.firewall.extraInputRules = ''
ip saddr { 10.0.0.0/8 } tcp dport 5432 accept
'';
}
+24
View File
@@ -0,0 +1,24 @@
{ inputs }:
let
mkHost = name: extraModules:
inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit inputs; };
modules = [
./common.nix
(import ../modules).gebos-postgres
(import ../modules).gebos-supabase
(import ../modules).gebos-caddy
(import ../modules).gebos-hivemq
(import ../modules).gebos-ingester
(import ../modules).gebos-frontend
./${name}.nix
] ++ extraModules;
};
in
{
db-host = mkHost "db-host" [ ];
app-host = mkHost "app-host" [ ];
mqtt-ingest = mkHost "mqtt-ingest" [ ];
}
+29
View File
@@ -0,0 +1,29 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "mqtt-ingest";
services.gebos.hivemq = {
enable = true;
# Persistent sessions on disk — single-node, devices reconnect on restart.
persistentSessions = true;
tlsPort = 8883;
};
services.gebos.ingester = {
enable = true;
mqttBroker = "tcp://127.0.0.1:1883"; # local broker
postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal:5432/postgres?sslmode=require";
# Password sourced from /etc/gebos/secrets.env via EnvironmentFile.
};
# Caddy fronts TLS for `ingest.ge-bos.de` and forwards to HiveMQ.
services.gebos.caddy = {
enable = true;
sites = {
"ingest.ge-bos.de" = { tcpProxy = "127.0.0.1:8883"; };
};
};
networking.firewall.allowedTCPPorts = [ 8883 ];
}
+8
View File
@@ -0,0 +1,8 @@
{
gebos-ingester = import ./gebos-ingester.nix;
gebos-supabase = import ./gebos-supabase.nix;
gebos-caddy = import ./gebos-caddy.nix;
gebos-postgres = import ./gebos-postgres.nix;
gebos-hivemq = import ./gebos-hivemq.nix;
gebos-frontend = import ./gebos-frontend.nix;
}
+47
View File
@@ -0,0 +1,47 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.caddy;
siteBlock = host: site:
if site ? staticRoot then ''
${host} {
root * ${site.staticRoot}
file_server
try_files {path} /index.html
encode zstd gzip
}
''
else if site ? upstream then ''
${host} {
reverse_proxy ${site.upstream}
encode zstd gzip
}
''
else if site ? tcpProxy then ''
${host}:8883 {
# TLS termination for MQTT Caddy's `layer4` app would be ideal here;
# for now, document that the upstream HiveMQ port is ${site.tcpProxy}.
# TODO: switch to the caddy-l4 module or terminate TLS in HiveMQ directly.
}
''
else throw "site `${host}` needs one of: staticRoot, upstream, tcpProxy";
in
{
options.services.gebos.caddy = {
enable = lib.mkEnableOption "Gebos Caddy reverse proxy / TLS terminator";
sites = lib.mkOption {
type = lib.types.attrsOf lib.types.attrs;
default = { };
};
};
config = lib.mkIf cfg.enable {
services.caddy = {
enable = true;
email = "ops@ge-bos.de"; # TODO: confirm ACME contact
extraConfig = lib.concatStringsSep "\n"
(lib.mapAttrsToList siteBlock cfg.sites);
};
};
}
+19
View File
@@ -0,0 +1,19 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.frontend;
in
{
options.services.gebos.frontend = {
enable = lib.mkEnableOption "Gebos static SPA";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.callPackage ../../frontend { };
};
};
config = lib.mkIf cfg.enable {
# Nothing to install at the system level — Caddy serves the package's
# `share/frontend` directory directly. See nix/hosts/app-host.nix.
};
}
+33
View File
@@ -0,0 +1,33 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.hivemq;
in
{
options.services.gebos.hivemq = {
enable = lib.mkEnableOption "HiveMQ CE single-node broker";
persistentSessions = lib.mkOption {
type = lib.types.bool;
default = true;
};
tlsPort = lib.mkOption {
type = lib.types.port;
default = 8883;
};
};
config = lib.mkIf cfg.enable {
# TODO: package HiveMQ CE (download tarball + JRE wrapper) and wire as a
# systemd unit. Until then this module only declares options so dependent
# hosts can be evaluated.
systemd.services.gebos-hivemq = {
description = "HiveMQ CE";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.coreutils}/bin/true"; # TODO: real HiveMQ command
Restart = "on-failure";
};
};
};
}
+41
View File
@@ -0,0 +1,41 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.ingester;
ingester = pkgs.callPackage ../../ingester { };
in
{
options.services.gebos.ingester = {
enable = lib.mkEnableOption "Gebos MQTT Postgres ingester";
mqttBroker = lib.mkOption {
type = lib.types.str;
example = "tcp://127.0.0.1:1883";
};
postgresUrl = lib.mkOption {
type = lib.types.str;
description = "DSN without password password comes from EnvironmentFile.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.gebos-ingester = {
description = "Gebos MQTT Postgres ingester";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
GEBOS_MQTT_BROKER = cfg.mqttBroker;
GEBOS_POSTGRES_URL = cfg.postgresUrl;
};
serviceConfig = {
ExecStart = "${ingester}/bin/gebos-ingester";
DynamicUser = true;
EnvironmentFile = "/etc/gebos/secrets.env"; # GEBOS_POSTGRES_PASSWORD
Restart = "on-failure";
RestartSec = "5s";
};
};
};
}
+36
View File
@@ -0,0 +1,36 @@
{ 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 {
services.postgresql = {
enable = true;
package = pkgs.postgresql_17;
enableTCPIP = true;
settings = {
listen_addresses = 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, the gebos_ingest role (BYPASSRLS), and telemetry hypertables.
initialScript = ../supabase/init.sql;
};
};
}
+61
View File
@@ -0,0 +1,61 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.supabase;
composeDir = ../supabase;
in
{
options.services.gebos.supabase = {
enable = lib.mkEnableOption "Supabase stack via vanilla docker-compose";
postgresHost = lib.mkOption {
type = lib.types.str;
description = "External Postgres host (db-host).";
};
postgresPort = lib.mkOption {
type = lib.types.port;
default = 5432;
};
studioBindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Studio is intentionally never exposed publicly.";
};
};
config = lib.mkIf cfg.enable {
virtualisation.docker.enable = true;
systemd.tmpfiles.rules = [
"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.
environment.etc."gebos/supabase/docker-compose.yml".source =
"${composeDir}/docker-compose.yml";
systemd.services.gebos-supabase = {
description = "Gebos Supabase compose stack";
after = [ "docker.service" "network-online.target" ];
wants = [ "docker.service" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
POSTGRES_HOST = cfg.postgresHost;
POSTGRES_PORT = toString cfg.postgresPort;
POSTGRES_DB = "postgres";
STUDIO_BIND = cfg.studioBindAddress;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/etc/gebos/supabase";
EnvironmentFile = "/etc/gebos/secrets.env"; # POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD
ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
};
}
+40
View File
@@ -0,0 +1,40 @@
# Supabase compose stack
Vendored from the official Supabase self-hosting bundle, with two 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`.
2. Studio is bound to `127.0.0.1` only. There is **no public route** to it.
To use it from your laptop:
```
ssh -L 3000:127.0.0.1:3000 app-host
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.
## 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).
## 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.
+67
View File
@@ -0,0 +1,67 @@
# Gebos — vendored Supabase compose stack.
#
# TODO: copy the full official compose from
# 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)
#
# 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.
services:
kong:
image: kong:2.8.1
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000/tcp" # Caddy proxies api.ge-bos.de here
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
volumes:
- ./kong.yml:/home/kong/kong.yml:ro
auth:
image: supabase/gotrue:v2.158.1
restart: unless-stopped
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.ge-bos.de
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: "3600"
API_EXTERNAL_URL: https://api.ge-bos.de
rest:
image: postgrest/postgrest:v12.0.2
restart: unless-stopped
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_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.ge-bos.de
DASHBOARD_USERNAME: gebos
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
meta:
image: supabase/postgres-meta:v0.83.2
restart: unless-stopped
environment:
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+81
View File
@@ -0,0 +1,81 @@
-- Gebos Postgres bootstrap.
-- Runs once via services.postgresql.initialScript on db-host.
--
-- Sets up:
-- * Extensions Supabase + TimescaleDB need
-- * Supabase's expected schemas and admin roles
-- * The gebos_ingest role (BYPASSRLS) used by the MQTT ingester
-- * A telemetry hypertable with tenant_id column for RLS
--
-- TODO: split into ordered files once it grows. For the skeleton, one file is fine.
create extension if not exists timescaledb;
create extension if not exists pgcrypto;
create extension if not exists pgjwt;
create extension if not exists pg_graphql;
create extension if not exists pgsodium;
-- create extension if not exists supabase_vault; -- TODO: vendor or package
-- Supabase schemas
create schema if not exists auth;
create schema if not exists storage;
create schema if not exists _analytics;
create schema if not exists _supavisor;
-- Roles Supabase services connect as. Passwords come from secrets.env, not here.
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 noinherit login;
grant anon, authenticated, service_role to authenticator;
exception when duplicate_object then null; end $$;
do $$ begin
create role supabase_admin login createrole createdb replication bypassrls;
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;
+48
View File
@@ -0,0 +1,48 @@
_format_version: "2.1"
# Declarative Kong config — vanilla Supabase routing, no surprises.
# Lives at /home/kong/kong.yml inside the container.
consumers:
- username: anon
keyauth_credentials:
- key: ${ANON_KEY}
- username: service_role
keyauth_credentials:
- key: ${SERVICE_ROLE_KEY}
services:
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-route
strip_path: true
paths: [ /auth/v1/ ]
plugins:
- name: cors
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-route
strip_path: true
paths: [ /rest/v1/ ]
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
key_names: [ apikey ]
- name: rpc
url: http://rest:3000/rpc/
routes:
- name: rpc-route
strip_path: true
paths: [ /rpc/ ]
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
key_names: [ apikey ]