# Contributing This is the configuration for one person's self-hosted development box, but issues and pull requests are welcome - especially ones that catch a security mistake or a wrong claim in a comment. Two things to read before you change anything: - **[SECURITY.md](SECURITY.md)** - several choices in this repo look like style and are actually the security boundary. Widening one `Path()` to a `PathPrefix()` publishes every container's environment variables. - **[README.md](README.md)** - what the box is and how the pieces fit. --- ## Running it locally **You need:** Linux (or WSL2), Docker Engine with the Compose plugin, [`just`](https://github.com/casey/just). **No resolver, no DNS setup, no hostnames** - access is `IP:port` and `just urls` prints the table. (`host/dnsmasq/` still holds the configuration for the retired name layer; as of 2026-08-12 nothing depends on it.) Node 24 is only needed if you are working on the portal front-end. `jq` and `tailscale` are optional; `just urls` degrades gracefully without them. ```sh cp .env.example .env # then fill it in - see the fork checklist in SECURITY.md just up # bring everything up, in dependency order just urls # print every address just doctor # health check the whole box ``` `just up` is not a single `docker compose up`. It creates the two shared networks and then brings the stacks up in an order that matters: `edge` (Traefik must exist before anything expects to be routed) → `auth` (oauth2-proxy must exist before routers reference its middleware) → `monitoring` → `data` → `mgmt` → `apps`. Each group also has its own recipe (`just up-edge`, `just up-data`, …) so you can restart one thing without cycling the box. **Always use `just`, never `docker compose` directly.** `just` loads the root `.env` through `set dotenv-load`. `docker compose` looks for a `.env` beside the compose file it was handed, finds none, and either silently falls back to the insecure defaults or aborts on a required variable. ### The rest of the workflow | Recipe | What it does | |---|---| | `just` | list every recipe | | `just ps` | what is running | | `just logs ` | follow one container's logs | | `just psql` / `just redis` | a shell on the dev database / cache | | `just backup` | run the backup the nightly timer also runs | | `just doctor` | containers, Prometheus targets, k8s nodes, disk/memory, backup freshness | | `just down` | stop everything, keep the volumes | | `just nuke` | stop everything **and delete every data volume** | `just nuke` sits one keystroke from `just down` in the recipe list and destroys all seven data volumes. It prompts for a literal `yes`. Do not remove that prompt. ### After any branch switch, recreate Traefik Traefik file-provider routes live in `edge/dynamic/`, bind-mounted into the container. A bind mount pins the host inode at container-creation time, and `git checkout` deletes and recreates that directory - so after switching branches, Traefik keeps reading an orphaned, empty directory and silently serves a stale in-memory config. It ran that way for five days once. Every edit to `edge/dynamic/` appears to have no effect. ```sh docker compose -f edge/compose.yml up -d --force-recreate ``` Do this after every checkout that touches `edge/dynamic/`, and any time a route change "doesn't apply". --- ## House rules These are the conventions the whole box depends on. A change that breaks one of them will be sent back. ### Add a browser-facing service with a published port and a `just urls` entry > **Rewritten 2026-08-12.** This section used to teach a two-label recipe that > gave a service a `.` hostname and **no** published port. That > recipe is dead: the name layer was deleted on 2026-08-12, Traefik holds zero > `Host()` rules, and such a router would register, report `enabled`, and match > nothing forever. If you find that recipe anywhere else, it is stale. ```yaml services: myapp: image: myorg/myapp networks: [default, devnet] # BOTH - see below ports: - "8099:8080" # HOST:CONTAINER - check the host port is free labels: # Optional. Portal discovery works with zero labels; these are polish. - dev.portal.name=My App - dev.portal.desc=What it does. ``` Then, in order: | Step | Why | |---|---| | Check the port is free - `just urls`, then `docker ps` | Ports are a flat namespace of 65,535 with no allocator, and everyone reaches for 3000, 8080, 5432. This check is the price of the model | | Add it to the `urls` recipe in the `justfile` | `just urls` is the only inventory of what is reachable. A service missing from it is a service nobody finds | | Give it a login | Use the shared `DEV_LOGIN_*` credential from `.env`, the way Grafana and Prometheus do. There is no edge auth to fall back on | | Join `devnet` as well as `default` | So Prometheus can scrape it and the portal can discover it | | **Do not** add `traefik.http.routers.*.rule=Host(...)` | There is no name to match | | **Do not** attach `sso@file`, and do not add a role gate on the side | See below | `edge/compose.yml` is the only container that should ever publish `:80`. Databases stay bound to `127.0.0.1` (see below). **Listing `networks:` on a service silently drops it off the compose default network.** Always `[default, devnet]`, or the service loses its own database. **The cost is worth naming.** The name layer existed because ports collide and names do not, and that argument was never wrong. It was abandoned because it had a single point of failure - the Tailscale split-DNS route - whose loss killed every address on the box at once while every container stayed healthy. Ports are ugly and they always work. See [docs/ARCHITECTURE.md § 4](docs/ARCHITECTURE.md#4-the-naming-convention-retired). ### SSO: what is attached, and what you should not attach SSO **is** enforced, on the routes that change things. The three role gates - `sso-viewer`, `sso-editor` and `sso-operator` - are defined once, in `edge/dynamic/bothy-gates.yml`, and attached (each followed by `sso-errors`) to every router in the five Bothy tier files tabled in [SECURITY.md § 1](SECURITY.md#1-sso-enforces-on-the-tiers-that-change-things-and-nowhere-else-yet). `scripts/checks/router-gates.sh` holds that table against `edge/dynamic/` in CI. What is attached to no router is the **bare** `sso` middleware in `edge/dynamic/auth.yml`: it asks only "is there a session", not "which role", and it fails closed - if oauth2-proxy is down, every router carrying it returns 500, including whichever one you would need to fix it. Extending SSO to the rest of the box (Prometheus first, then the portal and the catch-all) is a planned one-router-at-a-time exercise, not a per-service decision. So, for a new service: - **Give it its own login**, on the shared `DEV_LOGIN_*` credential. Every service outside the Bothy tiers is still reachable from the tailnet without passing an auth boundary, and that is the control it has. - **Do not attach `sso@file`.** - **A new Bothy route gets a role gate, never a bare `sso`** - a gate from `bothy-gates.yml` followed by `sso-errors`, in its tier file. That changes the boundary, so update the tables in SECURITY.md § 1 (and the copies `scripts/checks/router-gates.sh` lists), run that check, and say so in the PR. - **Never define a gate outside `bothy-gates.yml`.** Two definitions of one gate in one provider is a collision no single file decides. ### Never put a doubled brace in `edge/dynamic/`, not even in a comment Traefik renders every file in that directory as a Go template before it parses the YAML, and it does not skip comments. One doubled brace - a `docker inspect -f` format string in a comment is the way this happens - is evaluated as a template action, fails, and silently takes out the **whole file**: no routers, no middlewares, while the file on disk looks perfect. Cost a session on 2026-08-12. Use the `jq` form instead. Single braces (Traefik's own `{url}`) are fine. ### `.test`, never `.dev` - if a name layer ever returns Nothing on this box uses a name today. Kept because it is the constraint any replacement inherits: `.dev` is HSTS-preloaded, so browsers force HTTPS on it and a plain-HTTP page never loads at all. `.test` is reserved by RFC 6761. ### Databases bind to `127.0.0.1` explicitly `- "127.0.0.1:5432:5432"`, never `- "5432:5432"`. Containers reach them by name over `devnet`; humans reach them by SSH tunnel. ### Pin images No `:latest`, ever - least of all on the socket proxy or oauth2-proxy, which are the two containers that *are* the boundary. Prefer an exact version tag, and a digest for third-party images - written `name:tag@sha256:…`, never the digest alone: the digest decides what is pulled, the tag is what a human (and Dependabot, which bumps both together) reads. Use the multi-arch *index* digest (`docker buildx imagetools inspect `), not one platform's manifest. ### Don't add a port to the Windows portproxy scripts That layer under `host/windows/` is vestigial. Publish the port in the compose file instead, as above. ### Secrets Real values go in `.env`, which is gitignored. `.env.example` gets a placeholder and, if the variable is mandatory, the compose file gets `${VAR:?message}` so a missing value aborts the stack rather than starting an auth layer with no credentials. Never put a secret in a `command:` array. ### Comments carry the *why* Nearly every non-obvious line in this repo has a comment explaining the failure that produced it - a boot race, a CSRF cookie thrown away by an errors middleware, a bind-mount inode. Keep that up. If you change a line that has such a comment, either the comment changes with it or you have probably reintroduced the bug. --- ## Working on the portal The portal - **Bothy**, served at `http:///` by the catch-all router - is a Vite + React app in `apps/bothy-web/web`, built to static files by a multi-stage Docker image (Node builds, nginx serves `dist/`). It **discovers** what is running from read-only APIs under `/-/api/*` - it is not a hand-written list, and must never become one again. Note (2026-08-12): with the name layer gone, Traefik reports seven routers instead of twenty-two, so the Docker half of the discovery join carries nearly all the weight and `extractHost()` returns `null` for every router. A service that publishes a port but has no route still appears - it comes from `/containers/json`, not from the route table. Before you open a PR, both of these must pass: ```sh cd apps/bothy-web/web npx tsc -b --noEmit npm run build ``` Then rebuild and check it in a real browser: ```sh docker compose -f apps/bothy-web/compose.yml up -d --build ``` **Do not `docker compose down` the `bothy` project to restart the portal.** That project also owns `bothy-socket-read`, the read-only Docker socket the portal needs for `/-/api/docker`; take it down and the Overview enrichment goes blank even after bothy-web comes back. Act on the one service, as above. (This paragraph used to warn about `apps/portal/`. That directory existed only to hold the socket proxy and was deleted on 2026-08-18 - the fragment now lives in `apps/bothy/compose.socket-proxy.yml`. The retired pure-HTML nginx portal it also described went on 2026-08-17.) **A headless DOM cannot review a page.** jsdom has no layout, so "blank" and "perfect" look identical to it. This repo has shipped a near-blank portal twice because of an entrance animation that defaulted content to `opacity: 0`. Use a real browser - Playwright plus a screenshot - for anything visual, and make entrance animations animate *from* a visible resting state. Useful checks: ```sh BOX=127.0.0.1 # or the tailnet IP from `just urls` - there is no name # data plane curl -s http://$BOX/-/api/traefik/http/routers | jq length # 7 as of 2026-08-12 curl -s http://$BOX/-/api/docker/containers/json | jq length # the security gate - CHECK THE CONTENT TYPE, NOT THE STATUS. # The catch-all answers every unrouted path with the SPA at 200, so a status # assertion here can never fail. Blocked = text/html, allowed = application/json. curl -s -o /dev/null -w '%{content_type}\n' \ http://$BOX/-/api/docker/containers/$(docker ps -q | head -1)/json # text/html curl -s -o /dev/null -w '%{content_type}\n' \ http://$BOX/-/api/docker/containers/json # application/json # the thesis: a container appears with no portal edit at all docker stop grafana # its dot goes red within 10s; nothing was hand-edited ``` **A 200 with HTML is not success - it is the catch-all.** Every path on `:80` that is not one of the exact `/-/api/*` rules returns the portal SPA. When a data-plane curl looks wrong, check `%{content_type}` before anything else. --- ## Commits and branches **Commit subjects are Conventional Commits**, lowercase, imperative, with an optional scope: ``` feat(portal): reorganize dashboard around "systems" + disk usage fix(portal): stop the dashboard lying, and rebuild the Overview around it fix: bind databases to loopback only chore: pin every floating image tag docs: rewrite readme for traefik sso and tailnet refactor: split portal into live discovery assets ``` Types in use: `feat`, `fix`, `chore`, `docs`, `refactor`. The scope, when present, is the stack directory (`portal`, `monitoring`, `edge`, `data`). The body is where the *why* goes - what broke, what was ruled out, what the naive fix would have been. Several commits in this history are worth reading as documentation; aim for that. **Branch names** follow `//`, with the description in kebab-case: ``` Claude-Bot/Fix/bind-databases-to-loopback-only Claude-Bot/Feat/portal-react-rewrite Claude-Bot/Chore/pin-floating-image-tags Claude-Bot/Docs/rewrite-readme-for-current-architecture ``` Work happens on a branch and lands on `main` through a pull request - including the maintainer's own. Do not commit directly to `main`. ### Pull requests Keep one PR to one concern. In the description, say what broke and how you confirmed the fix - a pasted command and its output beats a claim. If you touched anything in [SECURITY.md's load-bearing list](SECURITY.md#load-bearing-design-rules), say so explicitly and include the boundary `curl` output. Please **do not** open a public issue for a vulnerability; use a private security advisory instead ([SECURITY.md](SECURITY.md#reporting-a-vulnerability)). By contributing you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).