Configure GitHub Action Runners
DATS uses self-hosted GitHub Action runners to run its CI checks (backend tests, frontend checks, Ray builds, and more). Runners are deployed as a dockerized fleet from the action-runner/ directory: ephemeral Docker containers that auto-register with GitHub, execute jobs, and cleanly deregister themselves on shutdown. This guide explains how to run the fleet on a machine so multiple runners can execute jobs concurrently without conflicting with each other.
Info
The action-runner/README.md is the authoritative reference for the fleet's configuration options and commands. This page focuses on the concepts and the CI isolation contract.
Why isolation matters
Each CI job spins up a full DATS stack using Docker Compose: PostgreSQL, Redis, Elasticsearch, Weaviate, the backend API, the frontend, and optionally Ray. By default, every run would try to use the same ports, the same Docker Compose project name (and thus the same containers, networks, and volumes), and the same GPU.
If two runners shared these, one run's docker compose up would hijack the other run's containers, and one run's cleanup (docker compose down -v) would tear down the other run's stack mid-test. To prevent this, every runner gets its own isolated identity.
How isolation works
Isolation is derived entirely from the runner's name. The fleet generates names automatically from the container hostname, using the convention:
dats-runner-01
dats-runner-02
dats-runner-03
...
The last two digits of the runner name drive everything:
| Runner name | Port prefix | Compose project | Example API port |
|---|---|---|---|
dats-runner-01 |
301 |
action-runner-dats-runner-01 |
30120 |
dats-runner-02 |
302 |
action-runner-dats-runner-02 |
30220 |
dats-runner-99 |
399 |
action-runner-dats-runner-99 |
39920 |
The CI's prepare-env step extracts the two digits, builds the port prefix as 3<digits>, and passes it to bin/setup/setup-envs.sh, which rewrites every port in the generated .env files. It also suffixes the Compose project name with the runner name, which isolates:
- Ports — each runner binds a distinct
3xx xxrange (e.g. API on30120vs30220). - Containers & networks — named
<project>-<service>and<project>_dats_network, so no cross-runner adoption. - Named volumes —
<project>_postgres_dataetc., so one runner'sdown -vnever deletes another's data.
Docker image tags are already unique per run (suffixed with the GitHub run_id), so concurrent builds never overwrite each other's images.
Warning
Runner names must end in exactly two digits. Each runner is an explicit service in compose.yml with a hardcoded hostname (dats-runner-01, dats-runner-02, …). To add more runners, copy a service block and increment the suffix. The prepare-env step fails with a clear error if it can't extract exactly two digits from the runner name.
Setting up the runner fleet
Prerequisites
- Docker and Docker Compose installed on the host machine.
- A GitHub Personal Access Token (PAT) with
reposcope (for repository-level runners) oradmin:orgscope (for organization-level runners). The PAT is used to request fresh registration tokens on the fly, avoiding the 60-minute expiration limit of standard UI tokens.
1. Configure the fleet
In the action-runner/ directory, copy the environment template and fill in your credentials:
cp .env.example .env
GITHUB_OWNER=uhh-lt
GITHUB_REPO=dats
GITHUB_PAT=ghp_YourSuperSecretTokenHere
Also set the two host directories, RUNNER_WORK_BASE_DIR and RAY_CACHE_HOST_DIR. See the configuration reference in action-runner/README.md for optional settings like RUNNER_GPU_DEVICE_ID.
2. Prepare the host directories
Create the host directories and give them to the container's runner user (uid 1000):
./setup-folders.sh
- Runner work directories (
$RUNNER_WORK_BASE_DIR/runner-XX) — each runner's_work, holding its checkouts and its uv cache. - Ray model cache (
$RAY_CACHE_HOST_DIR) — shared by all runners and the Ray containers that CI jobs start, see Shared resources.
Use sudo if the directories are outside your home. --recreate deletes and recreates them.
3. Start the fleet
docker compose up -d --build
This builds the runner image (which bundles Docker CLI + Compose plugin, just, uv, Node.js, pwgen, and git — everything the workflows need) and starts one container per service defined in compose.yml. Each container registers itself with GitHub under its hostname.
Useful commands:
| Command | Description |
|---|---|
docker compose up -d |
Start all runners |
docker compose logs -f |
Stream logs from all runners |
docker compose down |
Stop all runners and auto-deregister from GitHub |
docker compose ps |
Show runner status and health |
To add more runners, copy one of the service blocks in compose.yml, increment the hostname suffix (e.g. dats-runner-05), and run docker compose up -d.
4. Verify in GitHub
Navigate to Settings → Actions → Runners in the repository. You should see one runner per service defined in compose.yml (dats-runner-01, dats-runner-02, …) with status Idle or Active.
Architecture notes
- Docker-out-of-Docker — the host's
/var/run/docker.sockis mounted into each runner container, so workflows can build images and spin up sibling containers (the DATS stack's databases, etc.) directly on the host. - Host networking —
network_mode: "host"lets CI scripts and backend tests reach sibling containers vialocalhost:PORTwithout network bridges. - Self-healing — a cleanup trap in the entrypoint unregisters the runner from GitHub when its container stops, so no offline "ghost" runners linger in the dashboard.
- Non-root execution — jobs run as an unprivileged
runneruser inside the container; the entrypoint adds that user to the host's Docker group at runtime (detected from the socket's GID).
Shared resources (intentionally not isolated)
A few resources are deliberately shared across all runners on the machine:
- Disks — all runners and their sibling containers write to the host's disks, see Keeping the disk load low.
- Ray cache (
$RAY_CACHE_HOST_DIR) — when a job starts Ray locally (ray-checks, orbackend-checksfor changes toray/),prepare-envmounts this cache into the Ray container at/ray_cache. It holds the spaCy, Hugging Face, torch and EasyOCR models and compiled kernels, so each model is downloaded once per machine. Concurrent access is tolerable for a cache. - GPU — Ray jobs pin
RAY_DEVICE_IDS=1. This means only one Ray-enabled job should run at a time per machine; running two concurrently will contend for the same GPU. If you need parallel Ray jobs, assign different GPUs per runner. - Hosted services — LLM, embedding, and Docling endpoints point at shared hosted infrastructure (
ltdwise.informatik.uni-hamburg.de), which is stateless and safe to share.
Keeping the disk load low
Every job starts from a clean checkout (actions/checkout removes all untracked files, including .venv and node_modules), installs its dependencies, and starts its own DATS stack. These measures keep its disk writes low:
- uv cache in the work directory — each runner keeps its uv cache in its own work directory (
UV_CACHE_DIRinaction-runner/compose.yml), on the same mount as its checkouts, souv synchardlinks the packages into each.venv. - Data services in memory —
prepare-envaddsdocker/compose.ci.ymltoCOMPOSE_FILEin the generateddocker/.env. It replaces the volumes of PostgreSQL, Redis, Weaviate and Elasticsearch with tmpfs mounts, since a run throws its data away in the cleanup anyway. - Only the changed components —
precommit-checksinstalls and checks only the components (backend, frontend, ray) that a pull request or push changed. A change to.pre-commit-config.yaml, thejustfileorbin/checks all of them. - Pinned uv image — the Dockerfiles pin
ghcr.io/astral-sh/uv:<version>, so the cached image layers survive uv releases.
Applying changes to the fleet
The fleet runs from a checkout of this repository. After a change to action-runner/, update that checkout and recreate the runners while they are idle (recreating a runner aborts its running job):
git pull
docker compose up -d --build
Troubleshooting
Job fails with "must end in two digits"
The runner name doesn't match the convention. Check that each service in compose.yml has a hostname ending in exactly two digits (e.g. dats-runner-05).
"Port is already allocated"
Two runners are using the same port prefix. Ensure each runner has a unique two-digit suffix (check docker compose ps and the runner names in GitHub).
One run's tests fail when another run starts
This should not happen with per-runner Compose projects. Confirm the runners have distinct names and that COMPOSE_PROJECT_NAME differs between their generated docker/.env files.
Runners appear offline after shutdown
The cleanup trap should prevent this. If a container is killed forcefully (docker kill), the runner may remain registered — delete it manually from Settings → Actions → Runners.
Jobs cannot reach services on localhost
Ensure network_mode: "host" is still set in compose.yml. It is required for runners to access sibling containers via localhost.