Infrastructure skeleton — flake, three hosts, Supabase compose, ingester, deploy-rs #23

Merged
lars merged 3 commits from klaus/skeleton-infra into main 2026-06-25 14:54:06 +00:00
34 changed files with 1005 additions and 0 deletions
Showing only changes of commit 2d57db8182 - Show all commits
+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
+12
View File
@@ -0,0 +1,12 @@
result
result-*
.direnv/
node_modules/
dist/
*.log
# local secrets — never commit
secrets.env
.env
.env.*
!.env.example
+60
View File
@@ -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).
+56
View File
@@ -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;
};
};
};
}
+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>
+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>,
);
+7
View File
@@ -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);
+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
+41
View File
@@ -0,0 +1,41 @@
// 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"
)
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 (50100ms) for throughput
// 5. shutdown on ctx.Done
log.Printf("gebos-ingester: broker=%s db=<redacted> (stub)", broker)
<-ctx.Done()
log.Println("gebos-ingester: shutting down")
}
+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 ]