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 |
501 |
action-runner-dats-runner-01 |
50120 |
dats-runner-02 |
502 |
action-runner-dats-runner-02 |
50220 |
dats-runner-99 |
599 |
action-runner-dats-runner-99 |
59920 |
The CI's prepare-env step extracts the two digits, builds the port prefix as 5<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
5xx xxrange (e.g. API on50120vs50220). - 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
See the configuration reference in action-runner/README.md for optional settings like RUNNER_GPU_DEVICE_ID.
2. Prepare the Ray cache directory
The runners share a common Ray model cache on the host. Create it with proper permissions:
mkdir -p ~/ray_cache
chmod 777 ~/ray_cache
This directory is mounted into all runner containers and shared with sibling Ray containers spawned by CI jobs.
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:
- Ray cache (
$HOME/ray_cache) — model downloads are cached machine-wide to avoid re-downloading large models for every runner. 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.
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.