Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
# Forgejo on the Swarm cluster
|
|
|
|
|
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
Runs as `deploy/forgejo/docker-stack.yml` — the only Swarm-scheduled workload
|
2026-07-26 06:56:38 +00:00
|
|
|
this cluster carries (prod and beta's app stacks are separate `docker stack
|
|
|
|
|
deploy`s that happen to run on the manager node, vps2 — see
|
docs: correct file references and the dev reachability claim
Audited the five CLAUDE.md files and all twenty-one README.md files against the
tree, machine-checking every in-repo path they name and verifying the testable
claims against the live hosts.
The one that matters is in the root file: dev was documented as reachable on
the mesh at 10.10.0.2:8137. It is not, and never was from anywhere but vps1 —
infra/docker-compose.yml binds the port to 127.0.0.1, and the address answers
from neither vps2 nor vps1 itself. Anyone following it gets a connection
refused with nothing to explain it.
The rest are stale paths, several from the reunification:
* assetlinks.json moved under frontend/static/ in the subtree merge; the TWA
README kept the pre-merge path in both places it names it. Following it
would put the file where nothing serves it and Android app-link
verification would fail silently.
* push.py and notify.py now live in backend/notifications/.
* INFRA.md and deploy/stack/README have never existed in this repo, in any
branch.
* the Caddyfile is at deploy/stack/lb/Caddyfile.
* three bare relative paths that resolve for a reader but not from the
directory the file sits in: units.js is the frontend's, deploy.sh is
infra's, entrypoint.sh is the backend's.
Also records why mesh clients must pin the ROOT_URL host and not only the image
host: the registry's bearer-token realm follows ROOT_URL, so pinning
git.thermograph.org alone still sends the token request out the public route,
where the /v2/* matcher returns 403 and docker falls back to anonymous. That
surfaces as `unauthorized: reqPackageAccess`, indistinguishable from a bad
credential.
Verified true and left alone: the four-domain layout, both .claude runbooks,
the absence of any domain-level .forgejo directory, the pinned compose project
name, the deploy contract, prod's eight stack services, beta's five prefixed
ones with no db of its own, dev's five, and every documented make target.
2026-08-01 18:49:28 +00:00
|
|
|
`DEPLOY.md`). Pinned to the **vps1** node
|
2026-07-26 06:56:38 +00:00
|
|
|
(`75.119.132.91`) via the `role=forge` label from
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
`deploy/swarm/label-forge-node.sh`.
|
|
|
|
|
|
ci: add an always-on Actions runner on vps2
The estate had exactly one registered runner, on the desktop. It went offline
at 2026-07-31 16:31Z; for the next 21 hours no PR could satisfy a required
check, no deploy could run, and the 03:00Z ops-cron -- the only backup for both
application databases and for Forgejo -- did not fire. Forgejo queued that
scheduled run rather than dropping it, so it completed on reconnect and nothing
was lost. A longer outage would have meant real gaps.
Three files claimed an "always-on Swarm-hosted runner" existed and that the
estate therefore no longer depended on the desktop. It did not exist: an early
revision of docker-stack.yml ran one as a Docker-in-Docker sidecar and it was
removed. That claim is why a single point of failure sat unnoticed. Corrected
in docker-stack.yml, forgejo/README.md and register-lan-runner.sh.
The new runner is a plain restart:always container, not a Swarm service: a
Swarm-scheduled runner cannot redeploy the Swarm that schedules it, so CI would
be gone exactly when the cluster is what is broken. It runs from
/opt/forgejo-runner rather than in place, because the checkout is reset on
every prod deploy and one `git clean -fdx` there would destroy the
registration.
vps2 runs prod, so the socket mount is bounded rather than assumed benign:
capacity 1, --cpus=2/--memory=4g on job containers, valid_volumes empty so no
job can bind-mount /etc/thermograph.env, and no thermograph network joined.
This defends against accident, not against a hostile workflow author -- stated
plainly in the compose header rather than implied.
The desktop runner stays registered as extra capacity. Nothing may assume it
is up.
2026-08-01 14:54:28 +00:00
|
|
|
The Actions **runner** is deliberately *not* part of this stack: a
|
|
|
|
|
Swarm-scheduled runner cannot redeploy the Swarm that schedules it, so CI would
|
|
|
|
|
be unavailable exactly when the cluster is what needs repairing. An earlier
|
|
|
|
|
revision did run it as a Swarm-scheduled Docker-in-Docker sidecar pinned to the
|
|
|
|
|
Forgejo node; that is gone.
|
|
|
|
|
|
|
|
|
|
Runners live in two places, and only one of them counts:
|
|
|
|
|
|
|
|
|
|
- **`runner-vps2/`** — the always-on runner, a plain `restart: always` container
|
|
|
|
|
on vps2. This is the one CI depends on.
|
|
|
|
|
- **`register-lan-runner.sh`** — the desktop's systemd runner. Extra capacity.
|
|
|
|
|
**Nothing may assume it is up.** It was the only registered runner in the
|
|
|
|
|
estate until 2026-08-01, and its 21-hour outage on 2026-07-31 froze every
|
|
|
|
|
merge and deploy and stopped the nightly backup from firing.
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
## Prerequisites
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
1. All three nodes have joined the swarm (`deploy/swarm/`) and vps1 is
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
labeled `role=forge`.
|
2026-07-26 06:56:38 +00:00
|
|
|
2. `docker node ls` (from the manager, vps2) shows all three `Ready`.
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
## One-time setup: Swarm secret
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
One secret the stack expects to already exist (a Swarm secret, not a file —
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
`external: true` in the stack file, so `docker stack deploy` never creates or
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
sees the value, only references it):
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# A strong random password for Forgejo's own Postgres (NOT related to
|
|
|
|
|
# Thermograph's app database — entirely separate instance/network).
|
|
|
|
|
openssl rand -base64 32 | docker secret create forgejo_db_password -
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Deploy / update
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Re-running is safe — Swarm only touches services whose spec actually changed.
|
2026-07-21 02:21:53 +00:00
|
|
|
Do this **before** the DNS + Caddy step below — Caddy's reverse_proxy target
|
|
|
|
|
(`127.0.0.1:3080`) needs the `forgejo` service actually listening first, or
|
|
|
|
|
its first health check just fails harmlessly until it is.
|
|
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
This stack has **no auto-deploy trigger** — nothing in `.forgejo/workflows/`
|
|
|
|
|
redeploys it on push. A change to `docker-stack.yml` only takes effect once
|
2026-07-26 06:56:38 +00:00
|
|
|
someone re-runs `docker stack deploy` by hand on the manager (vps2).
|
2026-07-25 04:13:47 +00:00
|
|
|
|
|
|
|
|
`db`/`forgejo` both carry `resources.limits` (defaults: db 1 CPU/1g, forgejo 2
|
|
|
|
|
CPU/2g — several times observed steady-state usage), overridable with
|
|
|
|
|
`FORGEJO_DB_CPUS`/`FORGEJO_DB_MEMORY`/`FORGEJO_CPUS`/`FORGEJO_MEMORY` env vars
|
|
|
|
|
before `docker stack deploy`, same convention as the app stack.
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
## DNS + TLS: reusing vps1's existing Caddy, not a second reverse proxy
|
2026-07-21 02:21:53 +00:00
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
Forgejo is pinned to vps1 (`role=forge`) — the same box that also runs
|
|
|
|
|
Grafana/Loki/Alloy and the `emigriffith.dev` portfolio site, each fronted by
|
|
|
|
|
that host's own Caddy. A second ingress (Traefik) trying to bind the same
|
|
|
|
|
ports would collide with it. So there's no Traefik in this stack: `forgejo`'s
|
|
|
|
|
web port publishes to `127.0.0.1:3080` only (host-local), and vps1's
|
|
|
|
|
*existing* Caddy gets one more site block reverse-proxying to it — same
|
|
|
|
|
pattern as its other site blocks, same automatic-HTTPS.
|
2026-07-21 02:21:53 +00:00
|
|
|
|
|
|
|
|
1. Point the Forgejo domain (default `git.thermograph.org`; override with
|
2026-07-26 06:56:38 +00:00
|
|
|
`FORGEJO_DOMAIN=...` before `docker stack deploy`) at **vps1's** public IP
|
|
|
|
|
— that's where the task actually runs, not vps2's or the desktop's.
|
|
|
|
|
2. Append `deploy/forgejo/caddy-git.conf` to vps1's `/etc/caddy/Caddyfile`,
|
2026-07-21 02:21:53 +00:00
|
|
|
adjusting the domain if you didn't use the default, then `systemctl reload
|
|
|
|
|
caddy`.
|
|
|
|
|
3. That file also resolves the registry-exposure hazard (#15 in
|
2026-07-21 21:29:14 +00:00
|
|
|
`thermograph-docs/runbooks/hop1-forgejo-registry-cutover.md`): `/v2/*` (the registry
|
2026-07-21 02:21:53 +00:00
|
|
|
API) is blocked to everything except the WireGuard mesh CIDR
|
|
|
|
|
(`10.10.0.0/24`); the git/web UI stays public. CI runners and Swarm nodes
|
|
|
|
|
reach the registry over the mesh, not the public internet — see "Registry
|
|
|
|
|
access from mesh clients" below.
|
|
|
|
|
|
2026-08-01 16:01:59 +00:00
|
|
|
## Migrating the domain (git.thermograph.org -> dev.jinemi.com)
|
|
|
|
|
|
2026-08-01 16:32:12 +00:00
|
|
|
**Web UI and OAuth: done.** `dev.jinemi.com` is Forgejo's `ROOT_URL`
|
|
|
|
|
(`FORGEJO_DOMAIN` in `docker-stack.yml`), so it is the name Forgejo generates in
|
|
|
|
|
clone URLs, the Google OAuth callback, webhook payload URLs and mail links.
|
|
|
|
|
|
|
|
|
|
**Registry: deliberately not done.** Renaming the registry host is a separate
|
|
|
|
|
migration from renaming the web UI, and the two need not happen together.
|
2026-08-01 16:01:59 +00:00
|
|
|
|
|
|
|
|
`caddy-git.conf` lists both names on a *single* site block. That is deliberate,
|
|
|
|
|
not cosmetic: the `/v2/*` mesh-only matcher is per-block, so a second block for
|
2026-08-01 16:32:12 +00:00
|
|
|
either name would re-expose the registry API publicly and undo hazard #15.
|
2026-08-01 16:01:59 +00:00
|
|
|
Whatever else changes, keep the two names in one block.
|
|
|
|
|
|
2026-08-01 16:32:12 +00:00
|
|
|
**`git.thermograph.org` must stay served.** It is not a courtesy redirect for
|
|
|
|
|
old bookmarks — CI resolves it:
|
|
|
|
|
|
|
|
|
|
- images are named by registry host, and the `git.thermograph.org/` prefix is
|
|
|
|
|
baked into the runner labels (`register-lan-runner.sh`,
|
|
|
|
|
`runner-vps2/README.md`), the CI-runner image (`ci-runner/Dockerfile`),
|
|
|
|
|
`REGISTRY_HOST` in `infra/.env.example`, and every already-pushed tag;
|
|
|
|
|
- every mesh client in "Registry access from mesh clients" below pins
|
|
|
|
|
`git.thermograph.org` to `10.10.0.2` in `/etc/hosts`, which is what makes
|
|
|
|
|
Caddy's `/v2/*` matcher see a mesh source IP instead of returning 403;
|
|
|
|
|
- registered runners hold the instance URL they registered with
|
|
|
|
|
(`https://git.thermograph.org`); they keep working while that name resolves.
|
|
|
|
|
|
|
|
|
|
### Before changing ROOT_URL again
|
|
|
|
|
|
|
|
|
|
Login is Google-SSO-only, so the Google OAuth client must already carry a
|
|
|
|
|
matching redirect URI, or nobody can reach the UI — including to undo the
|
|
|
|
|
change. Both of these are registered on client
|
|
|
|
|
`337247886376-4360kepi20f24pue62ptg0lnqhcht46l.apps.googleusercontent.com`:
|
|
|
|
|
|
|
|
|
|
https://dev.jinemi.com/user/oauth2/google/callback
|
|
|
|
|
https://git.thermograph.org/user/oauth2/google/callback
|
|
|
|
|
|
|
|
|
|
Verify rather than assume. Google answers this with no credentials: a
|
|
|
|
|
registered URI serves the sign-in page, an unregistered one returns
|
|
|
|
|
`redirect_uri_mismatch`. Substitute the host you intend to make canonical:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
CID=337247886376-4360kepi20f24pue62ptg0lnqhcht46l.apps.googleusercontent.com
|
|
|
|
|
curl -sL "https://accounts.google.com/o/oauth2/v2/auth?client_id=$CID&redirect_uri=https%3A%2F%2F<host>%2Fuser%2Foauth2%2Fgoogle%2Fcallback&response_type=code&scope=openid+email+profile&state=probe" \
|
|
|
|
|
| grep -q redirect_uri_mismatch && echo REJECTED || echo ACCEPTED
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The stack has **no auto-deploy**: a `FORGEJO_DOMAIN` change here only takes
|
|
|
|
|
effect when someone re-runs `docker stack deploy` by hand on the manager (vps2)
|
|
|
|
|
— see "Deploy / update" above. Until then the running service keeps whatever
|
|
|
|
|
`ROOT_URL` it was last deployed with, regardless of what this file says.
|
2026-08-01 16:01:59 +00:00
|
|
|
|
2026-07-21 02:21:53 +00:00
|
|
|
## Registry access from mesh clients
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
Any node that needs `docker login`/push/pull against the registry (the CI
|
|
|
|
|
runner building/pushing images, any Swarm node pulling them, prod or beta on
|
|
|
|
|
vps2 pulling app images) must reach `git.thermograph.org` **over the
|
|
|
|
|
WireGuard tunnel**, not vps1's public IP — otherwise Caddy's `/v2/*` block
|
|
|
|
|
above refuses the connection. Public DNS resolves the domain to vps1's public
|
|
|
|
|
IP, so add a `/etc/hosts` override on each such node pinning it to vps1's
|
|
|
|
|
WireGuard address instead:
|
2026-07-21 02:21:53 +00:00
|
|
|
|
|
|
|
|
```
|
|
|
|
|
echo "10.10.0.2 git.thermograph.org" | sudo tee -a /etc/hosts
|
|
|
|
|
```
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
(`10.10.0.2` is vps1's WG address per `deploy/swarm/README.md`'s peer
|
2026-07-21 02:21:53 +00:00
|
|
|
numbering — adjust if you assigned it differently.) The git/web UI keeps
|
|
|
|
|
working normally for everyone else since only `/v2/*` is restricted.
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
|
docs: correct file references and the dev reachability claim
Audited the five CLAUDE.md files and all twenty-one README.md files against the
tree, machine-checking every in-repo path they name and verifying the testable
claims against the live hosts.
The one that matters is in the root file: dev was documented as reachable on
the mesh at 10.10.0.2:8137. It is not, and never was from anywhere but vps1 —
infra/docker-compose.yml binds the port to 127.0.0.1, and the address answers
from neither vps2 nor vps1 itself. Anyone following it gets a connection
refused with nothing to explain it.
The rest are stale paths, several from the reunification:
* assetlinks.json moved under frontend/static/ in the subtree merge; the TWA
README kept the pre-merge path in both places it names it. Following it
would put the file where nothing serves it and Android app-link
verification would fail silently.
* push.py and notify.py now live in backend/notifications/.
* INFRA.md and deploy/stack/README have never existed in this repo, in any
branch.
* the Caddyfile is at deploy/stack/lb/Caddyfile.
* three bare relative paths that resolve for a reader but not from the
directory the file sits in: units.js is the frontend's, deploy.sh is
infra's, entrypoint.sh is the backend's.
Also records why mesh clients must pin the ROOT_URL host and not only the image
host: the registry's bearer-token realm follows ROOT_URL, so pinning
git.thermograph.org alone still sends the token request out the public route,
where the /v2/* matcher returns 403 and docker falls back to anonymous. That
surfaces as `unauthorized: reqPackageAccess`, indistinguishable from a bad
credential.
Verified true and left alone: the four-domain layout, both .claude runbooks,
the absence of any domain-level .forgejo directory, the pinned compose project
name, the deploy contract, prod's eight stack services, beta's five prefixed
ones with no db of its own, dev's five, and every documented make target.
2026-08-01 18:49:28 +00:00
|
|
|
**Pin the canonical name too, not just the image host.** Forgejo derives the
|
|
|
|
|
registry's bearer-token realm from `ROOT_URL`, so a client that already
|
|
|
|
|
resolves `git.thermograph.org` over the mesh is still sent to
|
|
|
|
|
`https://<ROOT_URL host>/v2/token` to collect a token. When `ROOT_URL` became
|
|
|
|
|
`dev.jinemi.com`, that second request took the public route, the `/v2/*`
|
|
|
|
|
matcher above answered 403, and docker fell back to anonymous — every push then
|
|
|
|
|
failed with `unauthorized: reqPackageAccess`, which reads exactly like a
|
|
|
|
|
revoked token or a missing scope. It is neither. Pin both names:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
echo "10.10.0.2 git.thermograph.org dev.jinemi.com" | sudo tee -a /etc/hosts
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The realm moves whenever `ROOT_URL` does, so read it rather than assuming:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
curl -sI https://git.thermograph.org/v2/ | grep -i www-authenticate
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
These `/etc/hosts` pins are host state. Nothing in this repo writes them, so a
|
|
|
|
|
reprovisioned node has to be pinned again before it can pull.
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
## Register the Actions runner
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
|
|
|
|
|
Once Forgejo answers at its domain:
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-07-26 06:56:38 +00:00
|
|
|
# On the runner host (see DEPLOY-DEV.md for where that is today):
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
# 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
|
2026-07-26 06:56:38 +00:00
|
|
|
self-hosted runner) and why it registers with two labels where there used to
|
|
|
|
|
be two separate runners.
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
`config.yaml`'s `runner.capacity` is raised from the tool's default of 1 to 8
|
|
|
|
|
(override with `CAPACITY=`) — a single PR push fires `pr-build`,
|
|
|
|
|
`secrets-guard`, and `shell-lint` simultaneously (3 independent workflows,
|
|
|
|
|
no `needs:` between them), so capacity 1 serializes work that could run in
|
|
|
|
|
parallel, and even capacity 3 (an earlier, undocumented hand-tune) leaves
|
|
|
|
|
`build-backend`/`build-frontend`/`validate-observability` queued behind
|
2026-07-26 06:56:38 +00:00
|
|
|
those three before they get a slot.
|
2026-07-25 04:13:47 +00:00
|
|
|
|
forgejo: record why the runner is on vps2, rather than leaving a contradiction
This README said, in bold, that a runner must never go on prod or beta. A
runner has been registered and running on vps2 since 2026-08-01. Both
statements cannot stand: an instruction the estate visibly ignores teaches
readers to ignore the next one too.
The original objection is kept rather than deleted, because it is correct on
its merits -- docker_host: automount hands job containers the host's Docker
socket, which on vps2 is root over both the prod and beta stacks. What changed
is that the alternative proved worse. The desktop was the ONLY registered
runner in the estate; when it dropped on 2026-07-31 nothing merged, nothing
deployed and the nightly backup did not fire for 21 hours. On a repo where
every branch is protected and every change is a PR, one absent runner freezes
everything, and the backup hangs off the same path.
So the section now records the trade and the bounds actually applied --
capacity 1, --cpus=2/--memory=4g on job containers, valid_volumes empty, no
thermograph network -- and states plainly that this is defence against
accident, not against a hostile workflow author. Forgejo's database already
stored VPS2_SSH_KEY, which is root on that box; what changed is that the
material is now reachable by a job rather than only at rest.
It also keeps the original advice for the case it was actually written for:
if you are adding CAPACITY rather than REDUNDANCY, raise capacity or use a box
that hosts nothing.
2026-08-01 16:59:14 +00:00
|
|
|
**This paragraph used to say a runner must never go on vps2. That was reversed
|
|
|
|
|
deliberately on 2026-08-01, and the reasoning is worth keeping rather than
|
|
|
|
|
quietly deleting.**
|
|
|
|
|
|
|
|
|
|
The original objection stands on its merits: `container.docker_host: automount`
|
|
|
|
|
gives job containers the *host's* Docker socket, so a runner on vps2 means any
|
|
|
|
|
CI job has root-equivalent access to both the prod and beta stacks running
|
|
|
|
|
there. An earlier revision of this stack ran the runner as a Swarm-hosted
|
|
|
|
|
container on the Forgejo node and was reverted for the same class of reason.
|
|
|
|
|
|
|
|
|
|
What changed is that the alternative turned out to be worse. The desktop was
|
|
|
|
|
the estate's **only** registered runner, and when it went offline on
|
|
|
|
|
2026-07-31 nothing merged, nothing deployed, and the nightly backup did not
|
|
|
|
|
fire for 21 hours — on a repo where every branch is protected and every change
|
|
|
|
|
is a PR, a single absent runner freezes the whole estate. Availability of CI
|
|
|
|
|
is not a convenience here; it is what the backup and the deploy path both
|
|
|
|
|
hang off.
|
|
|
|
|
|
|
|
|
|
So the runner on vps2 (`runner-vps2/`) is a considered trade, not an
|
|
|
|
|
oversight, and it is bounded rather than assumed benign:
|
|
|
|
|
|
|
|
|
|
- `capacity: 1` — one job at a time, so a CI burst cannot contend with
|
|
|
|
|
`thermograph_web` for the box's six cores.
|
|
|
|
|
- `--cpus=2 --memory=4g` on job containers, set in `container.options`. Job
|
|
|
|
|
containers are siblings of the runner rather than children, so the runner's
|
|
|
|
|
own compose limits do not reach them; that option is the only lever that
|
|
|
|
|
does.
|
|
|
|
|
- `valid_volumes: []` — a job cannot bind-mount a host path, which is the
|
|
|
|
|
difference between "a job can read `/etc/thermograph.env`" and "a job
|
|
|
|
|
cannot", on the box where that file is prod's.
|
|
|
|
|
- The compose project joins no `thermograph` network.
|
|
|
|
|
|
|
|
|
|
Be honest about what that buys: it is defence against **accident**, not
|
|
|
|
|
against a hostile workflow author. Forgejo's own database on vps1 already
|
|
|
|
|
stores `VPS2_SSH_KEY`, which is root on vps2, so the material was already
|
|
|
|
|
reachable — what changed is that it is now reachable by a *job* rather than
|
|
|
|
|
only at rest. On a two-person estate where both people can already SSH to that
|
|
|
|
|
box as root, that is the honest boundary.
|
|
|
|
|
|
|
|
|
|
**If you are adding capacity rather than redundancy, raise `capacity` or add a
|
|
|
|
|
runner on a box that hosts nothing.** The vps2 runner exists so that CI has a
|
|
|
|
|
second home, not because prod is a good place to run CI.
|
2026-07-25 04:13:47 +00:00
|
|
|
|
|
|
|
|
## Custom CI job image (`ci-runner/`)
|
|
|
|
|
|
|
|
|
|
`ci-runner/Dockerfile` still bases on `node:20-bookworm` — Node is a hard
|
|
|
|
|
requirement, not leftover: Forgejo's runner executes `actions/checkout@v4`
|
|
|
|
|
(used by every workflow) as `node dist/index.js` *inside the job container*,
|
|
|
|
|
regardless of whether the workflow itself uses npm/node. (v1 of this image
|
|
|
|
|
tried a Node-free Debian-slim base and broke every job's checkout step —
|
|
|
|
|
`node: executable file not found` — within a minute of going live; reverted
|
|
|
|
|
immediately.) What it actually fixes: every `docker`-labeled build-push job
|
|
|
|
|
currently re-installs the Docker CLI on each run (`apt-get install
|
|
|
|
|
docker.io`), which pulls in the classic builder rather than BuildKit (the
|
|
|
|
|
classic builder mishandles `COPY --chown=<name>` group resolution — a real
|
|
|
|
|
bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` +
|
|
|
|
|
`docker-buildx-plugin` (BuildKit) on top of the same Node base, plus
|
|
|
|
|
`git`/`python3`/`python3-yaml` for the other jobs that need them
|
|
|
|
|
(`shell-lint`, `observability-validate`).
|
|
|
|
|
|
2026-07-31 03:53:26 +00:00
|
|
|
Current tag: `git.thermograph.org/jinemi/thermograph/ci-runner:v2` (`v1` is
|
2026-07-25 04:13:47 +00:00
|
|
|
broken — do not register any runner against it). Rebuild/push (requires a PAT
|
|
|
|
|
with `write:package` scope — the embedded git-remote token lacks it, same
|
2026-07-25 07:48:49 +00:00
|
|
|
requirement documented in `build-push.yml`):
|
2026-07-25 04:13:47 +00:00
|
|
|
|
|
|
|
|
```bash
|
2026-07-31 03:53:26 +00:00
|
|
|
docker build -t git.thermograph.org/jinemi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
|
|
|
|
|
docker push git.thermograph.org/jinemi/thermograph/ci-runner:vN
|
2026-07-25 04:13:47 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`register-lan-runner.sh`'s `LABELS` default points at the current tag, so
|
|
|
|
|
fresh registrations pick it up automatically. The live runner is cut over by
|
2026-07-26 06:56:38 +00:00
|
|
|
editing the `labels` array in `~/forgejo-runner/.runner` on the runner host
|
|
|
|
|
(same runner id/token, no re-registration needed) and restarting the service —
|
2026-07-25 04:13:47 +00:00
|
|
|
**verify a real job runs green under the new image before relying on it**,
|
|
|
|
|
same way v1's break was caught. Only after that verification should the
|
|
|
|
|
now-redundant `apt-get install docker.io` / `python3-yaml` steps be removed
|
|
|
|
|
from the workflows that had them — removing them first would break every job
|
|
|
|
|
still running on the stock `node:20-bookworm` image.
|
|
|
|
|
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
## Why Postgres here and not the Thermograph app's TimescaleDB
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
Separate instance, separate network (`forgejo_net`, not the app's overlay
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
network), separate volume. Forgejo is a distinct product with its own schema
|
|
|
|
|
and its own backup/restore lifecycle — sharing a database with the app would
|
|
|
|
|
couple two things that should be able to fail, migrate, and restore
|
|
|
|
|
independently.
|
|
|
|
|
|
|
|
|
|
## Verifying
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-07-21 02:21:53 +00:00
|
|
|
docker service ls # forgejo_db, forgejo_forgejo both Running, 1/1
|
|
|
|
|
curl -I https://git.thermograph.org/ # 200, valid cert (Caddy's, not a new one)
|
|
|
|
|
curl -I https://git.thermograph.org/v2/ # 403 from anywhere off the WireGuard mesh
|
2026-07-26 06:56:38 +00:00
|
|
|
# On the runner host, after registering the runner:
|
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)
2026-07-21 01:43:19 +00:00
|
|
|
systemctl --user status forgejo-runner # active, both labels registered
|
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234)
Three additive infrastructure layers on top of the two VPS boxes Terraform
already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old
VPS, 75.119.132.91). None of this touches backend/, Dockerfile,
docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the
app-containerization work in flight elsewhere; this is strictly the layer on
top. See INFRA.md for the full runbook and order of operations.
- deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login
(not raw root) for agent-driven ops — passwordless sudo under a distinct
username, sshd hardened to key-only auth, auditd logging every
root-effective command. One line to revoke.
- deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a
WireGuard tunnel rather than trusting the public internet with the
overlay data plane, which Docker's own guidance says should never face it
directly. Swarm ports locked to the tunnel interface once joined. This
cluster's only workload is Forgejo — it does not orchestrate the
Terraform-managed app deploys, so nothing here can strand the app's
single-writer database.
- deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a
Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus
Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom
auto-merge workflow step is dropped — it existed only to work around
GitHub's paywalled branch protection on private free-tier repos, which
Forgejo has no such tier for; native "auto merge when checks succeed"
replaces it, and as a real git push (unlike GitHub's non-triggering
token-merge) it fires the LAN deploy naturally with no double-trigger
logic needed. appleboy/ssh-action is referenced by full URL (not mirrored
on Forgejo's default action registry); actions/checkout and
actions/setup-python resolve unchanged.
Migration is mirror-first: the GitHub repo import and workflow files land
here, but cutting deploy secrets over and retiring GitHub happens only after
verification (INFRA.md 3d) — GitHub stays live as a fallback throughout.
One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but
terraform.tfvars.example names prod's deploy branch `release`. Left as a
faithful mirror rather than guessed at — reconcile with whoever's driving
Terraform/deploy.
2026-07-21 00:36:39 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Rollback / removal
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
docker stack rm forgejo
|
|
|
|
|
# volumes (forgejo_data, forgejo_db, ...) survive a stack rm — remove them
|
|
|
|
|
# explicitly only if you actually want to destroy the Forgejo instance's data.
|
|
|
|
|
```
|