Compare commits

..

4 Commits

Author SHA1 Message Date
lars 1cf710fe5b Merge pull request 'Infrastructure skeleton — flake, three hosts, Supabase compose, ingester, deploy-rs' (#23) from klaus/skeleton-infra into main
check / flake-check (push) Has been cancelled
deploy / deploy (push) Has been cancelled
Reviewed-on: #23
2026-06-25 14:54:06 +00:00
Lars Nolden a84b279747 fix nodejs version
check / flake-check (pull_request) Has been cancelled
2026-06-01 21:26:02 +02:00
Klaus 2dd5ed5877 Add sops-nix secrets + dev defaults so local stack runs with zero setup
check / flake-check (pull_request) Has been cancelled
- sops-nix flake input, age + ssh-to-age in devShell, .sops.yaml with
  per-host recipients (real pubkeys still TODO until lars bootstraps).
- nix/modules/gebos-secrets.nix centralises per-host secret subsets and
  renders /run/secrets/gebos-env via sops.templates. Service modules now
  read EnvironmentFile= from services.gebos.secrets.envFile instead of
  the placeholder /etc/gebos/secrets.env.
- Falls back to a writeText env file when nix/secrets/secrets.yaml is
  absent, so `nix flake check` and first-boot eval work pre-bootstrap.
- nix/secrets/README.md walks through age key + sops bootstrap.
- Dev defaults: ingester binary falls back to local tcp/postgres URLs,
  process-compose adds mosquitto and wires GEBOS_MQTT_BROKER /
  GEBOS_POSTGRES_URL / GEBOS_POSTGRES_PASSWORD + VITE_SUPABASE_URL /
  VITE_SUPABASE_ANON_KEY, frontend defaults to 127.0.0.1:8000 in DEV.

Refs #23 (comment #110).
2026-05-30 00:17:13 +00:00
Klaus 2d57db8182 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.).
2026-05-27 17:23:02 +00:00
40 changed files with 3402 additions and 0 deletions
+13
View File
@@ -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
+31
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
result
result-*
.direnv/
node_modules/
dist/
*.log
# local secrets — never commit
secrets.env
.env
.env.*
!.env.example
# age private keys — never commit (public keys live in .sops.yaml)
*.age
keys.txt
+33
View File
@@ -0,0 +1,33 @@
# sops creation/decryption rules.
#
# Recipients listed here decide who can decrypt each key in
# nix/secrets/secrets.yaml. Each host gets only the keys it actually needs;
# every developer who edits secrets is also a recipient on the matching key.
#
# Hosts derive their age identity from /etc/ssh/ssh_host_ed25519_key via
# ssh-to-age. Run `nix run nixpkgs#ssh-to-age -- < /etc/ssh/ssh_host_ed25519_key.pub`
# on each host to get its age pubkey, then paste it below.
keys:
# ---- developers (laptops, hardware keys) ----
- &dev_lars age1TODO_lars_personal_age_pubkey_replace_me_before_first_real_secret
# ---- hosts (derived from each host's ssh_host_ed25519_key.pub via ssh-to-age) ----
- &host_db age1TODO_db_host_age_pubkey_replace_me
- &host_app age1TODO_app_host_age_pubkey_replace_me
- &host_ingest age1TODO_mqtt_ingest_host_age_pubkey_replace_me
creation_rules:
# Supabase compose stack → app-host only.
- path_regex: nix/secrets/secrets\.yaml$
encrypted_regex: ^(supabase_|ingester_|postgres_admin_)
key_groups:
- age:
- *dev_lars
- *host_db
- *host_app
- *host_ingest
# NOTE: a single key_group above lets every recipient decrypt every key.
# Per-host filtering will be tightened to one rule per prefix once real age
# pubkeys land — see nix/secrets/README.md for the rotation/bootstrap flow.
+66
View File
@@ -0,0 +1,66 @@
# 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
[sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
Single encrypted file at `nix/secrets/secrets.yaml`; each host decrypts only the
keys it needs at activation, rendered into a tmpfs env file consumed by systemd
`EnvironmentFile=`. Plaintext never enters the Nix store. See
[`nix/secrets/README.md`](nix/secrets/README.md) for bootstrap and rotation.
Local dev needs **no** secrets bootstrap — `nix run .#dev`, `go run ./ingester`,
and `npm --prefix frontend run dev` all default to the local dev stack values
defined in `nix/dev/process-compose.nix`.
Generated
+179
View File
@@ -0,0 +1,179 @@
{
"nodes": {
"deploy-rs": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs",
"utils": "utils"
},
"locked": {
"lastModified": 1770019181,
"narHash": "sha256-hwsYgDnby50JNVpTRYlF3UR/Rrpt01OrxVuryF40CFY=",
"owner": "serokell",
"repo": "deploy-rs",
"rev": "77c906c0ba56aabdbc72041bf9111b565cdd6171",
"type": "github"
},
"original": {
"owner": "serokell",
"repo": "deploy-rs",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1743014863,
"narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"process-compose-flake": {
"locked": {
"lastModified": 1767863885,
"narHash": "sha256-XXekPAxzbv1DmHFo3Elmj/vDnvWc1V0jdDUvM0/Wf7k=",
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"rev": "99bea96cf269cfd235833ebdf645b567069fd398",
"type": "github"
},
"original": {
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"type": "github"
}
},
"root": {
"inputs": {
"deploy-rs": "deploy-rs",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_2",
"process-compose-flake": "process-compose-flake",
"services-flake": "services-flake"
}
},
"services-flake": {
"locked": {
"lastModified": 1779042632,
"narHash": "sha256-WgQZ1AsDMCTL+0x0yZEd1g3yMQbgEwAq0VUg73YU50M=",
"owner": "juspay",
"repo": "services-flake",
"rev": "b0012dbf955efc3f6e2decd61610da5cea17dce6",
"type": "github"
},
"original": {
"owner": "juspay",
"repo": "services-flake",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+63
View File
@@ -0,0 +1,63 @@
{
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";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
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_24
postgresql_17
supabase-cli
deploy-rs.packages.${system}.default
sops
age
ssh-to-age
];
};
# `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;
};
};
};
}
+21
View File
@@ -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";
};
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gebos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1871
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
export function App() {
return (
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
<h1>Gebos</h1>
<p>Skeleton SPA. Real UI lands once the architecture PR is approved.</p>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
import { createClient } from "@supabase/supabase-js";
// Single subdomain for everything Supabase — Kong fans out to /auth/v1, /rest/v1, /rpc.
//
// Defaults match the dev stack in nix/dev/process-compose.nix so `npm run dev`
// works without an .env file. Production bundles are built with VITE_SUPABASE_URL
// and VITE_SUPABASE_ANON_KEY set at `nix build .#frontend` time.
const DEV_SUPABASE_URL = "http://127.0.0.1:8000";
const DEV_SUPABASE_ANON_KEY =
"dev-anon-key-placeholder-regenerate-with-supabase-self-host-script";
const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL ??
(import.meta.env.DEV ? DEV_SUPABASE_URL : "https://api.ge-bos.de");
const SUPABASE_ANON_KEY =
import.meta.env.VITE_SUPABASE_ANON_KEY ??
(import.meta.env.DEV ? DEV_SUPABASE_ANON_KEY : "");
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
+15
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: { outDir: "dist" },
});
+15
View File
@@ -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";
};
}
+7
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
// Package main — Gebos MQTT → Postgres ingester.
//
// Skeleton. Target: < 200 lines.
//
// Subscribes to topics of the form `t/<tenant_id>/d/<device_id>/<metric>`,
// 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"
)
// Defaults match the local dev stack in nix/dev/process-compose.nix so
// `go run ./ingester` works without any env setup. Prod always sets these
// explicitly via the systemd unit's EnvironmentFile.
const (
defaultBroker = "tcp://127.0.0.1:1883"
defaultPgURL = "postgres://gebos_ingest@127.0.0.1:5432/gebos?sslmode=disable"
defaultPgPassword = "dev"
)
func envOr(key, fallback string) (string, bool) {
if v := os.Getenv(key); v != "" {
return v, false
}
return fallback, true
}
func main() {
broker, brokerDefault := envOr("GEBOS_MQTT_BROKER", defaultBroker)
pgURL, pgURLDefault := envOr("GEBOS_POSTGRES_URL", defaultPgURL)
pgPassword, pgPasswordDefault := envOr("GEBOS_POSTGRES_PASSWORD", defaultPgPassword)
_ = pgPassword
if brokerDefault || pgURLDefault || pgPasswordDefault {
log.Printf("gebos-ingester: using dev defaults for: %s%s%s — set env vars in prod",
ifStr(brokerDefault, "GEBOS_MQTT_BROKER ", ""),
ifStr(pgURLDefault, "GEBOS_POSTGRES_URL ", ""),
ifStr(pgPasswordDefault, "GEBOS_POSTGRES_PASSWORD ", ""),
)
}
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 (50100ms) for throughput
// 5. shutdown on ctx.Done
log.Printf("gebos-ingester: broker=%s db=%s (stub)", broker, pgURL)
<-ctx.Done()
log.Println("gebos-ingester: shutting down")
}
func ifStr(cond bool, a, b string) string {
if cond {
return a
}
return b
}
+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";
};
}
+75
View File
@@ -0,0 +1,75 @@
{ 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;
mqttHost = "127.0.0.1";
mqttPort = 1883;
# Pre-generated dev JWT pair, signed with `jwt_secret = "dev-jwt-secret-32chars-minimum-xxxxx"`.
# Anyone with this token can read/write the local dev stack only — not prod.
jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx";
anonKey = "dev-anon-key-placeholder-regenerate-with-supabase-self-host-script";
serviceRoleKey = "dev-service-role-key-placeholder-regenerate-with-supabase-self-host-script";
supabaseUrl = "http://127.0.0.1:8000";
};
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;
};
in
{
imports = [ services-flake.processComposeModules.default ];
services = {
postgres."db" = {
enable = true;
port = dev.pgPort;
initialDatabases = [{ name = dev.pgDB; }];
};
mosquitto."broker" = {
enable = true;
port = dev.mqttPort;
};
};
settings.processes = {
ingester = {
command = "${pkgs.go}/bin/go run ./ingester";
environment = ingesterEnv;
depends_on = {
"db".condition = "process_healthy";
"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;
};
# 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)
};
}
+29
View File
@@ -0,0 +1,29 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "app-host";
services.gebos.secrets.supabase = true;
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 ];
}
+23
View File
@@ -0,0 +1,23 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "db-host";
services.gebos.secrets.postgresAdmin = true;
# 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
'';
}
+28
View File
@@ -0,0 +1,28 @@
{ inputs }:
let
modules = import ../modules;
mkHost = name: extraModules:
inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit inputs; };
modules = [
./common.nix
inputs.sops-nix.nixosModules.sops
modules.gebos-secrets
modules.gebos-postgres
modules.gebos-supabase
modules.gebos-caddy
modules.gebos-hivemq
modules.gebos-ingester
modules.gebos-frontend
./${name}.nix
] ++ extraModules;
};
in
{
db-host = mkHost "db-host" [ ];
app-host = mkHost "app-host" [ ];
mqtt-ingest = mkHost "mqtt-ingest" [ ];
}
+31
View File
@@ -0,0 +1,31 @@
{ config, lib, pkgs, ... }:
{
networking.hostName = "mqtt-ingest";
services.gebos.secrets.ingester = true;
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 defaults to tcp://127.0.0.1:1883 (the local HiveMQ).
postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal:5432/postgres?sslmode=require";
# GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix).
};
# 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 ];
}
+9
View File
@@ -0,0 +1,9 @@
{
gebos-secrets = import ./gebos-secrets.nix;
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";
};
};
};
}
+44
View File
@@ -0,0 +1,44 @@
{ 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;
default = "tcp://127.0.0.1:1883";
example = "tcp://127.0.0.1:1883";
};
postgresUrl = lib.mkOption {
type = lib.types.str;
default = "postgres://gebos_ingest@127.0.0.1:5432/postgres?sslmode=require";
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;
# GEBOS_POSTGRES_PASSWORD. Rendered by sops-nix — see gebos-secrets module.
EnvironmentFile = config.services.gebos.secrets.envFile;
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;
};
};
}
+95
View File
@@ -0,0 +1,95 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.secrets;
secretsFile = ../secrets/secrets.yaml;
hasSecrets = builtins.pathExists secretsFile;
# Map from logical group → list of secret keys in secrets.yaml.
# Keys are flat (e.g. "supabase_jwt_secret") so .sops.yaml regex rules can
# filter per-host without nested-path footguns.
supabaseKeys = [
"supabase_postgres_password"
"supabase_jwt_secret"
"supabase_anon_key"
"supabase_service_role_key"
"supabase_dashboard_password"
];
ingesterKeys = [ "ingester_postgres_password" ];
postgresAdminKeys = [ "postgres_admin_password" ];
# Render an env file body from a list of (key, envVarName) pairs.
envBody = pairs: lib.concatStringsSep "\n" (map
({ key, var }: "${var}=${config.sops.placeholder.${key}}")
pairs);
enabledPairs =
lib.optionals cfg.supabase [
{ key = "supabase_postgres_password"; var = "POSTGRES_PASSWORD"; }
{ key = "supabase_jwt_secret"; var = "JWT_SECRET"; }
{ key = "supabase_anon_key"; var = "ANON_KEY"; }
{ key = "supabase_service_role_key"; var = "SERVICE_ROLE_KEY"; }
{ key = "supabase_dashboard_password"; var = "DASHBOARD_PASSWORD"; }
]
++ lib.optionals cfg.ingester [
{ key = "ingester_postgres_password"; var = "GEBOS_POSTGRES_PASSWORD"; }
]
++ lib.optionals cfg.postgresAdmin [
{ key = "postgres_admin_password"; var = "POSTGRES_ADMIN_PASSWORD"; }
];
enabledKeys =
lib.optionals cfg.supabase supabaseKeys
++ lib.optionals cfg.ingester ingesterKeys
++ lib.optionals cfg.postgresAdmin postgresAdminKeys;
in
{
options.services.gebos.secrets = {
supabase = lib.mkEnableOption ''
Supabase compose stack secrets: POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY,
SERVICE_ROLE_KEY, DASHBOARD_PASSWORD.'';
ingester = lib.mkEnableOption ''
Ingester DB password: GEBOS_POSTGRES_PASSWORD (for the gebos_ingest role).'';
postgresAdmin = lib.mkEnableOption ''
Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.'';
envFile = lib.mkOption {
type = lib.types.path;
readOnly = true;
description = ''
Path of the rendered env file consumed by service units' EnvironmentFile=.
Resolves to the sops-templated file under /run/secrets when sops material
is present, or to a baked-in dev-defaults file otherwise (so first-boot
eval and local NixOS VMs work without any secrets bootstrap).
'';
default =
if hasSecrets
then config.sops.templates."gebos-env".path
else "${pkgs.writeText "gebos-env-dev-defaults" (
# Dev fallback. Never used in prod — overridden once secrets.yaml exists.
lib.concatStringsSep "\n" (map ({ var, ... }: "${var}=dev-${var}") enabledPairs)
)}";
};
};
config = lib.mkIf hasSecrets {
sops = {
defaultSopsFile = secretsFile;
# Derive each host's age identity from its existing ssh host key.
# No extra key material to provision — ssh-to-age handles conversion.
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
secrets = lib.listToAttrs (map
(key: { name = key; value = { }; })
enabledKeys);
templates."gebos-env" = lib.mkIf (enabledPairs != []) {
content = envBody enabledPairs;
mode = "0400";
owner = "root";
};
};
};
}
+63
View File
@@ -0,0 +1,63 @@
{ 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";
# POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD.
# Rendered by sops-nix from nix/secrets/secrets.yaml — see gebos-secrets module.
EnvironmentFile = config.services.gebos.secrets.envFile;
ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
};
}
+71
View File
@@ -0,0 +1,71 @@
# Secrets
Managed with [sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
## Layout
- `.sops.yaml` (repo root) — recipients and per-file creation rules.
- `nix/secrets/secrets.yaml.example` — plaintext template (committed).
- `nix/secrets/secrets.yaml` — sops-encrypted real values (committed once created).
Plaintext values never enter the Nix store or disk outside tmpfs. sops-nix
decrypts on activation into `/run/secrets/<name>` with declared owner/mode,
then renders `gebos-env` from those placeholders for systemd `EnvironmentFile=`.
## First-time bootstrap
```sh
# 1. Generate your personal age key (keep this private, NEVER commit).
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# → "Public key: age1abc..." ← paste this into .sops.yaml as &dev_<you>
# 2. Get each host's age pubkey (one per host).
# On each host as root:
ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub
# → "age1xyz..." ← paste into .sops.yaml as &host_<name>
# 3. Edit .sops.yaml — replace every "age1TODO_..." with the real pubkeys above.
# 4. Create the real secrets file from the template, fill in real values, encrypt.
cp nix/secrets/secrets.yaml.example nix/secrets/secrets.yaml
$EDITOR nix/secrets/secrets.yaml
sops -e -i nix/secrets/secrets.yaml
# 5. Commit .sops.yaml + the encrypted secrets.yaml.
git add .sops.yaml nix/secrets/secrets.yaml
git commit -m "secrets: initial sops bootstrap"
```
## Editing later
```sh
sops nix/secrets/secrets.yaml # opens $EDITOR, re-encrypts on save
```
## Rotation
- **Leaked value** → `sops nix/secrets/secrets.yaml`, change value, redeploy.
- **New host** → add its age pubkey to `.sops.yaml`, then `sops updatekeys nix/secrets/secrets.yaml`.
- **Compromised dev key** → remove from `.sops.yaml`, `sops updatekeys`, redeploy.
## Generating value material
| Field | How |
| ------------------------------ | ------------------------------------------------------------ |
| `supabase_postgres_password` | `openssl rand -hex 32` |
| `supabase_jwt_secret` | `openssl rand -hex 32` (must be ≥32 chars) |
| `supabase_anon_key` | JWT signed with `jwt_secret`, `role: anon`, long expiry |
| `supabase_service_role_key` | JWT signed with `jwt_secret`, `role: service_role` |
| `supabase_dashboard_password` | `openssl rand -hex 16` (Studio basic-auth, username `gebos`) |
| `ingester_postgres_password` | `openssl rand -hex 32` |
| `postgres_admin_password` | `openssl rand -hex 32` |
Self-hosted Supabase docs have a one-liner for signing the two JWTs offline.
## Local development
You do **not** need to bootstrap secrets for `nix run .#dev`. The dev process-compose
stack and the ingester binary both default to local-only values
(`GEBOS_POSTGRES_URL`, `GEBOS_MQTT_BROKER`, `GEBOS_POSTGRES_PASSWORD`, plus the
JWT/anon-key pair the frontend needs). See `nix/dev/process-compose.nix`.
+29
View File
@@ -0,0 +1,29 @@
# Template for nix/secrets/secrets.yaml — the sops-encrypted store.
#
# To bootstrap:
# 1. cp nix/secrets/secrets.yaml.example nix/secrets/secrets.yaml
# 2. Replace the placeholder values below with real ones.
# (See nix/secrets/README.md for how to generate JWT_SECRET / ANON_KEY /
# SERVICE_ROLE_KEY etc.)
# 3. sops -e -i nix/secrets/secrets.yaml
# (requires sops + age + the recipients in .sops.yaml to be real keys)
#
# Once encrypted, edit in place with: sops nix/secrets/secrets.yaml
#
# Local dev does NOT need this file at all — process-compose.nix bakes in
# dev defaults and the NixOS modules fall back to a writeText env file when
# nix/secrets/secrets.yaml is absent. This file is only consumed at activation
# time on real hosts.
# Supabase compose stack (consumed on app-host by gebos-supabase.service)
supabase_postgres_password: "replace-me-openssl-rand-hex-32"
supabase_jwt_secret: "replace-me-openssl-rand-hex-32-minimum-32-chars"
supabase_anon_key: "replace-me-jwt-signed-with-jwt_secret-role-anon"
supabase_service_role_key: "replace-me-jwt-signed-with-jwt_secret-role-service_role"
supabase_dashboard_password: "replace-me-studio-basic-auth-password"
# Ingester (consumed on mqtt-ingest by gebos-ingester.service)
ingester_postgres_password: "replace-me-openssl-rand-hex-32"
# Postgres bootstrap (consumed on db-host by a one-shot ALTER ROLE unit)
postgres_admin_password: "replace-me-openssl-rand-hex-32"
+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 ]