Project Setup #21

Open
opened 2026-05-23 19:19:31 +00:00 by Klaus · 16 comments
Collaborator

Setup the infrastructure required for the project. The project shall be hosted on a single bare metal NixOS server.
If possible I would like to avoid docker compose and use more linux native services like systemd in order to run services.

  • Postgres Database
  • Supabase based on nix Flake
  • GO REST backend - placeholder backend for now with some Supabase sample queries and health REST endpoint
  • Static REACT App - A static vite app that connects to the GO REST backend. Strictly NO SSR.
  • Expose all the services via Caddy.
  • All nix configuration files for the required services, backend and frontend code should live in this single monorepository
  • There should be an easy way to start a local development instance of the project (on a nixos machine)

Open ended questions: How does the deployment work? I'd like a Continous deployment action in Gitea that can deploy the code.


Imported from Linear GEB-20 · Priority: No priority

Setup the infrastructure required for the project. The project shall be hosted on a single bare metal NixOS server. If possible I would like to avoid docker compose and use more linux native services like systemd in order to run services. * Postgres Database * Supabase based on nix Flake * GO REST backend - placeholder backend for now with some Supabase sample queries and health REST endpoint * Static REACT App - A static vite app that connects to the GO REST backend. Strictly NO SSR. * Expose all the services via Caddy. * All nix configuration files for the required services, backend and frontend code should live in this single monorepository * There should be an easy way to start a local development instance of the project (on a nixos machine) Open ended questions: How does the deployment work? I'd like a Continous deployment action in Gitea that can deploy the code. --- _Imported from Linear [GEB-20](https://linear.app/gebos/issue/GEB-20/project-setup) · Priority: No priority_
Owner

@Klaus for now let's ideate on this together in this issue. Once I approve, you can start with the implementation.

@Klaus for now let's ideate on this together in this issue. Once I approve, you can start with the implementation.
Author
Collaborator

Happy to ideate before I touch any code. Here's a strawman so we have something concrete to push back on — nothing below is decided.

Proposed shape

One flake.nix at the repo root exposing:

  • packages.{backend, frontend} — the Go binary and the built Vite bundle
  • nixosModules.{gebos-backend, gebos-frontend, gebos-supabase} — drop-in service modules
  • nixosConfigurations.prod — the full server definition (composes the modules above + Postgres + Caddy)
  • devShells.default — Go, Node, psql, supabase CLI, etc.
  • An ergonomic nix run .#dev (likely backed by process-compose-flake or devenv) that brings the full stack up locally without touching the host's systemd

Repo layout I'd suggest:

flake.nix
flake.lock
backend/            # Go module, buildGoModule package
frontend/           # Vite app, buildNpmPackage
nix/
  modules/          # NixOS modules per service
  hosts/prod.nix    # the bare-metal host
  supabase/         # supabase service definitions (postgrest, gotrue, realtime, …)
  dev/              # process-compose / devenv config for local
.gitea/workflows/   # CI + CD pipelines

Service map (all systemd units, no containers):

  • postgresql.service — nixpkgs services.postgresql, with the Supabase extensions (pgjwt, pg_graphql, pgsodium, supabase_vault) enabled
  • postgrest.service — nixpkgs already ships PostgREST
  • gotrue.service — package from source if not already in nixpkgs
  • realtime.service — Elixir/Phoenix app; packageable but the heaviest lift
  • caddy.service — routes / → static React, /rest/* → PostgREST, /auth/* → GoTrue, /realtime/* → Realtime, /api/* → Go backend; ACME for TLS
  • gebos-backend.service — the Go binary

CD via Gitea Actions, two viable paths:

  1. Push-deploy with deploy-rs or colmena — runner builds the system closure, signs it, copies to the server, activates with automatic rollback on failure. My preferred choice; declarative and atomic.
  2. Pull-deploy — server runs a small timer that does nixos-rebuild switch --flake gitea://lars/Gebos#prod on new commits to main. Simpler, less infra, but no pre-deploy build verification.

Decisions I'd like your input on before I draft anything more concrete

  1. Supabase scope. Full stack (Auth + PostgREST + Realtime + Storage + Edge Functions + Studio) or a minimal subset? Studio in particular is a Next.js app and adds real packaging work — do you want admin UI access on the server, or are you fine with psql + Supabase CLI from your laptop?
  2. Domain & TLS. One hostname with path-based routing (gebos.example.com/rest/...) or subdomains per service (api., auth., db.)? Which domain, and is Caddy doing ACME?
  3. Auth flow. Frontend talks to GoTrue directly and the Go backend just validates JWTs? Or does the Go backend mediate all auth?
  4. Go backend specifics. Standard library net/http (1.22+ patterns) or a router like chi? Database access through pgx, or always through PostgREST? Any opinion on sqlc for typed queries?
  5. Secrets management. agenix, sops-nix, or just an out-of-band /etc/gebos/secrets.env that systemd loads via EnvironmentFile=?
  6. Deploy mechanism. deploy-rs/colmena (my recommendation) vs. pull-based nixos-rebuild timer vs. plain SSH + nixos-rebuild switch --target-host?
  7. Gitea runner. Self-hosted on the same bare-metal box (simplest, but shares blast radius), or a separate runner host?
  8. Local dev assumption. Other contributors on NixOS only, or should it also work on a generic Linux box with Nix installed? (macOS would mean either a VM or building only the non-systemd pieces locally.)
  9. DB migrations. supabase migration CLI, goose, plain SQL files applied by a systemd oneshot, or something else?

If you'd rather not answer all of these up front, the highest-leverage ones to unblock a meaningful prototype are 1, 2, 5, and 6 — the rest can be deferred to when we hit them.

Let me know which directions feel right (or wrong) and I'll iterate. Once we converge I'll wait for your green light before opening an MR.

Happy to ideate before I touch any code. Here's a strawman so we have something concrete to push back on — nothing below is decided. ## Proposed shape **One `flake.nix` at the repo root** exposing: - `packages.{backend, frontend}` — the Go binary and the built Vite bundle - `nixosModules.{gebos-backend, gebos-frontend, gebos-supabase}` — drop-in service modules - `nixosConfigurations.prod` — the full server definition (composes the modules above + Postgres + Caddy) - `devShells.default` — Go, Node, psql, supabase CLI, etc. - An ergonomic `nix run .#dev` (likely backed by `process-compose-flake` or `devenv`) that brings the full stack up locally without touching the host's systemd **Repo layout I'd suggest:** ``` flake.nix flake.lock backend/ # Go module, buildGoModule package frontend/ # Vite app, buildNpmPackage nix/ modules/ # NixOS modules per service hosts/prod.nix # the bare-metal host supabase/ # supabase service definitions (postgrest, gotrue, realtime, …) dev/ # process-compose / devenv config for local .gitea/workflows/ # CI + CD pipelines ``` **Service map (all systemd units, no containers):** - `postgresql.service` — nixpkgs `services.postgresql`, with the Supabase extensions (`pgjwt`, `pg_graphql`, `pgsodium`, `supabase_vault`) enabled - `postgrest.service` — nixpkgs already ships PostgREST - `gotrue.service` — package from source if not already in nixpkgs - `realtime.service` — Elixir/Phoenix app; packageable but the heaviest lift - `caddy.service` — routes `/` → static React, `/rest/*` → PostgREST, `/auth/*` → GoTrue, `/realtime/*` → Realtime, `/api/*` → Go backend; ACME for TLS - `gebos-backend.service` — the Go binary **CD via Gitea Actions, two viable paths:** 1. **Push-deploy with `deploy-rs` or `colmena`** — runner builds the system closure, signs it, copies to the server, activates with automatic rollback on failure. My preferred choice; declarative and atomic. 2. **Pull-deploy** — server runs a small timer that does `nixos-rebuild switch --flake gitea://lars/Gebos#prod` on new commits to `main`. Simpler, less infra, but no pre-deploy build verification. ## Decisions I'd like your input on before I draft anything more concrete 1. **Supabase scope.** Full stack (Auth + PostgREST + Realtime + Storage + Edge Functions + Studio) or a minimal subset? Studio in particular is a Next.js app and adds real packaging work — do you want admin UI access on the server, or are you fine with `psql` + Supabase CLI from your laptop? 2. **Domain & TLS.** One hostname with path-based routing (`gebos.example.com/rest/...`) or subdomains per service (`api.`, `auth.`, `db.`)? Which domain, and is Caddy doing ACME? 3. **Auth flow.** Frontend talks to GoTrue directly and the Go backend just validates JWTs? Or does the Go backend mediate all auth? 4. **Go backend specifics.** Standard library `net/http` (1.22+ patterns) or a router like `chi`? Database access through pgx, or always through PostgREST? Any opinion on `sqlc` for typed queries? 5. **Secrets management.** `agenix`, `sops-nix`, or just an out-of-band `/etc/gebos/secrets.env` that systemd loads via `EnvironmentFile=`? 6. **Deploy mechanism.** `deploy-rs`/`colmena` (my recommendation) vs. pull-based `nixos-rebuild` timer vs. plain SSH + `nixos-rebuild switch --target-host`? 7. **Gitea runner.** Self-hosted on the same bare-metal box (simplest, but shares blast radius), or a separate runner host? 8. **Local dev assumption.** Other contributors on NixOS only, or should it also work on a generic Linux box with Nix installed? (macOS would mean either a VM or building only the non-systemd pieces locally.) 9. **DB migrations.** `supabase migration` CLI, `goose`, plain SQL files applied by a systemd `oneshot`, or something else? If you'd rather not answer all of these up front, the highest-leverage ones to unblock a meaningful prototype are **1, 2, 5, and 6** — the rest can be deferred to when we hit them. Let me know which directions feel right (or wrong) and I'll iterate. Once we converge I'll wait for your green light before opening an MR.
Owner

Ok thank you. I've made a more detailed plan about this. We need multiple different machines (microservces) in order to separate failure modes.

Architecture

Node Purpose Software stack Key concerns
mqtt-ingest Terminate MQTT TLS, accept device connections, route messages into the time-series store HiveMQ CE (broker) + Telegraf (MQTT→Postgres bridge) Single-node; HA via two instances behind a TCP load balancer (devices reconnect). EMQX an option if BSL is acceptable. Telegraf colocated, writes over network to db-host
db-host All persistent state: telemetry hypertables, app tables, Supabase Auth auth schema Postgres 17 + TimescaleDB extension + PgBouncer + pgBackRest The only node where ops mistakes lose data. Continuous WAL archive to S3-compatible storage, daily base backups, synchronous streaming replica, monthly restore drill
app-host Serve static SPA, run Go backend, run Supabase Auth (GoTrue) container Caddy (reverse proxy + TLS + static files) + Go backend (systemd) + supabase/auth (Docker) Stateless — scale horizontally behind any load balancer. Caddy handles Let's Encrypt. JWT_SECRET shared between Go backend and GoTrue

The other assumptions still holding:

  • based on nix Flakes (NixOS everywhere)
  • Static REACT App - A static vite app that connects to the GO REST backend. Strictly NO SSR.
  • Expose all required services via Caddy.
  • All nix configuration files for the required services, backend and frontend code should live in this single monorepository
  • There should be an easy way to start a local development instance of the project (on a nixos machine)

To your questions

  1. Supabase Scope: Full
  2. The domain is ge-bos.de . I want to use subdomains like ingest.ge-bos.de and app.ge-bos.de
  3. Auth flow is handled by Supabase. We need to use Row based permissions on the psql data. Not sure how this works with the ingested data
  4. I would like to use Huma in order to generate OpenAPI specs
  5. With secrets management, I want to go the simpler route.
  6. deply-rs seems like the simpler choice for deployments. Perhaps we could tie that up nicely with gitea actions?
  7. I will setup a Gitea runner on the same host as the gitea server
  8. We can assume that the local dev will always be using NixOS
  9. For migrations let's use the out of the box supabase migrations

Let's ideate on this together!

Ok thank you. I've made a more detailed plan about this. We need multiple different machines (microservces) in order to separate failure modes. **Architecture** | Node | Purpose | Software stack | Key concerns | | --- | --- | --- | --- | | **mqtt-ingest** | Terminate MQTT TLS, accept device connections, route messages into the time-series store | HiveMQ CE (broker) + Telegraf (MQTT→Postgres bridge) | Single-node; HA via two instances behind a TCP load balancer (devices reconnect). EMQX an option if BSL is acceptable. Telegraf colocated, writes over network to db-host | | **db-host** | All persistent state: telemetry hypertables, app tables, Supabase Auth `auth` schema | Postgres 17 + TimescaleDB extension + PgBouncer + pgBackRest | The only node where ops mistakes lose data. Continuous WAL archive to S3-compatible storage, daily base backups, synchronous streaming replica, monthly restore drill | | **app-host** | Serve static SPA, run Go backend, run Supabase Auth (GoTrue) container | Caddy (reverse proxy + TLS + static files) + Go backend (systemd) + `supabase/auth` (Docker) | Stateless — scale horizontally behind any load balancer. Caddy handles Let's Encrypt. `JWT_SECRET` shared between Go backend and GoTrue | **The other assumptions still holding:** - based on nix Flakes (NixOS everywhere) - Static REACT App - A static vite app that connects to the GO REST backend. Strictly NO SSR. - Expose all required services via Caddy. - All nix configuration files for the required services, backend and frontend code should live in this single monorepository - There should be an easy way to start a local development instance of the project (on a nixos machine) **To your questions** 1. Supabase Scope: Full 2. The domain is ge-bos.de . I want to use subdomains like ingest.ge-bos.de and app.ge-bos.de 3. Auth flow is handled by Supabase. We need to use Row based permissions on the psql data. Not sure how this works with the ingested data 4. I would like to use Huma in order to generate OpenAPI specs 5. With secrets management, I want to go the simpler route. 6. deply-rs seems like the simpler choice for deployments. Perhaps we could tie that up nicely with gitea actions? 7. I will setup a Gitea runner on the same host as the gitea server 8. We can assume that the local dev will always be using NixOS 9. For migrations let's use the out of the box supabase migrations Let's ideate on this together!
Owner

@Klaus Please respond to my request

@Klaus Please respond to my request
Owner

@Klaus Can you hear me?

@Klaus Can you hear me?
Author
Collaborator

Loud and clear. Ready when you are.

— Klaus

Loud and clear. Ready when you are. — Klaus
Owner

@Klaus Please continue our discussion about the system architecture above

@Klaus Please continue our discussion about the system architecture above
Author
Collaborator

Good — splitting failure domains across three hosts makes sense. A few things to refine before we commit.

Things I'd push back on or want to confirm

1. HiveMQ CE HA isn't really HA. Clustering is an Enterprise feature; CE is single-node. "Two instances behind a TCP LB" with QoS 1/2 means duplicated messages (best case) or lost sessions on failover (worst case) — devices reconnect to whichever node the LB picks and their persistent session lives on the other one. Realistic options:

  • Accept single-node with fast restart + persistent session storage on disk; devices reconnect and replay. Probably fine for the actual SLA we need.
  • Switch broker to NanoMQ, Mosquitto with bridging, or EMQX (BSL — only restrictive if we offer it as a multi-tenant SaaS, which we don't).
  • Active/standby with shared storage for session state and a VIP — operationally heavier than it sounds.

My recommendation: single mqtt-ingest with persistent sessions, accept ~30s of reconnection on restart. Revisit if device count crosses ~10k.

2. Supabase Auth in Docker contradicts "avoid docker compose / prefer systemd." Either:

  • Package GoTrue as a Nix derivation + NixOS module (it's a Go binary — straightforward, I'd do this), or
  • Run it as a systemd-nspawn unit if Docker is purely about the upstream image, or
  • Accept Docker just for GoTrue and document it as the one exception.

I'd vote (a). Same for postgrest, realtime, storage-api, imgproxy — all already packageable or in nixpkgs.

3. RLS on ingested telemetry — the actual mechanic. Two viable patterns:

  • Topic-encoded tenancy: MQTT topics shaped t/<tenant_id>/d/<device_id>/.... Telegraf's topic_parser extracts tenant_id as a tag, lands as a column on the hypertable. RLS policy: USING (tenant_id = current_setting('app.tenant_id')::uuid). The Go backend sets app.tenant_id from the JWT before each query (via SET LOCAL in a transaction or pgx BeforeAcquire). Telegraf itself writes as a BYPASSRLS role — it has to, because at write time there's no session user.
  • Device-registry lookup: A devices(device_id, tenant_id) table, RLS uses a subquery. Slower, but doesn't trust the topic structure.

I'd start with the first. The trust boundary is "what tenants can publish to what topics" which we enforce at the broker via ACL — HiveMQ CE supports file-based ACLs, or we can write a small auth extension that hits Postgres.

4. Where does the Go/Huma backend sit relative to PostgREST? Original sketch had PostgREST as the main REST surface. If you want Huma for OpenAPI generation, that implies the Go backend is the public REST API and PostgREST goes away (or is internal-only). I'd actually drop PostgREST entirely in favor of Huma + pgx + sqlc — cleaner trust boundary, real types, no PostgREST schema-cache reload gymnastics. Confirm?

5. Synchronous replica on db-host. Sync replication blocks writes when the replica is unreachable, which is bad for an MQTT ingest pipeline that can't pause. Recommended setup: one async streaming replica + pgBackRest WAL archive to S3-compatible (Hetzner Object Storage or Backblaze B2). RPO ≈ archive interval, RTO ≈ restore time. Sync only if you genuinely need RPO=0; for telemetry, async is almost always correct.

New decisions needed

A. Subdomain map. I'd propose:

  • app.ge-bos.de → static SPA (Caddy on app-host)
  • api.ge-bos.de → Go/Huma backend
  • auth.ge-bos.de → GoTrue
  • realtime.ge-bos.de → Realtime (if we keep it)
  • storage.ge-bos.de → Storage API (if we keep it)
  • ingest.ge-bos.de:8883 → MQTT/TLS (mqtt-ingest)
  • ge-bos.de → marketing redirect to app? or same as app?

Or collapse api/auth/realtime/storage under app.ge-bos.de/{api,auth,realtime,storage} — fewer certs, simpler CORS. Preference?

B. Local dev model. Three-node prod, but local dev was "easy to start on a NixOS machine." Two options:

  • Single-VM dev: every service on one machine via process-compose-flake, fast iteration. Doesn't exercise the network topology.
  • Multi-VM dev: nixosTest-style three-VM setup mirroring prod. Realistic but slow to spin up.

Recommend single-VM for day-to-day, with the multi-VM setup available as nix run .#integration-test for pre-deploy smoke. Agree?

C. Secrets — "simpler route." Concretely that means EnvironmentFile=/etc/gebos/secrets.env per host, mode 0400, provisioned manually on each host the first time. The file is NOT in the repo or the Nix store. Deploys read it at service start. Cheap and works; downside is rotation is a manual ssh + restart. OK with this?

D. Telegraf vs. small Go ingester. Telegraf is fine if the payload is "JSON or line protocol → columns." If devices send anything that needs decoding (CBOR, protobuf, custom binary, calibration, deduplication), a 200-line Go service is easier to reason about than a Telegraf config. What's the actual device payload format?

E. Object storage for backups + Supabase Storage. Both pgBackRest and Supabase Storage want an S3-compatible bucket. Same provider, separate buckets? Hetzner Object Storage is the obvious pick since the hosts are likely Hetzner — confirm?

F. Deploy topology. deploy-rs with one flake exposing:

nixosConfigurations.{mqtt-ingest, db-host, app-host}
deploy.nodes.{mqtt-ingest, db-host, app-host}

Gitea Actions workflow on push to main: build all three closures, run deploy-rs per host in dependency order (db → app → ingest), each with auto-rollback. The Gitea runner needs SSH + Nix-store push to each host. Sound right?

My recommended highest-leverage decisions to lock first

A (subdomains), C (secrets file path), D (payload format — drives whether Telegraf survives), and the answer to #4 above (PostgREST stays or goes). The rest can iterate.

Once those four are settled I think we have enough to draft the actual flake layout and you can green-light implementation.

— Klaus

Good — splitting failure domains across three hosts makes sense. A few things to refine before we commit. ## Things I'd push back on or want to confirm **1. HiveMQ CE HA isn't really HA.** Clustering is an Enterprise feature; CE is single-node. "Two instances behind a TCP LB" with QoS 1/2 means duplicated messages (best case) or lost sessions on failover (worst case) — devices reconnect to whichever node the LB picks and their persistent session lives on the other one. Realistic options: - **Accept single-node** with fast restart + persistent session storage on disk; devices reconnect and replay. Probably fine for the actual SLA we need. - **Switch broker** to NanoMQ, Mosquitto with bridging, or EMQX (BSL — only restrictive if we offer it as a multi-tenant SaaS, which we don't). - **Active/standby** with shared storage for session state and a VIP — operationally heavier than it sounds. My recommendation: single `mqtt-ingest` with persistent sessions, accept ~30s of reconnection on restart. Revisit if device count crosses ~10k. **2. Supabase Auth in Docker contradicts "avoid docker compose / prefer systemd."** Either: - Package GoTrue as a Nix derivation + NixOS module (it's a Go binary — straightforward, I'd do this), or - Run it as a `systemd-nspawn` unit if Docker is purely about the upstream image, or - Accept Docker just for GoTrue and document it as the one exception. I'd vote (a). Same for `postgrest`, `realtime`, `storage-api`, `imgproxy` — all already packageable or in nixpkgs. **3. RLS on ingested telemetry — the actual mechanic.** Two viable patterns: - **Topic-encoded tenancy**: MQTT topics shaped `t/<tenant_id>/d/<device_id>/...`. Telegraf's `topic_parser` extracts `tenant_id` as a tag, lands as a column on the hypertable. RLS policy: `USING (tenant_id = current_setting('app.tenant_id')::uuid)`. The Go backend sets `app.tenant_id` from the JWT before each query (via `SET LOCAL` in a transaction or pgx `BeforeAcquire`). Telegraf itself writes as a `BYPASSRLS` role — it has to, because at write time there's no session user. - **Device-registry lookup**: A `devices(device_id, tenant_id)` table, RLS uses a subquery. Slower, but doesn't trust the topic structure. I'd start with the first. The trust boundary is "what tenants can publish to what topics" which we enforce at the broker via ACL — HiveMQ CE supports file-based ACLs, or we can write a small auth extension that hits Postgres. **4. Where does the Go/Huma backend sit relative to PostgREST?** Original sketch had PostgREST as the main REST surface. If you want Huma for OpenAPI generation, that implies the Go backend *is* the public REST API and PostgREST goes away (or is internal-only). I'd actually drop PostgREST entirely in favor of Huma + pgx + sqlc — cleaner trust boundary, real types, no PostgREST schema-cache reload gymnastics. Confirm? **5. Synchronous replica on db-host.** Sync replication blocks writes when the replica is unreachable, which is bad for an MQTT ingest pipeline that can't pause. Recommended setup: one async streaming replica + pgBackRest WAL archive to S3-compatible (Hetzner Object Storage or Backblaze B2). RPO ≈ archive interval, RTO ≈ restore time. Sync only if you genuinely need RPO=0; for telemetry, async is almost always correct. ## New decisions needed **A. Subdomain map.** I'd propose: - `app.ge-bos.de` → static SPA (Caddy on app-host) - `api.ge-bos.de` → Go/Huma backend - `auth.ge-bos.de` → GoTrue - `realtime.ge-bos.de` → Realtime (if we keep it) - `storage.ge-bos.de` → Storage API (if we keep it) - `ingest.ge-bos.de:8883` → MQTT/TLS (mqtt-ingest) - `ge-bos.de` → marketing redirect to `app`? or same as app? Or collapse api/auth/realtime/storage under `app.ge-bos.de/{api,auth,realtime,storage}` — fewer certs, simpler CORS. Preference? **B. Local dev model.** Three-node prod, but local dev was "easy to start on a NixOS machine." Two options: - **Single-VM dev**: every service on one machine via `process-compose-flake`, fast iteration. Doesn't exercise the network topology. - **Multi-VM dev**: `nixosTest`-style three-VM setup mirroring prod. Realistic but slow to spin up. Recommend single-VM for day-to-day, with the multi-VM setup available as `nix run .#integration-test` for pre-deploy smoke. Agree? **C. Secrets — "simpler route."** Concretely that means `EnvironmentFile=/etc/gebos/secrets.env` per host, mode 0400, provisioned manually on each host the first time. The file is NOT in the repo or the Nix store. Deploys read it at service start. Cheap and works; downside is rotation is a manual ssh + restart. OK with this? **D. Telegraf vs. small Go ingester.** Telegraf is fine if the payload is "JSON or line protocol → columns." If devices send anything that needs decoding (CBOR, protobuf, custom binary, calibration, deduplication), a 200-line Go service is easier to reason about than a Telegraf config. What's the actual device payload format? **E. Object storage for backups + Supabase Storage.** Both pgBackRest and Supabase Storage want an S3-compatible bucket. Same provider, separate buckets? Hetzner Object Storage is the obvious pick since the hosts are likely Hetzner — confirm? **F. Deploy topology.** `deploy-rs` with one flake exposing: ``` nixosConfigurations.{mqtt-ingest, db-host, app-host} deploy.nodes.{mqtt-ingest, db-host, app-host} ``` Gitea Actions workflow on push to `main`: build all three closures, run `deploy-rs` per host in dependency order (db → app → ingest), each with auto-rollback. The Gitea runner needs SSH + Nix-store push to each host. Sound right? ## My recommended highest-leverage decisions to lock first A (subdomains), C (secrets file path), D (payload format — drives whether Telegraf survives), and the answer to #4 above (PostgREST stays or goes). The rest can iterate. Once those four are settled I think we have enough to draft the actual flake layout and you can green-light implementation. — Klaus
Owner

@Klaus These are good points:

  1. I agree on the single-node HiveMQ: "Accept single-node with fast restart + persistent session storage on disk; devices reconnect and replay. Probably fine for the actual SLA we need.:
  2. I want to go with the vanilla Supabase setup method. Therefore is Docker is fine. No custom setup, but rather the default recommended method.
  3. I think the Topic-encoded tenancy is a good choice. This means we save the tenant-id on the ingest table as a column and are then able to apply standard RLS with the requirement that the requestors tenant-id matches that of the row requested?
  4. Let's keep the option for WAL backups to S3 open. But for the MVP we do not need to be concerned about backups for now.

Subdomain map
Your recommendation looks good, we do not need realtime or storage endpoints for now. And yes, ge-bos.de without subdomain will be used for marketing.

Local dev model
Single-VM dev sounds like the easier approach.

Secrets — "simpler route." EnvironmentFile=/etc/gebos/secrets.env per host is good

Telegraf vs. small Go ingester.
Let's go with our own GO ingester. This way we have full control. I'd think this should be less than 200 lines total.

Object storage for backups + Supabase Storage.
Both is not required for now.

Deploy topology
Yes, "workflow on push to main: build all three closures" sounds good

@Klaus These are good points: 1. I agree on the single-node HiveMQ: "Accept single-node with fast restart + persistent session storage on disk; devices reconnect and replay. Probably fine for the actual SLA we need.: 2. I want to go with the vanilla Supabase setup method. Therefore is Docker is fine. No custom setup, but rather the default recommended method. 3. I think the Topic-encoded tenancy is a good choice. This means we save the tenant-id on the ingest table as a column and are then able to apply standard RLS with the requirement that the requestors tenant-id matches that of the row requested? 4. Let's keep the option for WAL backups to S3 open. But for the MVP we do not need to be concerned about backups for now. **Subdomain map** Your recommendation looks good, we do not need realtime or storage endpoints for now. And yes, ge-bos.de without subdomain will be used for marketing. **Local dev model** Single-VM dev sounds like the easier approach. **Secrets** — "simpler route." EnvironmentFile=/etc/gebos/secrets.env per host is good **Telegraf vs. small Go ingester**. Let's go with our own GO ingester. This way we have full control. I'd think this should be less than 200 lines total. **Object storage for backups + Supabase Storage.** Both is not required for now. **Deploy topology** Yes, "workflow on push to main: build all three closures" sounds good
Author
Collaborator

Locked in:

  • Single-node HiveMQ CE with persistent sessions on disk
  • Topic-encoded tenancy → tenant_id column on the hypertable, standard RLS (USING (tenant_id = current_setting('app.tenant_id')::uuid)). Backend sets app.tenant_id from the JWT per-request. Yes, that's exactly the mechanic.
  • WAL/S3 backups deferred past MVP — pgBackRest hooks left wired but disabled
  • Subdomains: app., api., auth., ingest. — no realtime/storage. ge-bos.de apex = marketing (separate concern, not in this repo's deploy)
  • Single-VM local dev via process-compose-flake
  • Secrets: EnvironmentFile=/etc/gebos/secrets.env, provisioned out-of-band, mode 0400
  • Custom Go MQTT→Postgres ingester (target <200 LOC), Telegraf dropped
  • Deploy: deploy-rs from Gitea Actions on push to main, three closures, db → app → ingest order

Three things "vanilla Supabase via Docker" forces us to settle before I draft the flake — they're not nits, they change the topology:

1. Where does the Supabase stack actually run?
The official self-hosting bundle is a docker-compose.yml that brings its own Postgres container. Two choices:

  • (a) Run the full compose stack on app-host, including its bundled Postgres. Then db-host becomes the dedicated TimescaleDB for telemetry only, and Supabase's auth/metadata lives in the compose-bundled Postgres on app-host. Two Postgres instances, two backup stories later.
  • (b) Run the compose stack but point its services at external db-host Postgres (override POSTGRES_HOST etc., disable the bundled db service). Less "vanilla" — you're editing the compose file — but one Postgres to back up.

I'd recommend (b) despite the "vanilla" friction; one Postgres is much simpler operationally and RLS on telemetry needs to share the auth schema anyway. Your call though.

2. PostgREST vs. Huma — pick one public REST surface.
Vanilla Supabase ships PostgREST at /rest/v1/*. You also want Huma for OpenAPI. Running both as public APIs is confusing (two ways to read the same data, two auth integrations to keep in sync). Options:

  • PostgREST stays internal / unused, Go+Huma is the public API at api.ge-bos.de. Clean.
  • PostgREST is the CRUD surface, Go+Huma only owns endpoints that need custom logic (ingest, aggregations, exports). Mixed.

I'd recommend the first. Confirm?

3. GoTrue exposure.
Vanilla compose has Kong fronting all Supabase services on a single port. Do we:

  • Put Caddy in front of Kong and route auth.ge-bos.de → Kong → GoTrue, or
  • Bypass Kong, expose GoTrue's port directly, Caddy → GoTrue?

Bypassing Kong is simpler and we don't use most of what Kong gives us (rate limiting, API keys for anon/service roles — actually we do use the anon/service role keys; Kong injects them). So: keep Kong. auth.ge-bos.de → Kong's auth route. Same pattern for any other Supabase service we expose later.

Answer 1/2/3 and I'll draft the flake layout + a skeleton PR for your review before any real implementation.

— Klaus

Locked in: - Single-node HiveMQ CE with persistent sessions on disk - Topic-encoded tenancy → `tenant_id` column on the hypertable, standard RLS (`USING (tenant_id = current_setting('app.tenant_id')::uuid)`). Backend sets `app.tenant_id` from the JWT per-request. Yes, that's exactly the mechanic. - WAL/S3 backups deferred past MVP — pgBackRest hooks left wired but disabled - Subdomains: `app.`, `api.`, `auth.`, `ingest.` — no realtime/storage. `ge-bos.de` apex = marketing (separate concern, not in this repo's deploy) - Single-VM local dev via `process-compose-flake` - Secrets: `EnvironmentFile=/etc/gebos/secrets.env`, provisioned out-of-band, mode 0400 - Custom Go MQTT→Postgres ingester (target <200 LOC), Telegraf dropped - Deploy: `deploy-rs` from Gitea Actions on push to `main`, three closures, db → app → ingest order Three things "vanilla Supabase via Docker" forces us to settle before I draft the flake — they're not nits, they change the topology: **1. Where does the Supabase stack actually run?** The official self-hosting bundle is a `docker-compose.yml` that brings *its own* Postgres container. Two choices: - **(a)** Run the full compose stack on `app-host`, including its bundled Postgres. Then `db-host` becomes the dedicated TimescaleDB for telemetry only, and Supabase's auth/metadata lives in the compose-bundled Postgres on `app-host`. Two Postgres instances, two backup stories later. - **(b)** Run the compose stack but point its services at external `db-host` Postgres (override `POSTGRES_HOST` etc., disable the bundled `db` service). Less "vanilla" — you're editing the compose file — but one Postgres to back up. I'd recommend (b) despite the "vanilla" friction; one Postgres is much simpler operationally and RLS on telemetry needs to share the auth schema anyway. Your call though. **2. PostgREST vs. Huma — pick one public REST surface.** Vanilla Supabase ships PostgREST at `/rest/v1/*`. You also want Huma for OpenAPI. Running both as public APIs is confusing (two ways to read the same data, two auth integrations to keep in sync). Options: - PostgREST stays internal / unused, Go+Huma is *the* public API at `api.ge-bos.de`. Clean. - PostgREST is the CRUD surface, Go+Huma only owns endpoints that need custom logic (ingest, aggregations, exports). Mixed. I'd recommend the first. Confirm? **3. GoTrue exposure.** Vanilla compose has Kong fronting all Supabase services on a single port. Do we: - Put Caddy in front of Kong and route `auth.ge-bos.de` → Kong → GoTrue, or - Bypass Kong, expose GoTrue's port directly, Caddy → GoTrue? Bypassing Kong is simpler and we don't use most of what Kong gives us (rate limiting, API keys for anon/service roles — actually we *do* use the anon/service role keys; Kong injects them). So: keep Kong. `auth.ge-bos.de` → Kong's auth route. Same pattern for any other Supabase service we expose later. Answer 1/2/3 and I'll draft the flake layout + a skeleton PR for your review before any real implementation. — Klaus
Owner

@Klaus
Where does the Supabase stack actually run?
My choice is (b) Run the compose stack but point its services at external db-host Postgres.

PostgREST vs. Huma — pick one public REST surface.
I'd like to use PostgREST for most queries from the frontend. But it cannot handle all requests, for example custom aggregations of chart data. Is there a way that we can unify this better? Perhaps there is a better choice in this case than Huma?

GoTrue exposure.
I mainly want to use Caddy for the easy TLS certificate management. Are there any downsides to but Caddy in front of Kong? What does Kong provide for us?

@Klaus **Where does the Supabase stack actually run?** My choice is (b) Run the compose stack but point its services at external db-host Postgres. **PostgREST vs. Huma — pick one public REST surface.** I'd like to use PostgREST for most queries from the frontend. But it cannot handle all requests, for example custom aggregations of chart data. Is there a way that we can unify this better? Perhaps there is a better choice in this case than Huma? **GoTrue exposure.** I mainly want to use Caddy for the easy TLS certificate management. Are there any downsides to but Caddy in front of Kong? What does Kong provide for us?
Author
Collaborator

Good questions — 2 and 3 are worth digging into.

1. Supabase on external db-host — locked

Concretely: in the compose file, remove (or profiles: out) the bundled db service, then override on every Supabase service that talks to Postgres:

POSTGRES_HOST=db-host.internal
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_PASSWORD=<from secrets.env>

db-host Postgres needs the Supabase-required extensions enabled (pgjwt, pgcrypto, pgsodium, supabase_vault, pg_graphql) and the auth, storage, realtime, _analytics, _supavisor schemas pre-created with the right owners. The Supabase docker repo has an init SQL bundle we'll copy into the db-host NixOS module's initialScript.

One Postgres, one backup story when we get there. Good call.

2. PostgREST as primary, with a clean escape hatch for aggregations

You don't need Huma for this. The Supabase-native answer is /rpc/ endpoints backed by SQL functions — you stay in one API, one client (supabase-js / postgrest-js), one auth model, RLS still applies.

Pattern:

create or replace function chart_daily_energy(
  p_device_id uuid,
  p_from timestamptz,
  p_to   timestamptz
) returns table (bucket timestamptz, kwh numeric)
language sql stable
security invoker  -- runs as the calling user, RLS enforced
as $$
  select time_bucket('1 day', ts) as bucket,
         sum(value) / 1000.0      as kwh
  from   telemetry
  where  device_id = p_device_id
    and  ts >= p_from and ts < p_to
  group  by 1
  order  by 1;
$$;

Frontend then calls:

const { data } = await supabase.rpc('chart_daily_energy', {
  p_device_id: id, p_from: a, p_to: b,
});

That covers the large majority of "PostgREST can't express this" cases:

  • Time bucketing / window functions → SQL function returning a table (above).
  • Heavy precomputed aggregates → continuous aggregates (TimescaleDB feature) materialized in the background, then exposed as a regular table/view through PostgREST. Free at query time.
  • Cross-table joins PostgREST struggles with → a view is queryable directly; PostgREST treats it like a table.
  • Mutations with logicsecurity invoker function in /rpc/.

When you'd actually want a separate Go service instead of /rpc/:

  1. The logic isn't expressible in SQL (calling external APIs, parsing binary blobs, ML inference).
  2. You need streaming responses or non-JSON formats (CSV export of millions of rows).
  3. CPU-heavy compute you don't want hogging a Postgres connection.

For Gebos right now, I think none of those apply except possibly (2) eventually. So my recommendation:

  • Public REST surface = PostgREST only (via Kong, at api.ge-bos.de).
  • api.ge-bos.de/rpc/* for custom queries via SQL functions.
  • Drop Huma from the plan entirely — it was the answer to a problem PostgREST + RPC already solves.
  • The Go service we keep is the MQTT ingester, internal-only, not exposed publicly. It writes with a BYPASSRLS role.

OpenAPI: PostgREST auto-generates an OpenAPI 2.0 spec at GET / of its base URL. Not as nice as Huma's output but it exists and stays in sync automatically.

If we ever do need (1)–(3) above, we add a single Go endpoint then — easy to bolt on later, no need to design for it now.

3. Kong — keep it, Caddy in front is fine

What Kong does in the vanilla Supabase compose stack:

  • API key gating. Kong checks the apikey / Authorization: Bearer <anon|service_role> header against the two well-known JWTs, and rejects unauthenticated traffic before it ever reaches PostgREST/GoTrue. Without Kong, anything internet-facing is wide open at the network layer (it'd still hit RLS, but no defence in depth).
  • Path routing under one origin. /auth/v1/* → GoTrue, /rest/v1/* → PostgREST, /storage/v1/* → Storage, /realtime/v1/* → Realtime. supabase-js is hardcoded to this layout.
  • Header rewriting. Strips the apikey and forwards the JWT as Authorization to upstreams in the form they expect.
  • CORS + per-route rate limits (configurable).

Downsides of Caddy → Kong → service:

  • One extra hop. Negligible — both are in-process Go/OpenResty on localhost, sub-millisecond.
  • Two configs to keep coherent (Caddyfile + kong.yml). Real but small.
  • Kong's config is YAML you have to learn. Annoying once.

Downsides of bypassing Kong (Caddy → GoTrue directly):

  • You lose the API-key check — supabase-js will still send the apikey header, but nothing validates it. Effectively anyone on the internet can hit GoTrue's signup/login endpoints unauthenticated-by-apikey. (GoTrue itself has rate limits and captcha hooks, so not catastrophic, but it's an unforced downgrade.)
  • You'd have to either (a) reconfigure supabase-js to point at non-standard paths, or (b) replicate Kong's path layout in Caddy's reverse proxy block. Doable, but you're now maintaining a Caddy file that mimics Kong, defeating "vanilla."
  • Any future Supabase service (Storage, Realtime, Edge Functions) means another Caddy block instead of "free, Kong already routes it."

Recommendation: Caddy terminates TLS for api.ge-bos.de and auth.ge-bos.de, proxies to Kong on a private port (e.g., 127.0.0.1:8000), Kong fans out to GoTrue / PostgREST / etc. You still get the Caddy ACME flow you wanted; Kong does the API-gateway job it's designed for. The "two proxies" cost is real but tiny, and you don't lose features.

Concretely:

api.ge-bos.de  → Caddy (TLS, ACME) → 127.0.0.1:8000 (Kong) → /rest/v1/* → PostgREST
                                                            → /rpc/*    → PostgREST
auth.ge-bos.de → Caddy (TLS, ACME) → 127.0.0.1:8000 (Kong) → /auth/v1/* → GoTrue

(Or collapse onto a single api.ge-bos.de with /auth/v1 and /rest/v1 paths — that's actually what the Supabase JS client expects out of the box if you set one SUPABASE_URL. Worth considering — simpler client config, one cert, one CORS origin. Up to you.)


What I'd need from you to start drafting

  1. Confirm: drop Huma, PostgREST + /rpc/ is the public surface.
  2. Confirm: keep Kong, Caddy in front for TLS.
  3. One subdomain (api.ge-bos.de for everything Supabase) or two (api. + auth.)? I lean one — matches supabase-js defaults.

Once those three are settled I'll open the skeleton PR (flake, three host modules, compose unit for Supabase on app-host pointed at db-host, ingester stub) for you to review before any real implementation lands.

— Klaus

Good questions — 2 and 3 are worth digging into. ## 1. Supabase on external `db-host` — locked Concretely: in the compose file, remove (or `profiles:` out) the bundled `db` service, then override on every Supabase service that talks to Postgres: ``` POSTGRES_HOST=db-host.internal POSTGRES_PORT=5432 POSTGRES_DB=postgres POSTGRES_PASSWORD=<from secrets.env> ``` `db-host` Postgres needs the Supabase-required extensions enabled (`pgjwt`, `pgcrypto`, `pgsodium`, `supabase_vault`, `pg_graphql`) and the `auth`, `storage`, `realtime`, `_analytics`, `_supavisor` schemas pre-created with the right owners. The Supabase docker repo has an `init` SQL bundle we'll copy into the db-host NixOS module's `initialScript`. One Postgres, one backup story when we get there. Good call. ## 2. PostgREST as primary, with a clean escape hatch for aggregations You don't need Huma for this. The Supabase-native answer is **`/rpc/` endpoints backed by SQL functions** — you stay in one API, one client (`supabase-js` / `postgrest-js`), one auth model, RLS still applies. Pattern: ```sql create or replace function chart_daily_energy( p_device_id uuid, p_from timestamptz, p_to timestamptz ) returns table (bucket timestamptz, kwh numeric) language sql stable security invoker -- runs as the calling user, RLS enforced as $$ select time_bucket('1 day', ts) as bucket, sum(value) / 1000.0 as kwh from telemetry where device_id = p_device_id and ts >= p_from and ts < p_to group by 1 order by 1; $$; ``` Frontend then calls: ```ts const { data } = await supabase.rpc('chart_daily_energy', { p_device_id: id, p_from: a, p_to: b, }); ``` That covers the large majority of "PostgREST can't express this" cases: - **Time bucketing / window functions** → SQL function returning a table (above). - **Heavy precomputed aggregates** → continuous aggregates (TimescaleDB feature) materialized in the background, then exposed as a regular table/view through PostgREST. Free at query time. - **Cross-table joins PostgREST struggles with** → a `view` is queryable directly; PostgREST treats it like a table. - **Mutations with logic** → `security invoker` function in `/rpc/`. When you'd actually want a separate Go service instead of `/rpc/`: 1. The logic isn't expressible in SQL (calling external APIs, parsing binary blobs, ML inference). 2. You need streaming responses or non-JSON formats (CSV export of millions of rows). 3. CPU-heavy compute you don't want hogging a Postgres connection. For Gebos right now, I think *none* of those apply except possibly (2) eventually. So my recommendation: - **Public REST surface = PostgREST only** (via Kong, at `api.ge-bos.de`). - **`api.ge-bos.de/rpc/*`** for custom queries via SQL functions. - **Drop Huma from the plan entirely** — it was the answer to a problem PostgREST + RPC already solves. - The Go service we keep is the **MQTT ingester**, internal-only, not exposed publicly. It writes with a `BYPASSRLS` role. OpenAPI: PostgREST auto-generates an OpenAPI 2.0 spec at `GET /` of its base URL. Not as nice as Huma's output but it exists and stays in sync automatically. If we ever do need (1)–(3) above, we add a single Go endpoint then — easy to bolt on later, no need to design for it now. ## 3. Kong — keep it, Caddy in front is fine What Kong does in the vanilla Supabase compose stack: - **API key gating.** Kong checks the `apikey` / `Authorization: Bearer <anon|service_role>` header against the two well-known JWTs, and rejects unauthenticated traffic before it ever reaches PostgREST/GoTrue. Without Kong, anything internet-facing is wide open at the network layer (it'd still hit RLS, but no defence in depth). - **Path routing under one origin.** `/auth/v1/*` → GoTrue, `/rest/v1/*` → PostgREST, `/storage/v1/*` → Storage, `/realtime/v1/*` → Realtime. `supabase-js` is hardcoded to this layout. - **Header rewriting.** Strips the `apikey` and forwards the JWT as `Authorization` to upstreams in the form they expect. - **CORS + per-route rate limits** (configurable). Downsides of Caddy → Kong → service: - One extra hop. Negligible — both are in-process Go/OpenResty on localhost, sub-millisecond. - Two configs to keep coherent (`Caddyfile` + `kong.yml`). Real but small. - Kong's config is YAML you have to learn. Annoying once. Downsides of bypassing Kong (Caddy → GoTrue directly): - You lose the API-key check — `supabase-js` will still *send* the `apikey` header, but nothing validates it. Effectively anyone on the internet can hit GoTrue's signup/login endpoints unauthenticated-by-apikey. (GoTrue itself has rate limits and captcha hooks, so not catastrophic, but it's an unforced downgrade.) - You'd have to either (a) reconfigure `supabase-js` to point at non-standard paths, or (b) replicate Kong's path layout in Caddy's reverse proxy block. Doable, but you're now maintaining a Caddy file that mimics Kong, defeating "vanilla." - Any future Supabase service (Storage, Realtime, Edge Functions) means another Caddy block instead of "free, Kong already routes it." **Recommendation:** Caddy terminates TLS for `api.ge-bos.de` and `auth.ge-bos.de`, proxies to Kong on a private port (e.g., `127.0.0.1:8000`), Kong fans out to GoTrue / PostgREST / etc. You still get the Caddy ACME flow you wanted; Kong does the API-gateway job it's designed for. The "two proxies" cost is real but tiny, and you don't lose features. Concretely: ``` api.ge-bos.de → Caddy (TLS, ACME) → 127.0.0.1:8000 (Kong) → /rest/v1/* → PostgREST → /rpc/* → PostgREST auth.ge-bos.de → Caddy (TLS, ACME) → 127.0.0.1:8000 (Kong) → /auth/v1/* → GoTrue ``` (Or collapse onto a single `api.ge-bos.de` with `/auth/v1` and `/rest/v1` paths — that's actually what the Supabase JS client expects out of the box if you set one `SUPABASE_URL`. Worth considering — simpler client config, one cert, one CORS origin. Up to you.) --- ## What I'd need from you to start drafting 1. **Confirm: drop Huma, PostgREST + `/rpc/` is the public surface.** 2. **Confirm: keep Kong, Caddy in front for TLS.** 3. **One subdomain (`api.ge-bos.de` for everything Supabase) or two (`api.` + `auth.`)?** I lean one — matches `supabase-js` defaults. Once those three are settled I'll open the skeleton PR (flake, three host modules, compose unit for Supabase on app-host pointed at db-host, ingester stub) for you to review before any real implementation lands. — Klaus
Owner

@Klaus I think the choice of using RPC and ditching Huma is good. PostgREST + /rpc/ is the public surface.
Yes, then let's go with the setup of Caddy and Kong chained. In order to get the best of both worlds. The TLS termination via Caddy and the Auth via Kong. Also let's do a single subdomain for everything supabase. (What about the supabase dashboard?)

@Klaus I think the choice of using RPC and ditching Huma is good. PostgREST + /rpc/ is the public surface. Yes, then let's go with the setup of Caddy and Kong chained. In order to get the best of both worlds. The TLS termination via Caddy and the Auth via Kong. Also let's do a single subdomain for everything supabase. (What about the supabase dashboard?)
Author
Collaborator

Locked:

  • PostgREST + /rpc/ is the public REST surface, Huma dropped
  • Caddy (TLS / ACME) → Kong (API gateway, key check, path fan-out) → upstreams
  • Single subdomain: api.ge-bos.de for everything Supabase. supabase-js initialized with SUPABASE_URL=https://api.ge-bos.de, paths /auth/v1/*, /rest/v1/*, /rpc/* all routed by Kong

Studio (Supabase dashboard)

It's bundled in the vanilla compose stack (Next.js, port 3000 inside the network). Three real options, and the right answer depends on whether you want web access from anywhere or only from your laptop.

A. Don't expose it publicly — SSH tunnel when you need it. My recommendation.

  • ssh -L 3000:127.0.0.1:3000 app-host then open http://localhost:3000.
  • Why: Studio talks to Postgres with the service_role JWT — it intentionally bypasses RLS so admins can do admin things. Anything that reaches Studio's UI can read/write any tenant's data. The only thing in front of it in vanilla compose is HTTP Basic Auth with DASHBOARD_USERNAME / DASHBOARD_PASSWORD env vars, which is weak as the sole defence on the public internet.
  • Cost: one SSH command when you want to use it. For a solo-developer ops model this is genuinely fine.

B. Public at studio.ge-bos.de, behind real auth.

  • Caddy terminates TLS, then forward_auth to something stronger than basic — e.g., an OAuth2 proxy (oauth2-proxy against GitHub/Google), or Caddy's built-in basic_auth with an argon2 hash if you accept basic-auth's limits.
  • Don't expose Studio with only the bundled basic-auth on a public hostname.
  • Cost: one more service to run and configure; a second subdomain + cert (cheap, Caddy handles it).

C. Public at api.ge-bos.de/studio/* behind the same Caddy+Kong chain.

  • Possible, but Studio's Next.js asset paths assume it's served at the root; you'd be patching/configuring basePath and it's fiddly. Not worth it vs. (B).

My vote: (A) for now. Studio is in the compose file, but the unit is bound to 127.0.0.1:3000 on app-host, no public route, no Kong route. If you later want browser access from anywhere, we add (B) — it's a 20-line Caddy block and an oauth2-proxy systemd unit, no other changes.

If you want (B) from day one, tell me which IdP (GitHub? Google? a static password is also OK if you accept the limits) and I'll wire it in the initial PR.


Assuming (A), I have everything I need. Want me to open the skeleton PR now — flake at root, three nixosConfigurations (mqtt-ingest, db-host, app-host), Supabase compose unit on app-host pointed at db-host, Caddy→Kong wiring, Go ingester stub, deploy-rs config, Gitea Actions workflow — for review before any real implementation?

— Klaus

Locked: - PostgREST + `/rpc/` is the public REST surface, Huma dropped - Caddy (TLS / ACME) → Kong (API gateway, key check, path fan-out) → upstreams - Single subdomain: `api.ge-bos.de` for everything Supabase. `supabase-js` initialized with `SUPABASE_URL=https://api.ge-bos.de`, paths `/auth/v1/*`, `/rest/v1/*`, `/rpc/*` all routed by Kong ## Studio (Supabase dashboard) It's bundled in the vanilla compose stack (Next.js, port 3000 inside the network). Three real options, and the right answer depends on whether you want web access from anywhere or only from your laptop. **A. Don't expose it publicly — SSH tunnel when you need it.** My recommendation. - `ssh -L 3000:127.0.0.1:3000 app-host` then open `http://localhost:3000`. - Why: Studio talks to Postgres with the `service_role` JWT — it intentionally bypasses RLS so admins can do admin things. Anything that reaches Studio's UI can read/write any tenant's data. The only thing in front of it in vanilla compose is HTTP Basic Auth with `DASHBOARD_USERNAME` / `DASHBOARD_PASSWORD` env vars, which is weak as the sole defence on the public internet. - Cost: one SSH command when you want to use it. For a solo-developer ops model this is genuinely fine. **B. Public at `studio.ge-bos.de`, behind real auth.** - Caddy terminates TLS, then `forward_auth` to something stronger than basic — e.g., an OAuth2 proxy (`oauth2-proxy` against GitHub/Google), or Caddy's built-in `basic_auth` with an argon2 hash if you accept basic-auth's limits. - Don't expose Studio with only the bundled basic-auth on a public hostname. - Cost: one more service to run and configure; a second subdomain + cert (cheap, Caddy handles it). **C. Public at `api.ge-bos.de/studio/*` behind the same Caddy+Kong chain.** - Possible, but Studio's Next.js asset paths assume it's served at the root; you'd be patching/configuring `basePath` and it's fiddly. Not worth it vs. (B). My vote: **(A)** for now. Studio is in the compose file, but the unit is bound to `127.0.0.1:3000` on `app-host`, no public route, no Kong route. If you later want browser access from anywhere, we add (B) — it's a 20-line Caddy block and an `oauth2-proxy` systemd unit, no other changes. If you want **(B)** from day one, tell me which IdP (GitHub? Google? a static password is also OK if you accept the limits) and I'll wire it in the initial PR. --- Assuming **(A)**, I have everything I need. Want me to open the skeleton PR now — flake at root, three `nixosConfigurations` (`mqtt-ingest`, `db-host`, `app-host`), Supabase compose unit on `app-host` pointed at `db-host`, Caddy→Kong wiring, Go ingester stub, `deploy-rs` config, Gitea Actions workflow — for review before any real implementation? — Klaus
Owner

@Klaus For the studio: "Don't expose it publicly — SSH tunnel when you need it.". Yes please create a PR

@Klaus For the studio: "Don't expose it publicly — SSH tunnel when you need it.". Yes please create a PR
Author
Collaborator

Skeleton PR up as #23 (draft).

Studio is bound to 127.0.0.1 on app-host — no public route. Access from your laptop with ssh -L 3000:127.0.0.1:3000 app-host, as agreed.

PR description walks the layout and flags the TODOs that are intentionally deferred (real hardware configs, HiveMQ packaging, full upstream Supabase compose, etc.). Review the structure there and let me know what should move before I start filling in real implementation.

— Klaus

Skeleton PR up as #23 (draft). Studio is bound to `127.0.0.1` on `app-host` — no public route. Access from your laptop with `ssh -L 3000:127.0.0.1:3000 app-host`, as agreed. PR description walks the layout and flags the TODOs that are intentionally deferred (real hardware configs, HiveMQ packaging, full upstream Supabase compose, etc.). Review the structure there and let me know what should move before I start filling in real implementation. — Klaus
Sign in to join this conversation.
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lars/Gebos#21