Answering a CI-posture question from vastblue-dev meant measuring three things rather than recalling them. Two came back the opposite of the way the config reads: - `container.valid_volumes: []` does NOT keep the docker daemon out of jobs. act_runner mounts /var/run/docker.sock on its own, so every job on the shared runner is uid 0 with `docker ps` over all 49 containers on ana-docker — gitea, synapse, phasefinal-web, adguardhome included. It is also load-bearing: four repos drive buildx through it, so the fix is isolation onto a dedicated runner, not tightening this one. - A full-URL `uses: https://gitea.phasefinal.com/actions/checkout@v4` resolves from the local mirrors today. That is github-independence per workflow without the DEFAULT_ACTIONS_URL flip that has been parked on act_runner's action-fetch auth since 2026-08-05. Also recorded: `services:` containers work (Postgres 16 on the service name), job images need a node binary for JS actions, and `/actions/runs` lists runs that `/actions/tasks` reports as empty on gitea 1.26.1. Measured on a throwaway repo under the claude-bot account, since deleted. config.yaml change is comment-only and deliberately not deployed — it would bounce the runner for no runtime effect.
7.5 KiB
gitea-runner
Self-hosted Gitea Actions
runner. Polls gitea.phasefinal.com for jobs from any repo that has a
.gitea/workflows/ directory and runs them in ephemeral docker
containers on this host.
Server: ana-docker (single central runner — see "Topology" below
for when to add more)
Image: gitea/act_runner:latest
Outbound only — no host port published; the runner connects out to
gitea, gitea never connects in.
Topology
We run one central runner on ana-docker. Reasoning:
- gitea is on ana-docker, so runner→API is local
- existing fleet tooling (
elway,sync-stacks,deploy-stack,refresh-server-info) already SSHes from one origin to all hosts; the runner inherits that pattern - single point to manage SSH keys, secrets, and runner upgrades
The deploy playbook (playbooks/deploy-gitea-runner.yaml) is
parameterized by host / runner-name / labels, so spinning up
nh3-docker-runner or an ESH runner later is a one-line elway
invocation — not a copy-pasted playbook.
When to add a site-local runner:
- Cross-site SSH from ana-docker to that site has become unreliable
- A workflow needs LAN access to something only reachable from inside that site's network segment
- You want failure isolation (NH3 can deploy itself when Anaheim is down)
Until one of those bites, one runner is enough.
What a job on this runner can actually do
Measured 2026-09-02 on pfi-fleet (throwaway repo, three jobs, since deleted).
Recorded because two of these are commonly assumed the other way.
| capability | result |
|---|---|
services: containers |
yes — Postgres 16 answered on the service name as hostname after ~6 s; wait on pg_isready, not on ordering |
| host docker daemon | yes, root-equivalent — /var/run/docker.sock is in every job container, docker ps showed all 49 host containers, docker compose v2.33.0 on PATH |
uses: from the local mirrors |
yes — uses: https://gitea.phasefinal.com/actions/checkout@v4 resolves and runs, with no DEFAULT_ACTIONS_URL change |
⚠ container.valid_volumes: [] does not keep docker out of jobs. act_runner
mounts the daemon socket itself, independently of that list, so a tight-looking
valid_volumes is not containment. Any repo the runner serves — it is registered
instance-wide — can control everything on ana-docker, gitea included. Four repos
(vh/Worldtree, vh/soong-lab, vh/skaldsong, vh/wt-matrix-bridge) drive
buildx through it, so it is load-bearing and closing it would break their CI.
Isolate sensitive builds onto a dedicated runner rather than tightening this one.
⚠ Job images need a node binary. JS actions execute as node /var/run/act/...,
so python:3-slim fails on the first uses:. Use node:20-bookworm, or
docker:cli plus apk add --no-cache git nodejs when the job also builds images.
Full-URL uses: is the un-parked half of the github-independence work. The
global DEFAULT_ACTIONS_URL=self flip is still blocked on act_runner's
action-fetch auth, but a per-workflow full-URL ref needs neither the flip nor the
auth path. Mirrors live under the actions and astral-sh orgs, all public:
checkout, cache, upload-artifact, download-artifact, setup-node, setup-python,
setup-uv.
Polling a run from the API: use /actions/runs, not /actions/tasks — on
gitea 1.26.1 tasks returned an empty workflow_runs for a run that runs listed
and executed.
Prereqs
Before running the deploy playbook:
-
Verify Gitea Actions is enabled. In gitea 1.21+ Actions ships on by default, but check
/-/admin/actionsresolves. If not, addGITEA__actions__ENABLED=trueto the gitea stack env and bounce. -
Generate a registration token. Pick the scope:
Scope URL Use when Global (admin) https://gitea.phasefinal.com/-/admin/actions/runnersrunner serves any repo on the instance (recommended for the central PFI runner) Org/user https://gitea.phasefinal.com/<owner>/-/actions/runnersrunner serves all repos under one owner Repo https://gitea.phasefinal.com/<owner>/<repo>/settings/actions/runnersrunner serves one repo Register-as-admin is right for our use case: one runner, fleet-wide.
-
Create an SSH deploy key for the runner that lets it execute the elway playbooks against fleet hosts. The key lives only on ana-docker (passed in as a workflow secret per repo, or mounted into the runner via volume — see "Wiring deploys" below).
-
(Optional) Create a Gitea PAT with
read:repositoryscope onvh/esh-pfi-infrastructure. Workflows need to check out the management repo to get at the playbooks; the auto-injectedGITHUB_TOKENonly works for the triggering repo.
Deploy
# Edit .env first if not using defaults — at minimum paste the registration token
$EDITOR stacks/gitea-runner/.env.example # template
# First-time deploy
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml
# Site-local runner later (NH3 or ESH)
scripts/elway nh3-docker --playbook playbooks/deploy-gitea-runner.yaml \
--var runner_name=nh3-docker-runner --var runner_labels=pfi-fleet,nh3-docker
The playbook seeds .env from .env.example only if absent; for the
first run, copy .env.example to /opt/docker/compose/gitea-runner/.env
on the host and paste the registration token in before running, OR
let the playbook seed it and edit on the host before the
docker compose up -d step (it's idempotent — second run will pick up
the edited token).
After successful registration, the token is consumed (it's one-time
use). You can clear GITEA_RUNNER_REGISTRATION_TOKEN from .env;
the runner reads its permanent credentials from ${DATA_DIR}/.runner
on subsequent starts.
Wiring deploys
A workflow that runs on the central runner needs three things:
runs-on:matching a runner label —pfi-fleet(cross-fleet) orana-docker(pin to that host).- An SSH key to reach the deploy target. Stored as a repo or
org-level Actions secret named e.g.
DEPLOY_SSH_KEY. The corresponding public key must be in~lkraven/.ssh/authorized_keyson every host the workflow targets. - A token to clone
vh/esh-pfi-infrastructureif the workflow wants to invoke an elway playbook from this repo. Stored asMGMT_REPO_TOKEN(Gitea PAT,read:repositoryscope).
See stacks/task-board/gitea-workflow-deploy.yaml.example for a
complete deploy workflow that consumes all three.
Path layout (on ana-docker)
| Host path | Container path | Purpose | Restic? |
|---|---|---|---|
/opt/docker/compose/gitea-runner/ |
— | compose.yaml + .env | included (via /opt/docker) |
/opt/docker/conf/gitea-runner/data/ |
/data |
.runner creds, cache, job workspaces |
excluded (regenerable; nothing irreplaceable) |
Operations
# Tail runner logs
ssh ana-docker docker logs -f gitea-runner
# List currently registered runners (admin)
# https://gitea.phasefinal.com/-/admin/actions/runners
# Re-register (lost the .runner file? regenerate token, then:)
ssh ana-docker docker compose -f /opt/docker/compose/gitea-runner/compose.yaml down
ssh ana-docker rm /opt/docker/conf/gitea-runner/data/.runner
# paste new GITEA_RUNNER_REGISTRATION_TOKEN into .env
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml
# Pin to a specific act_runner version
# Edit RUNNER_IMAGE in /opt/docker/compose/gitea-runner/.env, then:
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml