Running Multiple Self-Hosted GitHub Actions Runners in a Single Docker-in-Docker Container
Introduction
Running dozens of self-hosted GitHub Actions runners can quickly become a management headache. Maintaining separate Docker images for each runner and rebuilding them when GitHub updates the runner software burns time and storage. By leveraging one privileged Docker-in-Docker (DinD) container as a supervisor we can host many lightweight runner containers inside it. This approach isolates configuration per runner, keeps updates simple and avoids rebuilding the entire image each time a new runner version drops.
Prerequisites
- Docker and Docker Compose installed on the host
- A GitHub repository where self-hosted runners are allowed
- Registration tokens for each runner you intend to create
Architecture Overview
Within a single docker:dind supervisor container we build the runner image at runtime. Individual runner containers mount the DinD socket to use Docker and share a host-mounted config directory so their configuration persists across restarts. A helper script add-runner.sh registers new runners dynamically.
1+---------------------------------------------------------+
2| docker:dind supervisor |
3| |
4| +-----------------------------------------------+ |
5| | ubuntu-runner image (built at startup) | |
6| +-----------------------------------------------+ |
7| \--> runner containers | |
8| - mount /var/run/docker.sock | |
9| - mount /home/runner/configs/<name> | |
10| - run with env vars for token & repo URL | |
11+---------------------------------------------------------+
Handling directory names & rebuilds
Early versions assumed the config directory name was safe to use directly as the container name. When a folder contained hyphens or uppercase letters the supervisor failed with an invalid reference format error while trying to restart the runner after an image rebuild. Each runner now stores its GitHub URL and registration token in .url and .token files so the supervisor can re-create containers automatically. Directory names are normalised to lowercase and any disallowed characters become underscores.
Step-by-Step Instructions
DinD Supervisor Dockerfile
1FROM docker:24.0.7-dind
2RUN apk add --no-cache bash curl git jq nodejs npm tar openssl shadow sudo
3RUN useradd -m -s /bin/bash runner && echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
4WORKDIR /home/runner
5COPY supervisor.sh start-runner.sh Dockerfile.ubuntu-runner /home/runner/
6COPY add-runner.sh /usr/local/bin/add-runner
7RUN chmod +x /home/runner/*.sh /usr/local/bin/add-runner
8ENTRYPOINT ["/home/runner/supervisor.sh"]
Supervisor Script
1#!/bin/bash
2set -euxo pipefail
3
4echo "[supervisor] 🔥 Starting Docker daemon..."
5dockerd & DOCKERD_PID=$!
6
7echo "[supervisor] ⏳ Waiting for Docker daemon..."
8until docker info &>/dev/null; do sleep 1; done
9echo "[supervisor] ✅ Docker is ready."
10
11# 1) Fetch & extract runner bits
12: "${GH_RUNNER_VERSION:=2.325.0}"
13ASSET="actions-runner-linux-x64-${GH_RUNNER_VERSION}.tar.gz"
14DOWNLOAD_URL="https://github.com/actions/runner/releases/download/v${GH_RUNNER_VERSION}/${ASSET}"
15
16echo "[fetcher] 🌐 Downloading runner v${GH_RUNNER_VERSION}…"
17curl -sSfL -o "${ASSET}" "${DOWNLOAD_URL}"
18
19echo "[fetcher] 🗜 Extracting to base-runner/"
20rm -rf base-runner && mkdir base-runner
21tar xzf "${ASSET}" -C base-runner
22rm "${ASSET}"
23chown -R runner:runner base-runner
24echo "[fetcher] ✅ base-runner ready."
25
26# 2) Build the Ubuntu runner image
27echo "[builder] 🏗 Building ubuntu-runner image…"
28docker build -t ubuntu-runner -f Dockerfile.ubuntu-runner .
29
30# 3) Re-activate all existing configs
31echo "[supervisor] 🌱 Re-activating all runners under configs/…"
32for dir in /home/runner/configs/*; do
33 [[ -d "$dir" ]] || continue
34 raw="$(basename "$dir")"
35 name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')"
36
37 # Skip if container already exists
38 if docker ps -a --format '{{.Names}}' | grep -qx "$name"; then
39 echo "[supervisor] ↪ Runner '$name' already exists; skipping"
40 continue
41 fi
42
43 # Build ENV args only if metadata is present
44 env_args=()
45 if [[ -r "$dir/.url" && -r "$dir/.token" ]]; then
46 env_args+=( -e "RUNNER_REPO_URL=$(<"$dir/.url")" )
47 env_args+=( -e "RUNNER_TOKEN=$(<"$dir/.token")" )
48 else
49 echo "[supervisor] ⚠ No .url/.token for '$raw'; relying on existing credentials"
50 fi
51
52 echo "[supervisor] ⇢ Launching runner container '$name'…"
53 docker run -d \
54 --name "$name" \
55 "${env_args[@]}" \
56 -e RUNNER_NAME="$name" \
57 -v /var/run/docker.sock:/var/run/docker.sock \
58 -v "$dir":/opt/runner-files \
59 ubuntu-runner
60done
61
62# 4) Keep supervisor alive until dockerd exits
63wait "$DOCKERD_PID"
Ubuntu Runner Dockerfile
1FROM ubuntu:22.04
2RUN apt-get update && apt-get install -y \
3 curl git jq sudo nodejs npm \
4 libicu70 libssl-dev libcurl4-openssl-dev \
5 && rm -rf /var/lib/apt/lists/*
6
7COPY base-runner /opt/runner-files
8WORKDIR /opt/runner-files
9RUN sed -i -E \
10 -e 's/libssl1\.0\.(2|0|1)/libssl-dev/g' \
11 -e 's/libicu7(2|1)/libicu70/g' \
12 bin/installdependencies.sh \
13 && ./bin/installdependencies.sh
14
15COPY start-runner.sh /usr/local/bin/start-runner
16RUN chmod +x /usr/local/bin/start-runner
17RUN useradd -m -s /bin/bash runner
18USER runner
19ENTRYPOINT ["start-runner"]
Runner Wrapper
1#!/bin/bash
2set -euxo pipefail
3: "${RUNNER_REPO_URL:?RUNNER_REPO_URL is required}"
4: "${RUNNER_TOKEN:?RUNNER_TOKEN is required}"
5: "${RUNNER_NAME:=runner-$(hostname)}"
6cd /opt/runner-files
7./config.sh \
8 --url "$RUNNER_REPO_URL" \
9 --token "$RUNNER_TOKEN" \
10 --name "$RUNNER_NAME" \
11 --work _work \
12 --unattended \
13 --replace
14exec ./run.sh
Dynamic Add-Runner Helper
1#!/bin/bash
2set -euxo pipefail
3
4usage(){
5 echo "Usage: add-runner --url <repo_url> --token <reg_token> [--name <alias>]"
6 exit 1
7}
8
9URL="" TOKEN="" NAME=""
10while [[ $# -gt 0 ]]; do
11 case $1 in
12 --url) URL="$2"; shift 2;;
13 --token) TOKEN="$2"; shift 2;;
14 --name) NAME="$2"; shift 2;;
15 *) usage;;
16 esac
17done
18[[ -n "$URL" && -n "$TOKEN" ]] || usage
19
20# Derive a normalized default name if none given:
21if [[ -z "$NAME" ]]; then
22 slug="${URL#https://github.com/}"
23 slug="${slug//\//_}"
24 NAME="$(echo "$slug" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')"
25fi
26
27CONFIG_ROOT=/home/runner/configs
28BASE_RUNNER=/home/runner/base-runner
29RUN_DIR="$CONFIG_ROOT/$NAME"
30
31if [[ ! -d "$RUN_DIR" ]]; then
32 echo "[add-runner] Initializing directory for '$NAME'…"
33 mkdir -p "$RUN_DIR"
34
35 # Copy the runner bits
36 cp -R "$BASE_RUNNER/." "$RUN_DIR"
37
38 # Persist metadata
39 printf "%s\n" "$URL" > "$RUN_DIR/.url"
40 printf "%s\n" "$TOKEN" > "$RUN_DIR/.token"
41
42 # Fix permissions so runner user (UID 1000) can write
43 chown -R 1000:1000 "$RUN_DIR"
44fi
45
46# Skip if the container already exists
47if docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
48 echo "▶ Runner '$NAME' already exists. To redeploy, run: docker rm -f $NAME && add-runner …"
49 exit 0
50fi
51
52echo "[add-runner] Spawning runner container '$NAME'…"
53docker run -d \
54 --name "$NAME" \
55 -e RUNNER_NAME="$NAME" \
56 -e RUNNER_REPO_URL="$URL" \
57 -e RUNNER_TOKEN="$TOKEN" \
58 -v /var/run/docker.sock:/var/run/docker.sock \
59 -v "$RUN_DIR":/opt/runner-files \
60 ubuntu-runner
61
62echo "[add-runner] ✅ Launched runner '$NAME'. Logs: docker logs -f $NAME"
Docker Compose
1version: "3.8"
2services:
3 gh-multi-runner:
4 build: .
5 privileged: true
6 security_opt:
7 - seccomp:unconfined
8 volumes:
9 - ./configs:/home/runner/configs
10 - type: tmpfs
11 target: /var/lib/docker
12 - type: tmpfs
13 target: /run
14 environment:
15 - DOCKER_TLS_CERTDIR=
16 - GH_RUNNER_VERSION=2.325.0
17 restart: unless-stopped
Example Usage
Build the supervisor container and spin it up, then add a runner using a GitHub registration token:
1docker compose up -d --build
2docker compose exec gh-multi-runner add-runner \
3 --url https://github.com/arran4/goa4web \
4 --token AAA3IMZJPA7QKYUIFN5SHKDINDWQ4
5docker logs -f arran4_goa4web
Rebuilding the supervisor
When you rebuild the gh-multi-runner service the supervisor will automatically re-launch every runner found under configs/. Directory names are normalised and if .url and .token exist they are passed to docker run so the container can reconfigure itself. Simply run:
1docker compose up -d --build
All previously-added runners come back online without manual intervention. This setup lets you spawn as many runners as you need within a single DinD environment while keeping updates and configuration management streamlined.