`runs-on: ubuntu-latest` stays what it is everywhere else — a disposable container — and `docker-compose.ci.yml` is now what makes that true: the label maps to `catthehacker/ubuntu:act-24.04`, job containers share the runner's network so the cache server is reachable at all, and the host's docker socket is passed through (`docker_host: automount`) so the image workflow can build. The failure this is replacing is worth naming, because the symptom does not point at it: with a **host**-mode runner answering the same label, every job runs directly on the server as the runner's own user, so the workflows' `apt-get install` steps fail on permissions — and had they succeeded, CI would have been installing packages on a host. A workflow cannot tell which kind of runner it got; only the label map can say. `docs/gitea-runner.md` is the separate guide: why docker mode, the prerequisites including Gitea's two `app.ini` settings, the install, how to remove a host-mode runner (and what to check before you do, since a runner is shared), how to verify a job really is in a container, why labels cannot be changed without re-registering, and a table of the failures with their causes. Also `workflow_dispatch` on the test workflow, so a fresh runner can be proved from the UI without inventing a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.2 KiB
A dockerized Gitea Actions runner
Everything in .gitea/workflows needs a runner, and it has to be a docker-mode
one. This is how to set one up, what each setting is for, and what goes wrong if
it is set up the other way.
- Why docker mode and not host mode
- Prerequisites
- Install
- Removing a host-mode runner
- Verifying it
- Changing the labels later
- What can go wrong
Why docker mode and not host mode
A Gitea runner advertises labels, and each label says where a job runs:
| Label | Means |
|---|---|
ubuntu-latest:docker://catthehacker/ubuntu:act-24.04 |
run the job in a fresh container from that image |
ubuntu-latest:host |
run the job directly on the machine, as the runner's own user |
A workflow only says runs-on: ubuntu-latest. It cannot tell which of those it
got, and the two behave nothing alike:
- Host mode has no job isolation and no job toolchain.
apt-get installeither fails (the runner user is not root — the common case) or succeeds, and then you have CI installing packages on a server.actions/checkoutclones into a temp directory that persists between runs, so one job's leftovers are the next job's surprise. Every workflow written against GitHub's runners assumes a clean container and does not get one. - Docker mode is what
ubuntu-latestmeans everywhere else. A disposable container per job, the tools the image ships, and nothing left behind.
Host mode is for the narrow case where a job must touch the machine — a deploy script using that host's ssh keys, say. Everything else wants docker mode.
One label, one meaning. If two runners both claim ubuntu-latest, jobs land on
whichever answers first, and a workflow that passes on one fails on the other for
reasons nobody can see from the logs. Pick one, or give the special-purpose runner
a label of its own (deploy:host) and leave ubuntu-latest to the docker one.
Prerequisites
-
Docker and the compose plugin on the host (
docker --version,docker compose version). -
Gitea with Actions on. In
app.ini:[actions] ENABLED = true ; so `uses: actions/checkout@v4` can be resolved from GitHub DEFAULT_ACTIONS_URL = github -
Disk. The job image is ~1–2 GB; the Android SDK cache another ~3 GB, and gradle caches grow. Budget 20 GB and it will not surprise you.
-
A registration token, from whichever scope the runner should serve:
Scope Where Serves Repository repo → Settings → Actions → Runners → Create new runner that repo only Organisation org → Settings → Actions → Runners every repo in the org Instance Site Administration → Actions → Runners everything on the instance Prefer the narrowest scope that covers what you need — the runner's docker socket is effectively root on its host, and an instance-level runner hands that to every repository on the server.
Install
Two files, both in this repo: docker-compose.ci.yml (the service) and
deploy/act-runner.yaml (act_runner's own config). On the runner host:
mkdir -p /opt/gitea-runner && cd /opt/gitea-runner
# From a checkout, or scp them across:
cp /path/to/kadence/docker-compose.ci.yml docker-compose.yml
cp /path/to/kadence/deploy/act-runner.yaml config.yaml
# The token is needed once, at registration. Keep it out of the compose file.
printf 'GITEA_INSTANCE_URL=https://git.timka.su\n' > .env
printf 'GITEA_RUNNER_REGISTRATION_TOKEN=%s\n' "<paste the token>" >> .env
printf 'GITEA_RUNNER_NAME=%s\n' "$(hostname)-docker" >> .env
chmod 600 .env
docker compose up -d
docker compose logs -f # expect: "runner registered successfully"
That is it. The runner appears in Gitea under the scope you took the token from, with its labels listed beside it.
What the pieces do
GITEA_RUNNER_LABELSis the label map, and it is read only at first registration — see Changing the labels later../dataholds.runner: the runner's identity and its permanent token. Back it up or do not delete it; removing it makes the runner register again as a new one and leaves a dead entry behind in Gitea./var/run/docker.sockis mounted so a job can build images. Withdocker_host: automountinconfig.yaml, act_runner passes it into the job container too, which is what letsdocker buildwork inside a workflow. Treat it as what it is: any workflow on this runner can do anything to this host's docker.- The shared
gitea-runnernetwork. Job containers join the runner's own network (container.network), because act_runner's built-in cache server — the thing that makesactions/cache@v4work — advertises its own address, and from the default bridge that address routes nowhere. Symptom if you get it wrong: cache steps hang, then time out. catthehacker/ubuntu:act-24.04rather thanubuntu:24.04, because Gitea's javascript actions (checkout,cache,upload-artifact) execute inside the job container and need anodethere. This image also ships Python 3.12, which Kadence's backend requires.
Removing a host-mode runner
If the box already has one (the usual sign: jobs fail at apt-get with permission
errors, or succeed and change the host):
systemctl disable --now act_runner.service
mv /var/lib/act_runner/.runner /var/lib/act_runner/.runner.disabled # reversible
rm -f /etc/systemd/system/act_runner.service && systemctl daemon-reload
rm -f /usr/local/bin/act_runner
Then delete its (now offline) entry in Gitea, or it sits there looking available.
Check what else used it first. A runner is shared: look at its recent tasks
(journalctl -u act_runner | grep "task .* repo is") and at the other repos'
workflows. Jobs that ran on the host because they needed the host — deploying
with that machine's ssh keys, writing to a host path — will not work in a
container as they are. The docker socket is mounted, so docker compose steps
generally still do.
Verifying it
docker compose logs --tail 20 # "runner registered" / "daemon started"
In Gitea: the runner is listed, online, with its labels. Then run something — a
push, or the Run workflow button if the workflow has workflow_dispatch. The
first job pulls the image, so give it a few minutes; later ones start in seconds.
Job logs should show the job running as root inside a container:
whoami -> root
cat /etc/os-release -> Ubuntu 24.04
If it says your runner's user and the host's distribution, the job landed on a host-mode runner and the label map is wrong.
Changing the labels later
Labels live in ./data/.runner after registration; the environment variable is
only read when that file does not exist. To change them, re-register:
cd /opt/gitea-runner
docker compose down
rm data/.runner # the old registration goes stale in Gitea
# get a fresh token, put it in .env, adjust GITEA_RUNNER_LABELS
docker compose up -d
Then delete the stale runner in Gitea. (Editing .runner's labels array by hand
works too, but Gitea keeps its own copy, so the UI and the runner can disagree —
re-registering is the honest route.)
What can go wrong
| Symptom | Cause |
|---|---|
| Jobs stay queued forever | No runner claims that runs-on label, or the runner is registered at a scope that does not include this repo |
Cannot find: node in PATH |
A job image without node — use a catthehacker/* image, not bare ubuntu |
apt-get: Permission denied |
The job is on a host-mode runner |
uses: actions/checkout@v4 fails to resolve |
DEFAULT_ACTIONS_URL is not github |
| Cache steps hang | Job containers are not on the runner's network — see container.network |
docker: command not found in a job |
That image has no docker CLI; use the catthehacker one, which does |
Cannot connect to the Docker daemon in a job |
docker_host: automount missing from config.yaml, or the socket is not mounted into the runner |
| Everything is slow on the first run | It is pulling the job image. It is cached afterwards |