Realign Swarm/Forgejo infra to the 3-node design (#243)

Bring the Swarm+Forgejo layer in line with the canonical topology in
docs/runbooks/implementation-handoff.md: three nodes (prod, beta, and
the desktop LAN dev machine) instead of two, with the Forgejo Actions
runner registered on the desktop as a plain systemd service rather than
a Swarm-scheduled Docker-in-Docker sidecar.

- setup-wireguard.sh: full N-peer mesh instead of point-to-point
- docker-stack.yml: drop the runner/runner-dind services, volumes, and
  runner-token secret; only forgejo_db_password remains
- register-lan-runner.sh: register under both docker and
  thermograph-lan labels, since one runner now covers both job types
- init-swarm.sh, join-swarm.sh, swarm/README.md, forgejo/README.md,
  INFRA.md: updated node lists, order of operations, and access-state
  table for three nodes
- deploy.yml: renamed to "Deploy to beta VPS" and reconciled the
  main-vs-release branch question against terraform.tfvars.example
  (this workflow already targets beta; a release-triggered prod
  deploy doesn't exist yet and isn't invented here)
This commit is contained in:
Emi Griffith 2026-07-20 18:43:19 -07:00 committed by GitHub
parent 9611a7be8b
commit 7ff3873661
8 changed files with 281 additions and 203 deletions

102
INFRA.md
View file

@ -1,37 +1,49 @@
# Agent access, Docker Swarm, and Forgejo CI/CD # Agent access, Docker Swarm, and Forgejo CI/CD
This covers three additive infrastructure layers on top of the two VPS boxes This covers three additive infrastructure layers on top of the app
Terraform already provisions (see `terraform/README.md` and containerization / Postgres / Terraform work described in
`terraform.tfvars.example`, where they're named **prod** — the new 48 GB / **`docs/runbooks/implementation-handoff.md`** (read that first — it's the
12-core box, `thermograph.org` — and **beta** — the old VPS, canonical Track A/Track B plan this whole effort implements) and
`75.119.132.91`). **None of this touches `backend/`, `Dockerfile`, `docs/architecture/repo-topology-and-infrastructure.md`. Terraform provisions
**prod** (the new 48 GB / 12-core box, `thermograph.org`) and **beta** (the
old VPS, `75.119.132.91`) — see `terraform/README.md` /
`terraform.tfvars.example`. **None of this touches `backend/`, `Dockerfile`,
`docker-compose*.yml`, `terraform/`, or `deploy/db/`** — those stay owned by `docker-compose*.yml`, `terraform/`, or `deploy/db/`** — those stay owned by
the app-containerization work; this is strictly the layer above it. the app-containerization work; this is strictly the layer above it.
``` ```
Track 1: deploy/provision-agent-access.sh — a dedicated full-root login for me Track 1: deploy/provision-agent-access.sh — a dedicated full-root login for me
Track 2: deploy/swarm/ — Swarm cluster spanning both boxes Track 2: deploy/swarm/ — Swarm cluster spanning THREE nodes
Track 3: deploy/forgejo/ + .forgejo/ — Forgejo + Forgejo Actions, replacing GitHub Track 3: deploy/forgejo/ + .forgejo/ — Forgejo + Forgejo Actions, replacing GitHub
``` ```
**Three nodes, not two**: prod, beta, and **the desktop** (the LAN dev
machine — same box that already runs the pre-Forgejo GitHub self-hosted
runner). This matches the canonical Track B design in the handoff doc; an
earlier revision of this whole effort covered just prod+beta and has been
realigned.
## Track 1 — Agent access ## Track 1 — Agent access
Run `sudo bash deploy/provision-agent-access.sh` on **both** boxes. Creates a Run `sudo bash deploy/provision-agent-access.sh` on **prod and beta** (not
dedicated `agent` user (not raw root login — a distinct name gives a clean the desktop — that's wherever you're already working from, no separate
audit trail) with passwordless sudo, installs the agent's public key, disables access-provisioning step needed there). Creates a dedicated `agent` user (not
SSH password auth fleet-wide, and turns on `auditd` logging of every raw root login — a distinct name gives a clean audit trail) with passwordless
root-effective command. See the script's header comment for the full sudo, installs the agent's public key, disables SSH password auth
rationale and revocation steps (one line to delete, or delete the whole fleet-wide, and turns on `auditd` logging of every root-effective command.
account — your own access is never affected). 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) ### Current access state (live)
Both boxes are provisioned and reachable as of this writing: Both VPS boxes are provisioned and reachable as of this writing:
| Host | Role | Public IP | Login | | Host | Role | Public IP | Login |
|------|------|-----------|-------| |------|------|-----------|-------|
| prod | manager (Swarm), Thermograph's eventual home | `169.58.46.181` | `agent` | | prod | Swarm manager, Thermograph's eventual home | `169.58.46.181` | `agent` |
| beta | worker (Swarm), old VPS | `75.119.132.91` | `agent` | | beta | Swarm worker, old VPS | `75.119.132.91` | `agent` |
| desktop | Swarm worker, LAN dev machine + Actions runner | (local) | (already have access) |
``` ```
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # prod ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # prod
@ -54,23 +66,29 @@ 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 regenerating and re-running the provisioning script; it does not lock either
box, since your own login is untouched. box, since your own login is untouched.
As of this writing, neither box has Docker installed yet and Terraform has As of this writing, neither VPS has Docker installed yet and Terraform has
not been applied to either (`prod` is a bare OS install; `beta` has an old not been applied to either (`prod` is a bare OS install; `beta` has an old
`/opt/thermograph` checkout but no Docker) — Track 2 is paused on that. `/opt/thermograph` checkout but no Docker) — Track 2 is paused on that. The
desktop already has Docker (used for the LAN dev containerized stack) but
hasn't joined anything yet (`docker info` shows `Swarm: inactive`).
## Track 2 — Swarm ## Track 2 — Swarm
See `deploy/swarm/README.md` for the exact order of operations (WireGuard See `deploy/swarm/README.md` for the exact order of operations across all
tunnel first, then swarm init/join, then lock the Swarm ports down to the **three** nodes (WireGuard mesh first, then swarm init/join, then lock the
tunnel interface, then label beta for Forgejo placement). This cluster's only Swarm ports down to the tunnel interface, then label beta for Forgejo
job is hosting Forgejo — it does not orchestrate the Terraform-managed app placement). This cluster's only job is hosting Forgejo — it does not
deploys. orchestrate the Terraform-managed app deploys.
## Track 3 — Forgejo, replacing GitHub ## Track 3 — Forgejo, replacing GitHub
### 3a. Stand up Forgejo ### 3a. Stand up Forgejo
See `deploy/forgejo/README.md` — deploys the stack, walks through minting the See `deploy/forgejo/README.md` — deploys the stack (pinned to beta), then
two Swarm secrets it needs (`forgejo_db_password`, `forgejo_runner_token`). walks through registering the Actions runner **on the desktop** as a plain
systemd service (`register-lan-runner.sh`), not as a Swarm-scheduled
container. Only one Swarm secret to mint now (`forgejo_db_password`) — the
runner token is no longer a Swarm secret, since the runner isn't a Swarm
service anymore.
### 3b. Migrate the repo (mirror first, verify, then cut over) ### 3b. Migrate the repo (mirror first, verify, then cut over)
1. In Forgejo: **+ New Migration → GitHub**. Point it at 1. In Forgejo: **+ New Migration → GitHub**. Point it at
@ -82,10 +100,13 @@ two Swarm secrets it needs (`forgejo_db_password`, `forgejo_runner_token`).
3. **Don't retarget any secret or disable a GitHub workflow yet** — see 3d. 3. **Don't retarget any secret or disable a GitHub workflow yet** — see 3d.
### 3c. Workflows ### 3c. Workflows
`.forgejo/workflows/*.yml` already mirror `.github/workflows/*.yml` in this `.forgejo/workflows/{build,pr-build,deploy-dev,deploy}.yml` mirror
PR — copied with the mechanical changes Forgejo needs: `.github/workflows/*.yml`, copied with the mechanical changes Forgejo needs:
- `runs-on: ubuntu-latest``runs-on: docker` (the label the Swarm-hosted - `runs-on: ubuntu-latest``runs-on: docker` (one of the two labels the
runner in `deploy/forgejo/docker-stack.yml` registers under). desktop's runner registers under — see 3a; general CI/deploy jobs get a
fresh container, the LAN-deploy job in `deploy-dev.yml` runs bare/host-native
under the `thermograph-lan` label instead, since it needs real filesystem
access).
- `appleboy/ssh-action` referenced by full GitHub URL (not mirrored in - `appleboy/ssh-action` referenced by full GitHub URL (not mirrored in
Forgejo's default action registry, unlike `actions/checkout` / Forgejo's default action registry, unlike `actions/checkout` /
`actions/setup-python`, which resolve unchanged). `actions/setup-python`, which resolve unchanged).
@ -99,11 +120,24 @@ PR — copied with the mechanical changes Forgejo needs:
(unlike GitHub's token-based merge commit, which doesn't re-trigger `push`), (unlike GitHub's token-based merge commit, which doesn't re-trigger `push`),
so `deploy-dev.yml`'s push trigger fires naturally after auto-merge — no so `deploy-dev.yml`'s push trigger fires naturally after auto-merge — no
more "direct push vs. PR-merge" double-trigger logic to maintain. more "direct push vs. PR-merge" double-trigger logic to maintain.
- `.forgejo/workflows/deploy.yml` still targets `main` faithfully mirroring - `.forgejo/workflows/deploy.yml` ("Deploy to beta VPS") triggers on `main`,
the current GitHub workflow, but `terraform.tfvars.example` describes prod's same as it always did. This was flagged as a possible mismatch against
branch as `release`. That mismatch is flagged in the file's own header `terraform.tfvars.example`'s `release` branch for prod, but turned out not
comment — reconcile with whoever's driving the Terraform/deploy side rather to be one: this workflow predates the prod/beta split, so its secrets almost
than guessing here. certainly already point at beta (`main` is beta's branch per Terraform) —
see the file's own header comment. A separate `release` → prod deploy path
doesn't exist anywhere yet; don't invent one speculatively, confirm with
whoever owns Terraform whether prod even wants a push-triggered deploy or
is meant to go through `terraform apply` only.
A second, independent set of Forgejo workflows
(`.forgejo/workflows/{ci,build-push,ops-cron}.yml`) was added by a parallel
effort implementing `docs/runbooks/implementation-handoff.md` Track A chunk
7 — see that doc and its own reconciliation PR (which already fixed its
runner-label and ssh-action guesses to match the real infrastructure here).
`ci.yml` duplicated this PR's `build.yml` exactly and was dropped in that
reconciliation; `build-push.yml` (image → Forgejo's built-in registry) and
`ops-cron.yml` (backup + IndexNow) are novel and still present.
### 3d. Cut over ### 3d. Cut over
Only after 3a3c are verified: Only after 3a3c are verified:

View file

@ -1,37 +1,33 @@
# Forgejo on the Swarm cluster # Forgejo on the Swarm cluster
Runs as `deploy/forgejo/docker-stack.yml` — the only workload this Swarm Runs as `deploy/forgejo/docker-stack.yml` — the only Swarm-scheduled workload
cluster carries (the Thermograph app itself stays on the Terraform-managed this cluster carries (the Thermograph app itself stays on the
`docker compose` deploys; see `terraform/README.md`). Pinned to the **beta** Terraform-managed `docker compose` deploys; see `terraform/README.md`).
node (old VPS) via the `role=forge` label from Pinned to the **beta** node (old VPS) via the `role=forge` label from
`deploy/swarm/label-forge-node.sh`. `deploy/swarm/label-forge-node.sh`.
The Actions **runner** is deliberately *not* part of this stack — it runs on
the **desktop** as a plain systemd service (`register-lan-runner.sh` below),
per `docs/runbooks/implementation-handoff.md` Track B step 5. That's the
canonical placement; an earlier revision of this stack ran the runner as a
Swarm-scheduled Docker-in-Docker sidecar pinned to beta, which is gone now.
## Prerequisites ## Prerequisites
1. Both boxes have joined the swarm (`deploy/swarm/`) and beta is labeled 1. All three nodes have joined the swarm (`deploy/swarm/`) and beta is
`role=forge`. labeled `role=forge`.
2. `docker node ls` (from the manager) shows both `Ready`. 2. `docker node ls` (from the manager) shows all three `Ready`.
## One-time setup: Swarm secrets ## One-time setup: Swarm secret
Two secrets the stack expects to already exist (Swarm secrets, not files One secret the stack expects to already exist (a Swarm secret, not a file
`external: true` in the stack file, so `docker stack deploy` never creates or `external: true` in the stack file, so `docker stack deploy` never creates or
sees the values, only references them): sees the value, only references it):
```bash ```bash
# A strong random password for Forgejo's own Postgres (NOT related to # A strong random password for Forgejo's own Postgres (NOT related to
# Thermograph's app database — entirely separate instance/network). # Thermograph's app database — entirely separate instance/network).
openssl rand -base64 32 | docker secret create forgejo_db_password - openssl rand -base64 32 | docker secret create forgejo_db_password -
# The runner registration token. Forgejo can't issue one before it's running,
# so this is a two-step dance the first time:
docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo # 1. bring Forgejo up (runner will crashloop briefly — expected)
# 2. once Forgejo answers at the domain, log in, go to
# Site Administration -> Actions -> Runners -> Create new Runner
# (or, for a repo-scoped runner: <repo> -> Settings -> Actions -> Runners),
# copy the token, then:
echo -n "PASTE_TOKEN_HERE" | docker secret create forgejo_runner_token -
docker service update --force forgejo_runner # picks up the new secret and registers
``` ```
## Deploy / update ## Deploy / update
@ -42,12 +38,30 @@ docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
Re-running is safe — Swarm only touches services whose spec actually changed. Re-running is safe — Swarm only touches services whose spec actually changed.
## Register the Actions runner (on the desktop, not through Swarm)
Once Forgejo answers at its domain:
```bash
# On the desktop:
# Forgejo web UI -> Site Administration -> Actions -> Runners -> Create new Runner
# (or, for a repo-scoped runner: <repo> -> Settings -> Actions -> Runners)
# copy the registration token, then:
bash deploy/forgejo/register-lan-runner.sh https://<forgejo-domain> <token>
```
See that script's header for exactly what it replaces (the pre-Forgejo GitHub
self-hosted runner on this same machine) and why it registers with two
labels where there used to be two separate runners.
## DNS ## DNS
Point the Forgejo domain (default `git.thermograph.org`; override with Point the Forgejo domain (default `git.thermograph.org`; override with
`FORGEJO_DOMAIN=...` before `docker stack deploy`, Swarm reads it from the `FORGEJO_DOMAIN=...` before `docker stack deploy`, Swarm reads it from the
deploying shell's environment) at **either** node's public IP — the routing deploying shell's environment) at **prod's or beta's** public IP — not the
mesh forwards published ports to wherever the task actually landed. desktop's, which isn't a stable publicly-reachable address. The routing mesh
forwards published ports to wherever the task actually landed, so either VPS
IP works.
## Why Postgres here and not the Thermograph app's TimescaleDB ## Why Postgres here and not the Thermograph app's TimescaleDB
@ -60,9 +74,10 @@ independently.
## Verifying ## Verifying
```bash ```bash
docker service ls # all forgejo_* services Running, 1/1 docker service ls # forgejo_db, forgejo_forgejo, forgejo_traefik all Running, 1/1
curl -I https://git.thermograph.org/ # 200, valid cert curl -I https://git.thermograph.org/ # 200, valid cert
docker service logs forgejo_runner --tail 50 # "runner: successfully registered" then idle # On the desktop, after registering the runner:
systemctl --user status forgejo-runner # active, both labels registered
``` ```
## Rollback / removal ## Rollback / removal

View file

@ -1,20 +1,38 @@
# Forgejo (self-hosted Git + CI/CD) as a Docker Swarm stack — the only workload # Forgejo (self-hosted Git + CI/CD) as a Docker Swarm stack — the only workload
# this Swarm cluster runs (see deploy/swarm/README.md). Deliberately separate # this Swarm cluster runs (see deploy/swarm/README.md). Deliberately separate
# from the Terraform-managed docker-compose.yml that runs the Thermograph app # from the Terraform-managed docker-compose.yml that runs the Thermograph app
# itself: this stack's only job is Forgejo, its runner, and the ingress in # itself: this stack's only job is Forgejo, its container registry, and the
# front of it. # ingress in front of it.
# #
# Deploy from the manager node (prod), after both boxes have joined the swarm # The Actions RUNNER is deliberately NOT a service in this stack. Per
# and beta is labeled role=forge: # docs/runbooks/implementation-handoff.md (Track B step 5), the canonical
# design registers the runner on the desktop node as a plain systemd service
# (deploy/forgejo/register-lan-runner.sh) — the same place the pre-Forgejo
# GitHub self-hosted runner already lived, not a Swarm-scheduled container. An
# earlier revision of this file ran the runner + a Docker-in-Docker sidecar as
# Swarm services pinned to beta; that's gone now in favor of the canonical
# placement.
#
# Deploy from the manager node (prod), after all three nodes (prod, beta,
# desktop) have joined the swarm and beta is labeled role=forge:
# #
# docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo # docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
# #
# Requires these Swarm secrets to exist first (see deploy/forgejo/README.md): # Requires this Swarm secret to exist first (see deploy/forgejo/README.md):
# forgejo_db_password, forgejo_runner_token # forgejo_db_password
# (forgejo_runner_token is no longer a Swarm secret — see register-lan-runner.sh,
# which takes the registration token as a plain argument on the desktop instead.)
# #
# DNS: point git.thermograph.org (or your chosen domain — see plan) at EITHER # DNS: point the Forgejo domain at prod's or beta's public IP (not the
# node's public IP. Swarm's routing mesh accepts published ports on every node # desktop's — it isn't a stable, publicly-reachable address). Swarm's routing
# and forwards to wherever the task actually runs, so either IP works. # mesh accepts published ports on every node and forwards to wherever the
# task actually runs, so either of the two VPS IPs works.
#
# Registry exposure (hazard #15 in docs/runbooks/hop1-forgejo-registry-cutover.md)
# is still an open call, not resolved here: this file exposes Forgejo (and its
# built-in registry) publicly via Traefik/Let's Encrypt. The alternative —
# mesh-only registry + DNS-01 ACME + an outbound pull-mirror — is a deliberate
# design decision for whoever executes Track B, not something to guess at here.
services: services:
db: db:
@ -75,54 +93,6 @@ services:
- traefik.http.routers.forgejo.tls.certresolver=le - traefik.http.routers.forgejo.tls.certresolver=le
- traefik.http.services.forgejo.loadbalancer.server.port=3000 - traefik.http.services.forgejo.loadbalancer.server.port=3000
# Docker-in-Docker sidecar for the runner. Chosen over mounting the host
# docker.sock (root-equivalent host access, zero isolation) — DinD is the
# documented mitigation for a trusted single-maintainer runner that only
# ever executes this repo's own workflows.
runner-dind:
image: docker:27-dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
volumes:
- runner_dind_certs:/certs
networks: [forgejo_net]
deploy:
placement:
constraints: [node.labels.role == forge]
restart_policy:
condition: on-failure
runner:
image: code.forgejo.org/forgejo/runner:6
depends_on: [runner-dind, forgejo]
environment:
DOCKER_HOST: tcp://runner-dind:2375
FORGEJO_INSTANCE_URL: "https://${FORGEJO_DOMAIN:-git.thermograph.org}"
secrets:
- source: forgejo_runner_token
target: forgejo_runner_token
# Registers on first start using the token secret (see README for how to
# mint one), then just executes jobs — no docker.sock mount.
entrypoint: >
sh -c '
if [ ! -f /data/.runner ]; then
forgejo-runner register --no-interactive
--instance "$$FORGEJO_INSTANCE_URL"
--token "$$(cat /run/secrets/forgejo_runner_token)"
--name swarm-runner --labels docker:docker://node:20-bookworm;
fi;
forgejo-runner daemon
'
volumes:
- runner_data:/data
networks: [forgejo_net]
deploy:
placement:
constraints: [node.labels.role == forge]
restart_policy:
condition: on-failure
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.1
command: command:
@ -163,12 +133,8 @@ networks:
volumes: volumes:
forgejo_db: forgejo_db:
forgejo_data: forgejo_data:
runner_data:
runner_dind_certs:
traefik_certs: traefik_certs:
secrets: secrets:
forgejo_db_password: forgejo_db_password:
external: true external: true
forgejo_runner_token:
external: true

View file

@ -4,6 +4,17 @@
# runner (see DEPLOY-DEV.md) — this replaces that runner, it doesn't add a # runner (see DEPLOY-DEV.md) — this replaces that runner, it doesn't add a
# second one. Sudo-free, systemd --user, same pattern as the app service. # second one. Sudo-free, systemd --user, same pattern as the app service.
# #
# This is THE runner (docs/runbooks/implementation-handoff.md Track B step 5
# — canonical placement is the desktop, not a Swarm-hosted container), so it
# registers with BOTH labels the workflows need, where one runner used to be
# two: general CI/build/deploy.yml jobs (`docker`, containerized via this
# machine's own already-installed Docker — no Docker-in-Docker sidecar needed,
# unlike the Swarm-hosted design this replaces, since a real host with a real
# Docker install needs no such indirection) and the LAN-deploy job
# (`thermograph-lan`, bare/host-native — it writes to ~/thermograph-dev and
# restarts a systemd --user service, which only works running directly on the
# host, not inside a container).
#
# bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token> # bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token>
# #
# Get <registration_token> from the Forgejo web UI: # Get <registration_token> from the Forgejo web UI:
@ -15,12 +26,18 @@ set -euo pipefail
FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}" FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}"
TOKEN="${2:?}" TOKEN="${2:?}"
RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}" RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}"
LABELS="${LABELS:-thermograph-lan}" LABELS="${LABELS:-docker:docker://node:20-bookworm,thermograph-lan}"
echo "==> Stopping and disabling the old GitHub Actions runner service, if present" echo "==> Stopping and disabling the old GitHub Actions runner service, if present"
systemctl --user stop github-actions-runner 2>/dev/null || true systemctl --user stop github-actions-runner 2>/dev/null || true
systemctl --user disable github-actions-runner 2>/dev/null || true systemctl --user disable github-actions-runner 2>/dev/null || true
if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then
echo "WARNING: $USER is not in the 'docker' group — the 'docker:docker://...'"
echo "labeled jobs (general CI) will fail to start a container until this is"
echo "fixed: sudo usermod -aG docker $USER && (log out and back in)."
fi
echo "==> Installing forgejo-runner into $RUNNER_DIR" echo "==> Installing forgejo-runner into $RUNNER_DIR"
mkdir -p "$RUNNER_DIR" mkdir -p "$RUNNER_DIR"
cd "$RUNNER_DIR" cd "$RUNNER_DIR"
@ -49,7 +66,7 @@ echo "==> systemd --user unit"
mkdir -p "$HOME/.config/systemd/user" mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/forgejo-runner.service" <<EOF cat > "$HOME/.config/systemd/user/forgejo-runner.service" <<EOF
[Unit] [Unit]
Description=Forgejo Actions runner (thermograph-lan) Description=Forgejo Actions runner (docker + thermograph-lan)
After=network-online.target After=network-online.target
[Service] [Service]
@ -68,7 +85,9 @@ loginctl enable-linger "$USER" 2>/dev/null || true
cat <<EOF cat <<EOF
Done. The runner now serves Forgejo, not GitHub. Done. The runner now serves Forgejo, not GitHub — one runner, both labels
($LABELS), replacing what used to be a separate Swarm-hosted runner for
general CI plus this machine's own GitHub runner for LAN deploys.
status: systemctl --user status forgejo-runner status: systemctl --user status forgejo-runner
logs: journalctl --user -u forgejo-runner -f logs: journalctl --user -u forgejo-runner -f
restart: systemctl --user restart forgejo-runner restart: systemctl --user restart forgejo-runner

View file

@ -1,61 +1,79 @@
# 2-node Docker Swarm (prod + beta), for hosting Forgejo # 3-node Docker Swarm (prod + beta + desktop), for hosting Forgejo
This Swarm cluster's only job is to run Forgejo (`deploy/forgejo/`) — it does This Swarm cluster's only job is to run Forgejo (`deploy/forgejo/`) — it does
**not** orchestrate the Thermograph app itself, which stays on the **not** orchestrate the Thermograph app itself, which stays on the
Terraform-managed `docker compose` deploys on each box independently (see Terraform-managed `docker compose` deploys on prod/beta independently (see
`terraform/README.md`). Keeping those separate means nothing here can strand `terraform/README.md`). Keeping those separate means nothing here can strand
or interfere with the app's already-working, single-writer Postgres/TimescaleDB or interfere with the app's already-working, single-writer Postgres/TimescaleDB
deploys. deploys.
This is the canonical topology from
`docs/runbooks/implementation-handoff.md` (Track B steps 2-3) — three nodes,
not two. An earlier revision of this doc/scripts covered just prod+beta;
the desktop (this LAN dev machine) joins too.
**Nodes:** **Nodes:**
- **manager** — prod, the new 48 GB / 12-core box (more headroom). - **manager** — prod, the new 48 GB / 12-core box (more headroom).
- **worker** — beta, the old VPS (`75.119.132.91`). - **worker** — beta, the old VPS (`75.119.132.91`).
- **worker** — desktop, this LAN dev machine (also runs the Forgejo Actions
runner as a plain systemd service — see `deploy/forgejo/README.md` — not as
a Swarm-scheduled container).
1 manager + 1 worker, not 2 managers: Raft needs 3 nodes for real quorum-based One manager, not more: Raft needs 3 nodes for real quorum-based HA, and this
HA, so a second manager here would add complexity (split-brain risk) without cluster only has 3 nodes total, so making even one more of them a manager
adding actual failover. If the manager goes down, the worker keeps running would still fall short of real HA while adding split-brain risk. If the
whatever was already scheduled on it (Forgejo, since it's pinned there) but manager (prod) goes down, the workers keep running whatever was already
the cluster can't reschedule anything until the manager's back — acceptable scheduled on them (Forgejo, pinned to beta) but the cluster can't reschedule
for a 2-box hobby/small-team cluster whose only job is CI/CD. anything until prod's back — acceptable for a small cluster whose only job is
CI/CD.
## Order of operations ## Order of operations
1. **Agent access first** (`deploy/provision-agent-access.sh`) on both boxes — 1. **Agent access first** (`deploy/provision-agent-access.sh`) on prod and
everything below is run through that access. beta — everything below on those two boxes is run through that access. The
2. **WireGuard tunnel** (`setup-wireguard.sh`) — run on both boxes; see the desktop is wherever you're already working from; no separate access step
script's header for the two-pass key-exchange dance. Verify with needed there.
`ping <peer_wg_ip>` before continuing. 2. **WireGuard mesh** (`setup-wireguard.sh <my_wg_ip> <peers_file>`) — run on
**all three** nodes. See the script's header for the peer-list format and
the two-pass key-exchange dance (pubkeys aren't known until every node has
run it once). Verify with `ping <peer_wg_ip>` to each of the other two
before continuing.
3. **Swarm init** (`init-swarm.sh <manager_wg_ip>`) on the manager (prod) only. 3. **Swarm init** (`init-swarm.sh <manager_wg_ip>`) on the manager (prod) only.
4. **Swarm join** (`join-swarm.sh <manager_wg_ip> <token>`) on the worker 4. **Swarm join** (`join-swarm.sh <manager_wg_ip> <token>`) on **each** of the
(beta) only, using the token `init-swarm.sh` printed. two workers (beta, desktop) — same token for both.
5. **Firewall lockdown** (`firewall-swarm.sh`) on **both** boxes — closes 5. **Firewall lockdown** (`firewall-swarm.sh`) on **all three** nodes — closes
2377/7946/4789 to everything except the WireGuard interface. Do this 2377/7946/4789 to everything except the WireGuard interface. Do this
*after* joining is confirmed working, not before (locking the ports first *after* joining is confirmed working on all three, not before (locking the
would make the join itself fail). ports first would make the join itself fail).
6. **Label beta** (`label-forge-node.sh <beta-node-name>`) on the manager — 6. **Label beta** (`label-forge-node.sh <beta-node-name>`) on the manager —
`docker node ls` shows beta's node name/ID. `docker node ls` shows each node's name/ID. Only beta gets `role=forge`;
the desktop and prod don't need a Swarm label for anything in this setup.
7. Deploy Forgejo: see `deploy/forgejo/README.md`. 7. Deploy Forgejo: see `deploy/forgejo/README.md`.
8. Register the Actions runner **on the desktop** (not through Swarm):
`deploy/forgejo/register-lan-runner.sh`.
## Why WireGuard instead of relying on Swarm's built-in TLS alone ## Why WireGuard instead of relying on Swarm's built-in TLS alone
Swarm's control plane (port 2377) is TLS-encrypted and mutually authenticated Swarm's control plane (port 2377) is TLS-encrypted and mutually authenticated
by default. Its overlay data plane (VXLAN, port 4789) is **not** encrypted by by default. Its overlay data plane (VXLAN, port 4789) is **not** encrypted by
default, and Docker's own guidance is that port must never face the public default, and Docker's own guidance is that port must never face the public
internet — these two boxes are on different providers' public IPs, not a internet — these nodes are on different networks (two separate providers'
private LAN, so the tunnel is the network boundary the Swarm ports advertise public IPs, plus a home/LAN connection for the desktop), not one private LAN,
into, rather than trusting the public internet directly. so the tunnel is the network boundary the Swarm ports advertise into, rather
than trusting the public internet (or the desktop's home network) directly.
## Verifying ## Verifying
```bash ```bash
# On the manager: # On the manager:
docker node ls # both nodes Ready docker node ls # all three nodes Ready
docker node inspect <beta-node> --format '{{.Spec.Labels}}' # role:forge docker node inspect <beta-node> --format '{{.Spec.Labels}}' # role:forge
# From outside the WireGuard interface (e.g. your own machine), confirm the # From a FOURTH machine outside the mesh entirely, confirm the Swarm ports
# Swarm ports are NOT reachable on the public IP: # are NOT reachable on either VPS's public IP (the desktop has no public IP
nc -zv -w2 <public_ip> 2377 # should fail/timeout # to check this way):
nc -zvu -w2 <public_ip> 4789 # should fail/timeout nc -zv -w2 <prod_or_beta_public_ip> 2377 # should fail/timeout
nc -zvu -w2 <prod_or_beta_public_ip> 4789 # should fail/timeout
``` ```
## Adding a node label back out (undo) ## Adding a node label back out (undo)

View file

@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run ONCE, on the manager node only (prod — the new 48 GB box). Initializes # Run ONCE, on the manager node only (prod — the new 48 GB box). Initializes
# the Swarm advertising the WireGuard address, so cluster traffic never # the Swarm advertising the WireGuard address, so cluster traffic never
# touches the public interface. Run setup-wireguard.sh on both boxes first. # touches the public interface. Run setup-wireguard.sh on all THREE nodes
# first (prod, beta, and the desktop — see
# docs/runbooks/implementation-handoff.md Track B steps 2-3).
set -euo pipefail set -euo pipefail
MY_WG_IP="${1:?usage: $0 <my_wg_ip>}" MY_WG_IP="${1:?usage: $0 <my_wg_ip>}"
@ -19,8 +21,9 @@ echo "==> docker swarm init, advertising ${MY_WG_IP} (the WireGuard address, not
docker swarm init --advertise-addr "$MY_WG_IP" --listen-addr "${MY_WG_IP}:2377" docker swarm init --advertise-addr "$MY_WG_IP" --listen-addr "${MY_WG_IP}:2377"
echo echo
echo "==> Worker join command (run this on the worker/beta node):" echo "==> Worker join command (run this on EACH of the two workers — beta and"
echo " the desktop; the same token works for both):"
TOKEN="$(docker swarm join-token -q worker)" TOKEN="$(docker swarm join-token -q worker)"
echo " docker swarm join --token ${TOKEN} ${MY_WG_IP}:2377" echo " docker swarm join --token ${TOKEN} ${MY_WG_IP}:2377"
echo echo
echo "(Or run deploy/swarm/join-swarm.sh <manager_wg_ip> <token> on the worker.)" echo "(Or run deploy/swarm/join-swarm.sh <manager_wg_ip> <token> on each worker.)"

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run ONCE, on the worker node only (beta — the old VPS). Joins the Swarm # Run ONCE on EACH worker node (beta, and the desktop — not the manager,
# initialized by init-swarm.sh on the manager, over the WireGuard tunnel. # prod). Joins the Swarm initialized by init-swarm.sh, over the WireGuard
# tunnel. The join token is the same for both workers.
set -euo pipefail set -euo pipefail
MANAGER_WG_IP="${1:?usage: $0 <manager_wg_ip> <join_token>}" MANAGER_WG_IP="${1:?usage: $0 <manager_wg_ip> <join_token>}"

View file

@ -1,28 +1,33 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Sets up a point-to-point WireGuard tunnel between the two Swarm hosts. Docker # Sets up this node's side of a full-mesh WireGuard tunnel across all of
# Swarm's control plane (2377/tcp) is TLS-encrypted by default, but the overlay # prod, beta, and the desktop (three nodes, not two — see
# data plane (VXLAN, 4789/udp) is NOT — and it should never face the public # docs/runbooks/implementation-handoff.md Track B step 2). Docker Swarm's
# internet. Swarm is joined over this tunnel's private IPs instead. # control plane (2377/tcp) is TLS-encrypted by default, but the overlay data
# plane (VXLAN, 4789/udp) is NOT — and it should never face the public
# internet. Swarm joins over these private WireGuard IPs instead.
# #
# Run on EACH box, in either order. Each run generates that box's own WireGuard # A full mesh means every node peers directly with every other node (not a
# keypair (if missing) and prints its public key — paste it into the OTHER # hub-and-spoke through one box) — with three nodes that's 2 [Peer] blocks per
# box's invocation. Two runs total, one per box: # node. Run this on EACH of the three nodes, in any order, using a small peer
# list file so the script isn't hardcoded to any particular node count.
# #
# box A (prod): bash setup-wireguard.sh 10.10.0.1 10.10.0.2 <PROD_PUBLIC_IP> <BETA_PUBLIC_IP> <BETA_WG_PUBKEY_or_empty> # --- Peer list format --------------------------------------------------
# box B (beta): bash setup-wireguard.sh 10.10.0.2 10.10.0.1 <BETA_PUBLIC_IP> <PROD_PUBLIC_IP> <PROD_WG_PUBKEY> # A text file, one line per OTHER node (not including the one you're running
# on), each line: <wg_ip> <public_ip> <pubkey-or-dash>
# 10.10.0.1 169.58.46.181 <prod's pubkey, or - if not yet known>
# 10.10.0.2 75.119.132.91 <beta's pubkey, or ->
# 10.10.0.3 <desktop's public/reachable IP or a DDNS name> <desktop's pubkey, or ->
# #
# First run on either box: leave the last argument empty, note the printed # First pass on any node: peer pubkeys you don't have yet are "-". Run this
# pubkey, then run the second box with that value, then re-run the first box # script on all three nodes once (prints each node's own pubkey), fill those
# once more with the second box's now-known pubkey. (A local key never # into everyone's peer-list files, then re-run on all three once more to
# changes across re-runs — only the peer section updates.) # actually establish the tunnels. Re-running is safe (idempotent) at any point.
#
# bash setup-wireguard.sh <my_wg_ip> <peers_file>
set -euo pipefail set -euo pipefail
MY_WG_IP="${1:?usage: $0 <my_wg_ip> <peer_wg_ip> <my_public_ip> <peer_public_ip> [peer_wg_pubkey]}" MY_WG_IP="${1:?usage: $0 <my_wg_ip> <peers_file>}"
PEER_WG_IP="${2:?}" PEERS_FILE="${2:?}"
MY_PUBLIC_IP="${3:?}"
PEER_PUBLIC_IP="${4:?}"
PEER_PUBKEY="${5:-}"
WG_PORT="${WG_PORT:-51820}" WG_PORT="${WG_PORT:-51820}"
WG_DIR=/etc/wireguard WG_DIR=/etc/wireguard
@ -30,13 +35,17 @@ if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (or via the agent user's sudo)." >&2 echo "Run as root (or via the agent user's sudo)." >&2
exit 1 exit 1
fi fi
if [ ! -f "$PEERS_FILE" ]; then
echo "Peer list not found: $PEERS_FILE (see this script's header for the format)" >&2
exit 1
fi
echo "==> Installing WireGuard if needed" echo "==> Installing WireGuard if needed"
command -v wg >/dev/null 2>&1 || { apt-get update -y -q && apt-get install -y -q wireguard; } command -v wg >/dev/null 2>&1 || { apt-get update -y -q && apt-get install -y -q wireguard; }
install -d -m 700 "$WG_DIR" install -d -m 700 "$WG_DIR"
if [ ! -f "$WG_DIR/privatekey" ]; then if [ ! -f "$WG_DIR/privatekey" ]; then
echo "==> Generating this box's WireGuard keypair" echo "==> Generating this node's WireGuard keypair"
umask 077 umask 077
wg genkey | tee "$WG_DIR/privatekey" | wg pubkey > "$WG_DIR/publickey" wg genkey | tee "$WG_DIR/privatekey" | wg pubkey > "$WG_DIR/publickey"
fi fi
@ -44,50 +53,63 @@ MY_PRIVKEY="$(cat "$WG_DIR/privatekey")"
MY_PUBKEY="$(cat "$WG_DIR/publickey")" MY_PUBKEY="$(cat "$WG_DIR/publickey")"
echo echo
echo "==> This box's WireGuard public key (give this to the OTHER box's run):" echo "==> This node's WireGuard public key (put this in the OTHER two nodes'"
echo " peer-list files, replacing the '-' for this node's line):"
echo " $MY_PUBKEY" echo " $MY_PUBKEY"
echo echo
if [ -z "$PEER_PUBKEY" ]; then missing=0
echo "No peer public key supplied yet — writing a config with no [Peer] section." {
echo "Run this same command again once you have it (from the other box's output above)." echo "[Interface]"
cat > "$WG_DIR/wg0.conf" <<EOF echo "Address = ${MY_WG_IP}/24"
[Interface] echo "PrivateKey = ${MY_PRIVKEY}"
Address = ${MY_WG_IP}/24 echo "ListenPort = ${WG_PORT}"
PrivateKey = ${MY_PRIVKEY} echo
ListenPort = ${WG_PORT}
EOF
else
cat > "$WG_DIR/wg0.conf" <<EOF
[Interface]
Address = ${MY_WG_IP}/24
PrivateKey = ${MY_PRIVKEY}
ListenPort = ${WG_PORT}
[Peer] while read -r peer_wg_ip peer_public_ip peer_pubkey; do
PublicKey = ${PEER_PUBKEY} [ -z "${peer_wg_ip:-}" ] && continue
Endpoint = ${PEER_PUBLIC_IP}:${WG_PORT} case "$peer_wg_ip" in \#*) continue ;; esac
AllowedIPs = ${PEER_WG_IP}/32 if [ "$peer_pubkey" = "-" ] || [ -z "$peer_pubkey" ]; then
PersistentKeepalive = 25 echo "# SKIPPED (no pubkey yet) — peer at ${peer_public_ip}" >&2
EOF missing=$((missing + 1))
fi continue
fi
echo "[Peer]"
echo "PublicKey = ${peer_pubkey}"
echo "Endpoint = ${peer_public_ip}:${WG_PORT}"
echo "AllowedIPs = ${peer_wg_ip}/32"
echo "PersistentKeepalive = 25"
echo
done < "$PEERS_FILE"
} > "$WG_DIR/wg0.conf"
chmod 600 "$WG_DIR/wg0.conf" chmod 600 "$WG_DIR/wg0.conf"
echo "==> Bringing up wg0" echo "==> Bringing up wg0"
systemctl enable --now wg-quick@wg0 2>/dev/null || (wg-quick down wg0 2>/dev/null; wg-quick up wg0) systemctl enable --now wg-quick@wg0 2>/dev/null || (wg-quick down wg0 2>/dev/null; wg-quick up wg0)
# Re-apply if the config changed on an already-up interface.
wg syncconf wg0 <(wg-quick strip wg0) 2>/dev/null || true wg syncconf wg0 <(wg-quick strip wg0) 2>/dev/null || true
echo "==> Firewall: only let the OTHER box's public IP reach the WireGuard port" echo "==> Firewall: only let listed peers' public IPs reach the WireGuard port"
if command -v ufw >/dev/null 2>&1; then if command -v ufw >/dev/null 2>&1; then
ufw allow from "$PEER_PUBLIC_IP" to any port "$WG_PORT" proto udp comment "wireguard peer" while read -r _wg_ip peer_public_ip _pubkey; do
[ -z "${peer_public_ip:-}" ] && continue
case "$peer_public_ip" in \#*) continue ;; esac
ufw allow from "$peer_public_ip" to any port "$WG_PORT" proto udp comment "wireguard peer"
done < "$PEERS_FILE"
fi fi
echo echo
echo "wg0 status:" echo "wg0 status:"
wg show wg0 || true wg show wg0 || true
echo echo
if [ -n "$PEER_PUBKEY" ]; then if [ "$missing" -gt 0 ]; then
echo "==> Verify from here once BOTH boxes have run with each other's pubkey:" echo "NOTE: $missing peer(s) skipped (no pubkey yet in $PEERS_FILE). Fill them in"
echo " ping -c2 ${PEER_WG_IP}" echo "and re-run on this node once all three nodes have printed their pubkey."
else
echo "==> All peers configured. Verify once every node has run this:"
while read -r peer_wg_ip _peer_public_ip peer_pubkey; do
[ -z "${peer_wg_ip:-}" ] && continue
case "$peer_wg_ip" in \#*) continue ;; esac
[ "$peer_pubkey" = "-" ] && continue
echo " ping -c2 ${peer_wg_ip}"
done < "$PEERS_FILE"
fi fi