All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / validate-observability (pull_request) Successful in 18s
Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:
vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV
(own Postgres, mesh-only on 10.10.0.2:8137)
vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one
TimescaleDB instance, plus Centralis, Postfix, backups
desktop AI model hosting + flex Swarm capacity, no environment
Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.
deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.
Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.
One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.
Fixes that co-residency would otherwise have broken silently:
- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
also "the Forgejo box" because those shared a machine; that conflation is what
once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
have filed every beta line as prod, feeding prod's alert rules with beta's
traffic. It is now derived per source, with a new `node` label for the machine,
and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
target and env-file path from the topology instead of hardcoding beta to
75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
unreviewed branches on a VPS.
Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.
Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
192 lines
11 KiB
Markdown
192 lines
11 KiB
Markdown
# Agent access, Docker Swarm, and Forgejo CI/CD
|
|
|
|
This covers three additive infrastructure layers on top of the host
|
|
provisioning / Postgres / Terraform work described in `terraform/README.md`
|
|
and (for the historical Track A/Track B plan this whole effort grew from,
|
|
including the decision to split this repo out of the app monorepo)
|
|
`thermograph-docs/runbooks/implementation-handoff.md` and
|
|
`thermograph-docs/architecture/repo-topology-and-infrastructure.md`.
|
|
**Note:** `terraform/README.md`'s own host table still describes the
|
|
pre-cutover shape (`prod` and `beta` as two single-purpose boxes) and has not
|
|
yet been updated to the vps1/vps2 split below — that file is out of this
|
|
pass's scope; treat this document as the current source of truth for the
|
|
physical topology in the meantime.
|
|
|
|
```
|
|
Track 1: deploy/provision-agent-access.sh — a dedicated full-root login for me
|
|
Track 2: deploy/swarm/ — Swarm mesh spanning THREE nodes
|
|
Track 3: deploy/forgejo/ + .forgejo/ — Forgejo + Forgejo Actions, replacing GitHub
|
|
```
|
|
|
|
## The estate (renamed by role, not by environment)
|
|
|
|
The same two VPS boxes as always — **neither public IP nor WireGuard mesh
|
|
address moved** — plus the operator's desktop, now named for what they *do*
|
|
rather than which environment happens to live there:
|
|
|
|
| Host | Public IP | Mesh IP | Role |
|
|
|------|-----------|---------|------|
|
|
| **vps1** | `75.119.132.91` | `10.10.0.2` | "Operational programs": Forgejo (git + CI + registry, `git.thermograph.org`), Grafana/Loki/Alloy (`dashboard.thermograph.org`), the `emigriffith.dev` portfolio, and the **dev** environment (mesh-only, `10.10.0.2:8137`, no public DNS/Caddy/TLS) |
|
|
| **vps2** | `169.58.46.181` | `10.10.0.1` | "The deployed environment": **prod** and **beta**, as two separate Docker Swarm stacks, plus Centralis, Postfix, the backups, and the one shared TimescaleDB instance both stacks use |
|
|
| **desktop** | — | `10.10.0.3` | AI-model hosting (voice-to-text, an upcoming-feature LLM) plus flex Swarm-worker capacity. Hosts **no** Thermograph environment — `make dev-up` still works there, but only as a laptop-local rehearsal |
|
|
|
|
This is a **rename**, not a re-provisioning: vps1 is the box that used to be
|
|
called "beta" (it already ran Forgejo and Grafana before this split), and vps2
|
|
is the box that used to be called "prod". What moved is the **beta
|
|
environment** — off vps1 and onto vps2, so a beta green light is evidence
|
|
about prod (same orchestrator, same Postgres build, same Caddy, same mesh
|
|
position) — and the **dev environment**, off the desktop and onto vps1, so the
|
|
desktop can retire from the Thermograph estate entirely. See
|
|
`deploy/env-topology.sh`'s header comment for the full rationale.
|
|
|
|
**Three nodes on the Swarm mesh, not two**: vps2 (manager), vps1 (worker), and
|
|
the desktop (worker, flex capacity + AI model hosting). One manager, not more:
|
|
Raft needs 3 nodes for real quorum-based HA, and this cluster only has 3 nodes
|
|
total, so a second manager would still fall short of real HA while adding
|
|
split-brain risk. If the manager (vps2) goes down, the workers keep running
|
|
whatever was already scheduled on them (Forgejo, pinned to vps1) but the
|
|
cluster can't reschedule anything until vps2 is back — acceptable for a small
|
|
cluster whose only Swarm-scheduled workload today is Forgejo (prod and beta's
|
|
app stacks are separate Swarm stacks that also happen to run on vps2, the
|
|
manager, because their volumes are local to that node — see
|
|
`deploy/stack/thermograph-stack.yml`'s header).
|
|
|
|
## Track 1 — Agent access
|
|
|
|
Run `sudo bash deploy/provision-agent-access.sh` on **vps1 and vps2** (not
|
|
the desktop — that's wherever you're already working from, no separate
|
|
access-provisioning step needed there). Creates a dedicated `agent` user (not
|
|
raw root login — a distinct name gives a clean audit trail) with passwordless
|
|
sudo, installs the agent's public key, disables SSH password auth
|
|
fleet-wide, and turns on `auditd` logging of every root-effective command.
|
|
See the script's header comment for the full rationale and revocation steps
|
|
(one line to delete, or delete the whole account — your own access is never
|
|
affected).
|
|
|
|
### Current access state (live)
|
|
|
|
Both VPS boxes are provisioned and reachable as of this writing:
|
|
|
|
| Host | Role | Public IP | Login |
|
|
|------|------|-----------|-------|
|
|
| vps2 | Swarm manager; prod AND beta live here (`thermograph.org`, `beta.thermograph.org`) | `169.58.46.181` | `agent` |
|
|
| vps1 | Swarm worker; Forgejo + Grafana/Loki + dev (`git.thermograph.org`, `dashboard.thermograph.org`) | `75.119.132.91` | `agent` |
|
|
| desktop | Swarm worker, AI-model hosting | (local) | (already have access) |
|
|
|
|
```
|
|
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # vps2 (prod + beta)
|
|
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@75.119.132.91 # vps1 (dev + Forgejo + Grafana)
|
|
```
|
|
|
|
**On vps2, always say which environment you mean.** A shell on vps2 can act on
|
|
either `/opt/thermograph` (prod) or `/opt/thermograph-beta` (beta) — there is
|
|
no "the app" on that box any more. `deploy.sh` refuses to run if
|
|
`THERMOGRAPH_ENV` disagrees with the checkout it was invoked from (see
|
|
`deploy/env-topology.sh`), which catches the one genuinely dangerous mistake
|
|
here: running prod's checkout with `THERMOGRAPH_ENV=beta`, or the reverse.
|
|
|
|
The private half of the agent's dedicated keypair lives at
|
|
`~/.ssh/thermograph_agent_ed25519` on the operator's own machine (never in
|
|
this repo, never in a CI secret) — same convention `DEPLOY.md` already uses
|
|
for the separate CI deploy keys. If you're setting this up fresh elsewhere,
|
|
generate a new pair the same way
|
|
(`ssh-keygen -t ed25519 -a 100 -C "claude-agent@thermograph-infra" -f
|
|
~/.ssh/thermograph_agent_ed25519 -N ""`), embed the new public half in
|
|
`AGENT_PUBKEY` at the top of `provision-agent-access.sh`, and re-run it on
|
|
both boxes — that replaces the authorized key wholesale rather than
|
|
appending, so an old key stops working the moment the new one lands.
|
|
|
|
**Keep the private key somewhere that survives** — not a scratch/temp
|
|
directory that gets cleaned up, not anywhere-in-repo. Losing it just means
|
|
regenerating and re-running the provisioning script; it does not lock either
|
|
box, since your own login is untouched.
|
|
|
|
## Track 2 — Swarm
|
|
|
|
See `deploy/swarm/README.md` for the exact order of operations across all
|
|
**three** nodes (WireGuard mesh first, then swarm init/join, then lock the
|
|
Swarm ports down to the tunnel interface, then label vps1 for Forgejo
|
|
placement). This cluster's Swarm-scheduled workload is Forgejo; prod and
|
|
beta's app stacks are separate `docker stack deploy`s that happen to live on
|
|
the same manager node (vps2) because their volumes are local to it today.
|
|
|
|
## Track 3 — Forgejo, replacing GitHub
|
|
|
|
### 3a. Stand up Forgejo
|
|
See `deploy/forgejo/README.md` — deploys the stack (pinned to **vps1**), then
|
|
walks through registering the Actions runner as a plain systemd service
|
|
(`register-lan-runner.sh`), not as a Swarm-scheduled container.
|
|
|
|
### 3b. Migrate the repo (mirror first, verify, then cut over)
|
|
1. In Forgejo: **+ New Migration → GitHub**. Point it at
|
|
`https://github.com/griffemi/thermograph`, pull code + issues + PRs +
|
|
releases + labels. This is non-destructive to GitHub — do this as a mirror
|
|
and leave GitHub live and untouched until you're confident.
|
|
2. Spot-check: branch list matches, a few PRs/issues render correctly, LFS (if
|
|
any) came across.
|
|
3. **Don't retarget any secret or disable a GitHub workflow yet** — see 3d.
|
|
|
|
### 3c. Workflows
|
|
`.forgejo/workflows/{build,pr-build,deploy}.yml` (a single `Deploy` workflow
|
|
now covers all three environments — see its own header comment) mirror
|
|
`.github/workflows/*.yml`, copied with the mechanical changes Forgejo needs:
|
|
- `runs-on: ubuntu-latest` → `runs-on: docker` (the runner's `docker` label —
|
|
containerized jobs get a fresh container via the docker-socket-automounted
|
|
runner).
|
|
- `appleboy/ssh-action` referenced by full GitHub URL (not mirrored in
|
|
Forgejo's default action registry, unlike `actions/checkout` /
|
|
`actions/setup-python`, which resolve unchanged).
|
|
- **The custom auto-merge workflow step is gone.** It existed only because
|
|
GitHub's native branch protection/auto-merge is paywalled on private
|
|
free-tier repos. Forgejo has no such tier: `dev` is protected with the
|
|
`build` status check required (Settings → Branches). Forgejo does **not**
|
|
auto-merge on green by itself — a ready, green, mergeable PR is merged
|
|
**explicitly** (`POST .../pulls/{n}/merge`, `{"Do": "squash"}`), per
|
|
`CLAUDE.md`. That merge is an ordinary push, so `deploy.yml`'s push
|
|
trigger fires naturally afterward.
|
|
- **Branch/deploy mapping (settled):** `.forgejo/workflows/deploy.yml`
|
|
triggers on `dev`, `main` and `release`, and SSHes to the environment each
|
|
branch maps to (`dev` → dev on **vps1**, `main` → beta on **vps2**,
|
|
`release` → prod on **vps2**), running `deploy/deploy-dev.sh` or
|
|
`deploy/deploy.sh` per `deploy/env-topology.sh`. Credentials are keyed by
|
|
**host** (`VPS1_SSH_*`, `VPS2_SSH_*`), not by environment, since vps2 alone
|
|
now answers to two of them — see the workflow's own header comment for why
|
|
that rename matters.
|
|
|
|
A second, independent set of Forgejo workflows
|
|
(`.forgejo/workflows/{build-push,ops-cron}.yml`) was added by a parallel
|
|
effort implementing `thermograph-docs/runbooks/implementation-handoff.md` Track A chunk
|
|
7 — see that doc and its own reconciliation PR. `build-push.yml` (image →
|
|
Forgejo's built-in registry) and `ops-cron.yml` (backup + IndexNow) are novel
|
|
and still present; the latter's secrets are also keyed by host now (see its
|
|
own header comment).
|
|
|
|
### 3d. Cut over — DONE (2026-07-21)
|
|
The GitHub → Forgejo cutover is complete; this records what was done:
|
|
1. Deploy secrets live in Forgejo's repo Secrets, keyed by host
|
|
(`VPS1_SSH_*`, `VPS2_SSH_*`) since the vps1/vps2 split, pointing at each
|
|
box with a dedicated CI deploy key.
|
|
2. The Actions runner is registered against Forgejo via
|
|
`deploy/forgejo/register-lan-runner.sh`.
|
|
3. The repo was migrated into Forgejo; `dev`/`main`/`release` all promoted
|
|
through Forgejo and deploys verified live.
|
|
4. GitHub is retired as git host and CI (PR "Retire GitHub as the git host and
|
|
CI platform") — `origin` remains only as a read-only mirror of history.
|
|
|
|
## Verification checklist — all met (2026-07-21, topology as of 2026-07-25)
|
|
- [x] `ssh agent@<vps1>` and `ssh agent@<vps2>` both work; `sudo whoami` → `root`
|
|
- [x] Password SSH auth confirmed dead on both boxes (tested with an actual
|
|
failed login attempt, not just config inspection)
|
|
- [x] `docker node ls` (from vps2, the manager) shows all three nodes `Ready`
|
|
- [x] Swarm ports (2377/7946/4789) unreachable from outside the WireGuard tunnel
|
|
- [x] `https://git.thermograph.org` serves Forgejo over TLS; `/v2/` registry
|
|
API is 403 from off-mesh (firewalled to the WireGuard CIDR)
|
|
- [x] A test PR flow verified: required `build` check runs → explicit squash
|
|
merge → deploy lands on the branch's mapped environment
|
|
- [x] GitHub retired as git host + CI; kept only as a read-only history mirror
|
|
|
|
TODO(cutover): re-verify the checklist above against the vps1/vps2 rename
|
|
specifically (e.g. confirm the Forgejo Swarm label now reads `vps1`'s node
|
|
name, not a leftover `beta` node name) — this document was updated to match
|
|
the new topology brief, but a fresh live check wasn't run as part of this
|
|
pass.
|