thermograph/infra/ACCESS.md
Emi Griffith 396b80cf91 forgejo: serve dev.jinemi.com alongside git.thermograph.org
Both hostnames go on a single Caddy site block. That is load-bearing rather
than cosmetic: the /v2/* mesh-only matcher that keeps the OCI registry API
off the public internet (hazard #15) is scoped per block, so a separate
block for the new name would serve the same Forgejo with the registry open.

git.thermograph.org stays canonical — Forgejo has one ROOT_URL and builds
every absolute URL from it, so clone URLs, the OAuth callback and post-login
redirects keep naming the .org host. Flipping FORGEJO_DOMAIN additionally
requires the Google OAuth redirect URI, the registry host baked into image
names and runner labels, and the mesh /etc/hosts pins; README documents
those prerequisites.

Config verified with `caddy validate` and by asserting against the adapted
JSON that the /v2 403 guard matches both hostnames.
2026-08-01 09:01:59 -07:00

11 KiB

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, also served at dev.jinemi.com — migration in progress, .org still canonical), 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 deploys 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-latestruns-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)

  • ssh agent@<vps1> and ssh agent@<vps2> both work; sudo whoamiroot
  • Password SSH auth confirmed dead on both boxes (tested with an actual failed login attempt, not just config inspection)
  • docker node ls (from vps2, the manager) shows all three nodes Ready
  • Swarm ports (2377/7946/4789) unreachable from outside the WireGuard tunnel
  • https://git.thermograph.org serves Forgejo over TLS; /v2/ registry API is 403 from off-mesh (firewalled to the WireGuard CIDR)
  • A test PR flow verified: required build check runs → explicit squash merge → deploy lands on the branch's mapped environment
  • 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.