Install with Docker
The official images are published on Docker Hub as docker.io/gitea/runner.
latest is the newest release, nightly is built from the main branch, and every release is also tagged with its version.
In the container the registration and the daemon are combined: the entrypoint registers the runner on first start (when no registration file exists yet) and then execs gitea-runner daemon.
Image flavours
All flavours contain the same gitea-runner binary and differ only in how a Docker daemon is made available to jobs.
| Tag | Base image | Docker daemon | Supervisor | Runs as |
|---|---|---|---|---|
nightly, latest, <version> | alpine | none, you provide one | tini | root |
nightly-dind, latest-dind | docker:dind | bundled, needs --privileged | s6 | root |
nightly-dind-rootless, latest-dind-rootless | docker:dind-rootless | bundled, rootless | s6 | rootless (UID 1000) |
The rootless flavour's UID is fixed at 1000 by the upstream base image, and its daemon always listens on /run/user/1000/docker.sock, so --user 1001 does not work. To talk to a host rootless daemon under another UID, use the basic flavour and bind-mount that daemon's socket instead.
Basic flavour
The default image ships no daemon of its own, so jobs that use docker:// images need one from outside the container — usually the host's socket:
docker run -d --name my_runner \
-e GITEA_INSTANCE_URL=<instance_url> \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<registration_token> \
-e GITEA_RUNNER_NAME=<runner_name> \
-v $PWD/data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
docker.io/gitea/runner:nightly
This flavour does not need --privileged. The trade-off is that jobs share the host's daemon and can therefore see its other containers and images. A job that can reach the socket can also read the reusable GITEA_RUNNER_REGISTRATION_TOKEN from the runner container's docker inspect output.
Docker-in-Docker
The dind flavour bundles its own daemon, so no socket has to be mounted:
docker run -d --name my_runner --privileged \
-e GITEA_INSTANCE_URL=<instance_url> \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<registration_token> \
-v $PWD/data:/data \
docker.io/gitea/runner:nightly-dind
s6 starts dockerd first and the runner service waits for it before registering. Use nightly-dind-rootless to run both the daemon and the runner as an unprivileged user; rootless Docker's usual limitations around networking, cgroups and storage drivers apply.
Volumes
Two different pieces of state are worth persisting, and neither implies the other:
/datais the runner's working directory. It holds the.runnerregistration file and, optionally, the config file. Without it, a recreated container registers itself again as a new runner, leaving a stale entry in Gitea, and fails outright if the token has been reset in the meantime.- the Docker daemon's data root holds the images pulled for jobs. It is not under
/data: fordindit is/var/lib/dockerinside the container, fordind-rootlessit is/home/rootless/.local/share/docker. Give it its own volume, or every new container re-pulls the job images.
Entrypoint environment variables
The entrypoint (scripts/run.sh) understands:
| Variable | Meaning |
|---|---|
GITEA_INSTANCE_URL | instance to register against, e.g. https://gitea.example.com/ |
GITEA_RUNNER_REGISTRATION_TOKEN | registration token; unset before the daemon starts |
GITEA_RUNNER_REGISTRATION_TOKEN_FILE | file to read the token from, for Docker/Kubernetes secrets |
GITEA_RUNNER_NAME | runner name, defaults to the container hostname |
GITEA_RUNNER_LABELS | labels, passed to both register and daemon |
GITEA_RUNNER_EPHEMERAL | any non-empty value registers the runner as ephemeral |
GITEA_RUNNER_ONCE | any non-empty value runs a single job, then exits |
GITEA_MAX_REG_ATTEMPTS | registration attempts before giving up, default 10 |
RUNNER_STATE_FILE | registration file name inside /data, default .runner |
CONFIG_FILE | config file inside the container, passed as --config |
These are entrypoint variables, not runner settings: the runner process itself is configured only through the config file.
Mount the config file when you need one:
docker run -v $PWD/config.yaml:/config.yaml -e CONFIG_FILE=/config.yaml ...
A config file can be generated with the image itself:
docker run --rm --entrypoint="" docker.io/gitea/runner:nightly gitea-runner generate-config > config.yaml
docker compose
services:
runner:
image: docker.io/gitea/runner:nightly
restart: always
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: "${INSTANCE_URL}"
GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
GITEA_RUNNER_NAME: "${RUNNER_NAME}"
GITEA_RUNNER_LABELS: "${RUNNER_LABELS}"
volumes:
- ./config.yaml:/config.yaml
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
When Gitea runs in the same compose project, depend on its health check so the runner does not try to register before the instance answers:
depends_on:
gitea:
condition: service_healthy
restart: true
The rootless Docker-in-Docker variant needs a few extra options:
services:
runner:
image: docker.io/gitea/runner:nightly-dind-rootless
restart: always
privileged: true
security_opt:
# for hosts running AppArmor (Ubuntu, Debian), whose default profile blocks
# the user namespace changes the bundled daemon needs
- apparmor=rootlesskit
volumes:
- ./data/runner:/data
environment:
- GITEA_INSTANCE_URL=<instance_url>
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration_token>
- DOCKER_HOST=unix:///var/run/user/1000/docker.sock
# slirp4netns gives significantly better network throughput than vpnkit
- DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns
- DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520
Cache from a dockerized runner
A runner in a container creates a separate network per job by default, so the address it detects for its own cache server is often unreachable from job containers and actions/cache fails with a connection timeout. Set cache.host and cache.port explicitly and publish that port, or put the job containers on a shared network — see Caching.
More deployment examples live in the examples directory of the runner repository.