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 (self-hosted Git + CI/CD) as a Docker Swarm stack — the only workload
|
|
|
|
|
# this Swarm cluster runs (see deploy/swarm/README.md). Deliberately separate
|
|
|
|
|
# from the Terraform-managed docker-compose.yml that runs the Thermograph app
|
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
|
|
|
# itself: this stack's only job is Forgejo, its container registry, and the
|
|
|
|
|
# ingress in front of 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
|
|
|
#
|
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
|
|
|
# The Actions RUNNER is deliberately NOT a service in this stack. Per
|
|
|
|
|
# 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:
|
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
|
|
|
#
|
|
|
|
|
# docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
|
|
|
|
|
#
|
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
|
|
|
# Requires this Swarm secret to exist first (see deploy/forgejo/README.md):
|
|
|
|
|
# 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 the Forgejo domain at prod's or beta's public IP (not the
|
|
|
|
|
# desktop's — it isn't a stable, publicly-reachable address). Swarm's routing
|
|
|
|
|
# mesh accepts published ports on every node and forwards to wherever the
|
|
|
|
|
# task actually runs, so either of the two VPS IPs works.
|
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
|
|
|
# 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.
|
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
|
|
|
|
|
|
|
|
services:
|
|
|
|
|
db:
|
|
|
|
|
image: postgres:16-alpine
|
|
|
|
|
environment:
|
|
|
|
|
POSTGRES_USER: forgejo
|
|
|
|
|
POSTGRES_DB: forgejo
|
|
|
|
|
POSTGRES_PASSWORD_FILE: /run/secrets/forgejo_db_password
|
|
|
|
|
secrets: [forgejo_db_password]
|
|
|
|
|
volumes:
|
|
|
|
|
- forgejo_db:/var/lib/postgresql/data
|
|
|
|
|
networks: [forgejo_net]
|
|
|
|
|
deploy:
|
|
|
|
|
placement:
|
|
|
|
|
constraints: [node.labels.role == forge]
|
|
|
|
|
restart_policy:
|
|
|
|
|
condition: on-failure
|
|
|
|
|
|
|
|
|
|
forgejo:
|
|
|
|
|
image: codeberg.org/forgejo/forgejo:9-rootless
|
|
|
|
|
depends_on: [db]
|
|
|
|
|
environment:
|
|
|
|
|
FORGEJO__database__DB_TYPE: postgres
|
|
|
|
|
FORGEJO__database__HOST: db:5432
|
|
|
|
|
FORGEJO__database__NAME: forgejo
|
|
|
|
|
FORGEJO__database__USER: forgejo
|
|
|
|
|
FORGEJO__server__DOMAIN: "${FORGEJO_DOMAIN:-git.thermograph.org}"
|
|
|
|
|
FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN:-git.thermograph.org}/"
|
|
|
|
|
FORGEJO__server__SSH_PORT: "2222"
|
|
|
|
|
# Actions on: repo/org/user-level runners register against this instance.
|
|
|
|
|
FORGEJO__actions__ENABLED: "true"
|
|
|
|
|
# Gitea/Forgejo's app.ini env-mapping honors a __FILE suffix to read a
|
|
|
|
|
# value from a file instead of the literal env var — same convention as
|
|
|
|
|
# the official Postgres image's POSTGRES_PASSWORD_FILE above.
|
|
|
|
|
FORGEJO__database__PASSWD__FILE: /run/secrets/forgejo_db_password
|
|
|
|
|
FORGEJO__database__USER: forgejo
|
|
|
|
|
secrets:
|
|
|
|
|
- source: forgejo_db_password
|
|
|
|
|
target: forgejo_db_password
|
|
|
|
|
volumes:
|
|
|
|
|
- forgejo_data:/var/lib/gitea
|
|
|
|
|
networks: [forgejo_net]
|
|
|
|
|
ports:
|
|
|
|
|
# SSH for git@ clones — Swarm's routing mesh publishes this on both nodes.
|
|
|
|
|
- target: 2222
|
|
|
|
|
published: 2222
|
|
|
|
|
protocol: tcp
|
|
|
|
|
mode: host
|
|
|
|
|
deploy:
|
|
|
|
|
placement:
|
|
|
|
|
constraints: [node.labels.role == forge]
|
|
|
|
|
restart_policy:
|
|
|
|
|
condition: on-failure
|
|
|
|
|
labels:
|
|
|
|
|
- traefik.enable=true
|
|
|
|
|
- traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN:-git.thermograph.org}`)
|
|
|
|
|
- traefik.http.routers.forgejo.entrypoints=websecure
|
|
|
|
|
- traefik.http.routers.forgejo.tls.certresolver=le
|
|
|
|
|
- traefik.http.services.forgejo.loadbalancer.server.port=3000
|
|
|
|
|
|
|
|
|
|
traefik:
|
|
|
|
|
image: traefik:v3.1
|
|
|
|
|
command:
|
|
|
|
|
- --providers.swarm.endpoint=unix:///var/run/docker.sock
|
|
|
|
|
- --providers.swarm.exposedbydefault=false
|
|
|
|
|
- --entrypoints.web.address=:80
|
|
|
|
|
- --entrypoints.websecure.address=:443
|
|
|
|
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
|
|
|
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
|
|
|
|
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:-admin@thermograph.org}
|
|
|
|
|
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
|
|
|
|
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
|
|
|
|
ports:
|
|
|
|
|
- target: 80
|
|
|
|
|
published: 80
|
|
|
|
|
mode: host
|
|
|
|
|
- target: 443
|
|
|
|
|
published: 443
|
|
|
|
|
mode: host
|
|
|
|
|
volumes:
|
|
|
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
|
|
|
- traefik_certs:/letsencrypt
|
|
|
|
|
networks: [forgejo_net]
|
|
|
|
|
deploy:
|
|
|
|
|
placement:
|
|
|
|
|
# Pinned to the same node as the ACME cert store volume — a floating
|
|
|
|
|
# Traefik would re-request certs from Let's Encrypt on every
|
|
|
|
|
# reschedule and hit its rate limit.
|
|
|
|
|
constraints: [node.labels.role == forge]
|
|
|
|
|
restart_policy:
|
|
|
|
|
condition: on-failure
|
|
|
|
|
|
|
|
|
|
networks:
|
|
|
|
|
forgejo_net:
|
|
|
|
|
driver: overlay
|
|
|
|
|
attachable: false
|
|
|
|
|
|
|
|
|
|
volumes:
|
|
|
|
|
forgejo_db:
|
|
|
|
|
forgejo_data:
|
|
|
|
|
traefik_certs:
|
|
|
|
|
|
|
|
|
|
secrets:
|
|
|
|
|
forgejo_db_password:
|
|
|
|
|
external: true
|