# 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"