diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml
new file mode 100644
index 0000000..fa43703
--- /dev/null
+++ b/.gitea/workflows/check.yml
@@ -0,0 +1,13 @@
+name: check
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ flake-check:
+ runs-on: nix
+ steps:
+ - uses: actions/checkout@v4
+ - run: nix flake check
+ - run: nix build .#ingester .#frontend
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..dee3807
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,31 @@
+name: deploy
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: nix
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup deploy SSH key
+ run: |
+ mkdir -p ~/.ssh
+ install -m 600 /dev/stdin ~/.ssh/id_ed25519 <<< "${{ secrets.DEPLOY_SSH_KEY }}"
+ ssh-keyscan -H db-host.gebos.internal app-host.gebos.internal ingest.ge-bos.de >> ~/.ssh/known_hosts 2>/dev/null || true
+
+ - name: Build all closures
+ run: nix flake check --no-build
+
+ # Order matters: db schema migrations are part of db-host's activation,
+ # so it must land first. App-host's Supabase compose stack expects the
+ # schemas to exist. mqtt-ingest only needs the network paths to db-host.
+ - name: Deploy db-host
+ run: nix run github:serokell/deploy-rs -- .#db-host --skip-checks
+
+ - name: Deploy app-host
+ run: nix run github:serokell/deploy-rs -- .#app-host --skip-checks
+
+ - name: Deploy mqtt-ingest
+ run: nix run github:serokell/deploy-rs -- .#mqtt-ingest --skip-checks
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e8bdb7f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+result
+result-*
+.direnv/
+node_modules/
+dist/
+*.log
+
+# local secrets — never commit
+secrets.env
+.env
+.env.*
+!.env.example
diff --git a/README.md b/README.md
index e69de29..5bd85ce 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,60 @@
+# Gebos
+
+IoT telemetry stack on bare-metal NixOS.
+
+This repo is a monorepo containing all code, NixOS modules, host configurations,
+and CI/CD pipelines for the project. Everything is one `flake.nix`.
+
+## Architecture
+
+Three hosts (see issue #21 for the full design discussion):
+
+| Host | Role | Public hostname |
+| ------------- | ----------------------------------------- | ---------------------- |
+| `mqtt-ingest` | HiveMQ CE + Go MQTT→Postgres ingester | `ingest.ge-bos.de` |
+| `db-host` | Postgres 17 + TimescaleDB (telemetry+auth)| internal only |
+| `app-host` | Caddy + Kong + Supabase (compose) + SPA | `app.ge-bos.de`, `api.ge-bos.de` |
+
+Public REST surface is PostgREST + SQL `/rpc/` functions, fronted by Kong, TLS-terminated by Caddy.
+Supabase Studio is bound to `127.0.0.1` on `app-host` — reach it with `ssh -L 3000:127.0.0.1:3000 app-host`.
+
+## Repo layout
+
+```
+flake.nix
+frontend/ # Vite + React SPA
+ingester/ # Go MQTT → Postgres
+nix/
+ modules/ # NixOS modules (one per service)
+ hosts/ # nixosConfigurations: mqtt-ingest, db-host, app-host
+ supabase/ # vendored Supabase docker-compose, db init SQL
+ dev/ # process-compose for local development
+ deploy.nix # deploy-rs node map
+.gitea/workflows/ # CI + CD
+```
+
+## Local development
+
+```
+nix run .#dev
+```
+
+Brings the full stack up on one machine via process-compose-flake (Postgres,
+Supabase compose, Kong, Caddy, ingester, Vite dev server). NixOS required.
+
+## Deployment
+
+`deploy-rs` from a Gitea Actions runner on push to `main`. Closures are built
+once, copied to each host, activated with auto-rollback. Order: `db-host` →
+`app-host` → `mqtt-ingest`.
+
+```
+nix run github:serokell/deploy-rs -- .#db-host # one host
+nix run github:serokell/deploy-rs -- . # all hosts
+```
+
+## Secrets
+
+Out-of-band file at `/etc/gebos/secrets.env` on each host, mode `0400`, loaded
+by systemd `EnvironmentFile=`. Not in the repo, not in the Nix store. Rotation
+is manual (ssh + edit + restart unit).
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..3f0c9f7
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,56 @@
+{
+ description = "Gebos — IoT telemetry stack on bare-metal NixOS";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-parts.url = "github:hercules-ci/flake-parts";
+ deploy-rs.url = "github:serokell/deploy-rs";
+ process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
+ services-flake.url = "github:juspay/services-flake";
+ };
+
+ outputs = inputs@{ self, flake-parts, nixpkgs, deploy-rs, ... }:
+ flake-parts.lib.mkFlake { inherit inputs; } {
+ systems = [ "x86_64-linux" "aarch64-linux" ];
+
+ imports = [
+ inputs.process-compose-flake.flakeModule
+ ];
+
+ flake = {
+ nixosModules = import ./nix/modules;
+ nixosConfigurations = import ./nix/hosts { inherit inputs; };
+ deploy = import ./nix/deploy.nix { inherit inputs; };
+
+ # `nix flake check` runs deploy-rs's schema check on every node
+ checks = builtins.mapAttrs
+ (system: deployLib: deployLib.deployChecks self.deploy)
+ deploy-rs.lib;
+ };
+
+ perSystem = { config, pkgs, system, ... }: {
+ packages = {
+ ingester = pkgs.callPackage ./ingester { };
+ frontend = pkgs.callPackage ./frontend { };
+ };
+
+ devShells.default = pkgs.mkShell {
+ packages = with pkgs; [
+ go
+ gopls
+ nodejs_20
+ postgresql_17
+ supabase-cli
+ deploy-rs.packages.${system}.default
+ ];
+ };
+
+ # `nix run .#dev` — full stack locally via process-compose-flake.
+ # Real service wiring lives in nix/dev/process-compose.nix.
+ process-compose.dev = import ./nix/dev/process-compose.nix {
+ inherit pkgs;
+ inherit (inputs) services-flake;
+ };
+ };
+ };
+}
diff --git a/frontend/default.nix b/frontend/default.nix
new file mode 100644
index 0000000..177259e
--- /dev/null
+++ b/frontend/default.nix
@@ -0,0 +1,21 @@
+{ buildNpmPackage, lib }:
+
+buildNpmPackage {
+ pname = "gebos-frontend";
+ version = "0.0.0";
+ src = ./.;
+
+ # Filled in once package-lock.json exists.
+ npmDepsHash = lib.fakeHash;
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/share/frontend
+ cp -r dist/* $out/share/frontend/
+ runHook postInstall
+ '';
+
+ meta = {
+ description = "Gebos static SPA";
+ };
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..3a48ef7
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Gebos
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..b2b9140
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "gebos-frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@supabase/supabase-js": "^2.45.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.5",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "typescript": "^5.5.4",
+ "vite": "^5.4.2"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..2a95e5d
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,8 @@
+export function App() {
+ return (
+
+ Gebos
+ Skeleton SPA. Real UI lands once the architecture PR is approved.
+
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..1c711a7
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,9 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/frontend/src/supabase.ts b/frontend/src/supabase.ts
new file mode 100644
index 0000000..fe1d54d
--- /dev/null
+++ b/frontend/src/supabase.ts
@@ -0,0 +1,7 @@
+import { createClient } from "@supabase/supabase-js";
+
+// Single subdomain for everything Supabase — Kong fans out to /auth/v1, /rest/v1, /rpc.
+const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL ?? "https://api.ge-bos.de";
+const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
+
+export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..08ff715
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..425c778
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ build: { outDir: "dist" },
+});
diff --git a/ingester/default.nix b/ingester/default.nix
new file mode 100644
index 0000000..03a5e9e
--- /dev/null
+++ b/ingester/default.nix
@@ -0,0 +1,15 @@
+{ buildGoModule, lib }:
+
+buildGoModule {
+ pname = "gebos-ingester";
+ version = "0.0.0";
+ src = ./.;
+
+ # Set once `go mod tidy` has produced a real go.sum.
+ vendorHash = null;
+
+ meta = {
+ description = "MQTT → Postgres telemetry ingester for Gebos";
+ mainProgram = "gebos-ingester";
+ };
+}
diff --git a/ingester/go.mod b/ingester/go.mod
new file mode 100644
index 0000000..bbeb981
--- /dev/null
+++ b/ingester/go.mod
@@ -0,0 +1,7 @@
+module github.com/gebos/ingester
+
+go 1.22
+
+// TODO after first `go mod tidy`:
+// - github.com/eclipse/paho.mqtt.golang
+// - github.com/jackc/pgx/v5
diff --git a/ingester/main.go b/ingester/main.go
new file mode 100644
index 0000000..72cdef8
--- /dev/null
+++ b/ingester/main.go
@@ -0,0 +1,41 @@
+// Package main — Gebos MQTT → Postgres ingester.
+//
+// Skeleton. Target: < 200 lines.
+//
+// Subscribes to topics of the form `t//d//`,
+// extracts tenant_id and device_id from the topic, writes rows into
+// public.telemetry on db-host as the gebos_ingest role (BYPASSRLS).
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+func main() {
+ broker := os.Getenv("GEBOS_MQTT_BROKER")
+ pgURL := os.Getenv("GEBOS_POSTGRES_URL")
+ pgPassword := os.Getenv("GEBOS_POSTGRES_PASSWORD")
+
+ if broker == "" || pgURL == "" || pgPassword == "" {
+ log.Fatal("GEBOS_MQTT_BROKER, GEBOS_POSTGRES_URL and GEBOS_POSTGRES_PASSWORD must be set")
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ // TODO:
+ // 1. pgxpool.New with password injected into the DSN
+ // 2. paho MQTT client.Connect, Subscribe("t/+/d/+/+")
+ // 3. on message: parse topic → (tenant_id, device_id, metric), parse
+ // payload (JSON for now), INSERT INTO public.telemetry
+ // 4. batch with a short flush interval (50–100ms) for throughput
+ // 5. shutdown on ctx.Done
+
+ log.Printf("gebos-ingester: broker=%s db= (stub)", broker)
+ <-ctx.Done()
+ log.Println("gebos-ingester: shutting down")
+}
diff --git a/nix/deploy.nix b/nix/deploy.nix
new file mode 100644
index 0000000..530b973
--- /dev/null
+++ b/nix/deploy.nix
@@ -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";
+ };
+}
diff --git a/nix/dev/process-compose.nix b/nix/dev/process-compose.nix
new file mode 100644
index 0000000..a0d546e
--- /dev/null
+++ b/nix/dev/process-compose.nix
@@ -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)
+ };
+}
diff --git a/nix/hosts/app-host.nix b/nix/hosts/app-host.nix
new file mode 100644
index 0000000..bc44122
--- /dev/null
+++ b/nix/hosts/app-host.nix
@@ -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 ];
+}
diff --git a/nix/hosts/common.nix b/nix/hosts/common.nix
new file mode 100644
index 0000000..7a29e7d
--- /dev/null
+++ b/nix/hosts/common.nix
@@ -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 ];
+}
diff --git a/nix/hosts/db-host.nix b/nix/hosts/db-host.nix
new file mode 100644
index 0000000..5a8c2ab
--- /dev/null
+++ b/nix/hosts/db-host.nix
@@ -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
+ '';
+}
diff --git a/nix/hosts/default.nix b/nix/hosts/default.nix
new file mode 100644
index 0000000..2a22222
--- /dev/null
+++ b/nix/hosts/default.nix
@@ -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" [ ];
+}
diff --git a/nix/hosts/mqtt-ingest.nix b/nix/hosts/mqtt-ingest.nix
new file mode 100644
index 0000000..fb808fb
--- /dev/null
+++ b/nix/hosts/mqtt-ingest.nix
@@ -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 ];
+}
diff --git a/nix/modules/default.nix b/nix/modules/default.nix
new file mode 100644
index 0000000..baf037b
--- /dev/null
+++ b/nix/modules/default.nix
@@ -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;
+}
diff --git a/nix/modules/gebos-caddy.nix b/nix/modules/gebos-caddy.nix
new file mode 100644
index 0000000..56d4790
--- /dev/null
+++ b/nix/modules/gebos-caddy.nix
@@ -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);
+ };
+ };
+}
diff --git a/nix/modules/gebos-frontend.nix b/nix/modules/gebos-frontend.nix
new file mode 100644
index 0000000..71c8d90
--- /dev/null
+++ b/nix/modules/gebos-frontend.nix
@@ -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.
+ };
+}
diff --git a/nix/modules/gebos-hivemq.nix b/nix/modules/gebos-hivemq.nix
new file mode 100644
index 0000000..43584f5
--- /dev/null
+++ b/nix/modules/gebos-hivemq.nix
@@ -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";
+ };
+ };
+ };
+}
diff --git a/nix/modules/gebos-ingester.nix b/nix/modules/gebos-ingester.nix
new file mode 100644
index 0000000..573b142
--- /dev/null
+++ b/nix/modules/gebos-ingester.nix
@@ -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";
+ };
+ };
+ };
+}
diff --git a/nix/modules/gebos-postgres.nix b/nix/modules/gebos-postgres.nix
new file mode 100644
index 0000000..e36eba4
--- /dev/null
+++ b/nix/modules/gebos-postgres.nix
@@ -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;
+ };
+ };
+}
diff --git a/nix/modules/gebos-supabase.nix b/nix/modules/gebos-supabase.nix
new file mode 100644
index 0000000..c285408
--- /dev/null
+++ b/nix/modules/gebos-supabase.nix
@@ -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";
+ };
+ };
+ };
+}
diff --git a/nix/supabase/README.md b/nix/supabase/README.md
new file mode 100644
index 0000000..e94d471
--- /dev/null
+++ b/nix/supabase/README.md
@@ -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.
diff --git a/nix/supabase/docker-compose.yml b/nix/supabase/docker-compose.yml
new file mode 100644
index 0000000..c9cba13
--- /dev/null
+++ b/nix/supabase/docker-compose.yml
@@ -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}
diff --git a/nix/supabase/init.sql b/nix/supabase/init.sql
new file mode 100644
index 0000000..339ba21
--- /dev/null
+++ b/nix/supabase/init.sql
@@ -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;
diff --git a/nix/supabase/kong.yml b/nix/supabase/kong.yml
new file mode 100644
index 0000000..188b58e
--- /dev/null
+++ b/nix/supabase/kong.yml
@@ -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 ]