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.