Runs multiple GitHub Actions self-hosted runners from a single container, configured via a JSON file. No custom Docker image — just an entrypoint script mounted into the official ghcr.io/actions/actions-runner image.
The config file is watched at runtime: new runners are launched automatically, changed runners are restarted, unchanged runners are left alone, and removed runners are gracefully drained. Each runner writes its own log file, and the container's stdout carries only the manager's summary events.
- Docker and Docker Compose on the host
- A GitHub personal access token (PAT) with runner registration permissions (see below)
Classic PAT:
repo— required for repository-scoped runnersmanage_runners:org— required for organization-scoped runnersmanage_runners:enterprise— required for enterprise-scoped runners
Fine-grained PAT:
- Repository runners: "Administration" permission (read/write) on each target repo
- Organization runners: "Self-hosted runners" permission (read/write) on the target org
A single PAT can cover multiple scopes. Store it as GITHUB_TOKEN in your environment.
Copy runners.example.json to runners.json and define your runners:
{
"runners": [
{ "name": "my-org", "scope": "org", "target": "my-github-org" },
{ "name": "my-repo", "scope": "repo", "target": "myuser/my-repo" }
]
}| Field | Required | Description |
|---|---|---|
name |
Yes | Unique name for this runner, shown in GitHub Actions UI |
scope |
Yes | org, repo, or enterprise |
target |
Yes | Org name, owner/repo, or enterprise slug depending on scope |
labels |
No | Array of labels. Defaults to the runner's built-in defaults |
Copy docker-compose.example.yml to docker-compose.yml and adjust as needed:
services:
github-runner:
image: ghcr.io/actions/actions-runner:2.332.0
user: root
entrypoint: ["/bin/bash", "/config/entrypoint.sh"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./entrypoint.sh:/config/entrypoint.sh:ro
- ./runners.json:/config/runners.json:ro
- ./state:/runners
- ./logs:/runners/_logs
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
stop_grace_period: 5m
restart: unless-stoppedThen run:
GITHUB_TOKEN=your_pat docker compose up -d| Variable | Required | Default | Description |
|---|---|---|---|
GITHUB_TOKEN |
Yes | — | PAT used to generate runner registration tokens |
RUNNERS_CONFIG |
No | /config/runners.json |
Path to the runners config file inside the container |
CONFIG_POLL_INTERVAL |
No | 30 |
Seconds between config file checks for live updates |
LOG_DIR |
No | /runners/_logs |
Directory for per-runner log files |
LOG_MAX_BYTES |
No | 52428800 |
Size threshold (bytes) at which a runner log is rotated |
LOG_KEEP |
No | 3 |
Number of rotated log files to keep per runner |
GITHUB_API_RETRIES |
No | 5 |
Max attempts (with backoff) for token fetches against the GitHub API |
Stdout carries only manager-level events (one line per start/stop/reconcile). Each runner's full output — config.sh, run.sh, and deregistration — goes to ${LOG_DIR}/<name>.log, which rotates at LOG_MAX_BYTES (keeping LOG_KEEP older files as <name>.log.1, .2, …).
With the example docker-compose.yml binding ./logs:/runners/_logs, you can tail logs from the host:
tail -F logs/my-org.logThe entrypoint polls runners.json every CONFIG_POLL_INTERVAL seconds. Changes take effect automatically:
- New runner added → registered and started
- Runner config changed (any field) → gracefully stopped, re-registered, restarted
- Runner removed → gracefully stopped
- Unrelated runners are left untouched — editing one entry never bounces the others
- Crashed runner (subshell died outside of a drain) is detected on the next reconcile and respawned
Editor atomic-saves are tolerated: if runners.json is briefly empty or malformed during a write, the poll skips that cycle and retries.
With /runners bind-mounted (as in the example compose), each runner's registration is kept across container restarts. On boot, the entrypoint compares the on-disk config hash to the desired one and only re-registers when something actually changed. Without the bind mount, runners re-register on every start (the original behavior).
When a runner is stopped (due to a config change, removal, or container shutdown), the entrypoint:
- Touches a drain file so the restart loop won't re-launch the runner
- Fetches a removal token from the GitHub API and calls
config.sh removeto deregister the runner — GitHub stops sending it new jobs - Waits for
run.shto exit naturally after the current job completes
If the removal token cannot be fetched (after GITHUB_API_RETRIES attempts with backoff), it falls back to SIGTERM. On container shutdown (docker stop), all runners drain concurrently before the process exits — set stop_grace_period in the compose file high enough to accommodate your longest job.
The container detects the GID of /var/run/docker.sock at startup and grants the runner user access automatically. No manual group configuration is needed on the host.
Change the image tag in docker-compose.yml:
image: ghcr.io/actions/actions-runner:2.332.0Check the actions/runner releases page for the latest version.
Edit runners.json. Running containers will pick up the change within CONFIG_POLL_INTERVAL seconds. To apply immediately, restart the container.
- Each runner handles one job at a time. For concurrency on a given repo or org, add multiple entries to
runners.json. - Workflows in public repositories that target a self-hosted runner are a security risk — anyone can open a pull request and run code on your host. Use environment protection rules to require approval before jobs run.