thermograph/infra/deploy/forgejo/runner-vps2/docker-compose.yml
Emi Griffith 001e6b1365
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 13s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 7s
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 07:54:28 -07:00

83 lines
4 KiB
YAML

# The always-on Forgejo Actions runner, on vps2.
#
# cd /opt/forgejo-runner # a COPY of this file, not the checkout
# docker compose up -d
#
# It runs from /opt/forgejo-runner rather than in place, because the checkout is
# git reset --hard'd on every prod deploy and one `git clean -fdx` there would
# delete data/.runner. See README.md.
#
# Registration is a one-off and is NOT in this file, because it takes a
# short-lived token a human fetches. See README.md.
#
# ============================================================================
# WHY A SECOND RUNNER EXISTS AT ALL
# ============================================================================
# Until 2026-08-01 the estate had exactly one registered runner, on the desktop.
# It went offline at 2026-07-31 16:31Z and everything stopped for 21 hours: no
# merge could satisfy a required check, no deploy could run, and the 03:00Z
# ops-cron -- THE backup for both application databases and for Forgejo -- did
# not fire. Forgejo queued that run rather than dropping it, so it completed on
# reconnect, but a longer outage would have meant real backup gaps.
#
# So this runner is not extra capacity. It is the one that has to be up.
#
# ============================================================================
# WHY A PLAIN CONTAINER AND NOT A SWARM SERVICE
# ============================================================================
# Same argument as infra/openbao/config/openbao.hcl makes for OpenBao: the thing
# that repairs the estate must not depend on the estate. A Swarm-scheduled
# runner cannot redeploy the Swarm it is scheduled by, and if the cluster is the
# thing that is broken, CI is gone exactly when it is needed. `restart: always`
# on the local daemon has one dependency: the local daemon.
#
# An earlier revision of deploy/forgejo/docker-stack.yml did run a runner as a
# Swarm-scheduled Docker-in-Docker sidecar. It was removed, and the docs kept
# claiming an "always-on Swarm-hosted runner" for weeks afterwards -- which is
# how the single point of failure went unnoticed.
#
# ============================================================================
# WHAT MOUNTING THE DOCKER SOCKET ON THE PROD HOST MEANS
# ============================================================================
# Be plain about it: /var/run/docker.sock is root-equivalent on vps2, and vps2
# runs prod. A workflow that can start a container can mount / and read
# /etc/thermograph.env. This runner does not create that exposure from nothing
# -- Forgejo's own database already stores VPS2_SSH_KEY, which is root on this
# box -- but it does move the exposure from "a secret at rest" to "a secret a
# job can reach", and that is a real difference.
#
# What bounds it, in order of how much each is worth:
# 1. Only this repo's workflows run here, and only the owner can push to it.
# 2. config.yaml sets valid_volumes: [] -- jobs cannot bind-mount host paths.
# 3. capacity: 1 and --cpus/--memory keep a job from contending with prod.
# 4. This compose project joins no thermograph network, so a job container
# reaches prod's services no more easily than any other host process.
#
# It is defence against accident, not against a hostile workflow author. On a
# two-person estate where both people can already SSH to this box as root, that
# is the honest boundary.
name: forgejo-runner
services:
runner:
image: code.forgejo.org/forgejo/runner:6.3.1
restart: always
# The socket is root:docker on the host; the image's default user is not in
# that group. Running as root is what the socket mount requires.
user: root
working_dir: /data
command: forgejo-runner daemon --config /data/config.yaml
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
# Bounds the DAEMON. Job containers are siblings started through the socket,
# not children, so they are unaffected by these -- container.options in
# config.yaml is what bounds those.
cpus: 1.0
mem_limit: 1g
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"