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.

+---------------------------------------------------------+
| docker:dind supervisor                                  |
|                                                         |
|  +-----------------------------------------------+      |
|  | ubuntu-runner image (built at startup)        |      |
|  +-----------------------------------------------+      |
|   \--> runner containers                         |      |
|        - mount /var/run/docker.sock              |      |
|        - mount /home/runner/configs/<name>       |      |
|        - run with env vars for token & repo URL  |      |
+---------------------------------------------------------+

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

FROM docker:24.0.7-dind
RUN apk add --no-cache bash curl git jq nodejs npm tar openssl shadow sudo
RUN useradd -m -s /bin/bash runner && echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
WORKDIR /home/runner
COPY supervisor.sh start-runner.sh Dockerfile.ubuntu-runner /home/runner/
COPY add-runner.sh /usr/local/bin/add-runner
RUN chmod +x /home/runner/*.sh /usr/local/bin/add-runner
ENTRYPOINT ["/home/runner/supervisor.sh"]

Supervisor Script

#!/bin/bash
set -euxo pipefail

echo "[supervisor] 🔥 Starting Docker daemon..."
dockerd & DOCKERD_PID=$!

echo "[supervisor] ⏳ Waiting for Docker daemon..."
until docker info &>/dev/null; do sleep 1; done
echo "[supervisor] ✅ Docker is ready."

# 1) Fetch & extract runner bits
: "${GH_RUNNER_VERSION:=2.325.0}"
ASSET="actions-runner-linux-x64-${GH_RUNNER_VERSION}.tar.gz"
DOWNLOAD_URL="https://github.com/actions/runner/releases/download/v${GH_RUNNER_VERSION}/${ASSET}"

echo "[fetcher] 🌐 Downloading runner v${GH_RUNNER_VERSION}…"
curl -sSfL -o "${ASSET}" "${DOWNLOAD_URL}"

echo "[fetcher] 🗜 Extracting to base-runner/"
rm -rf base-runner && mkdir base-runner
tar xzf "${ASSET}" -C base-runner
rm "${ASSET}"
chown -R runner:runner base-runner
echo "[fetcher] ✅ base-runner ready."

# 2) Build the Ubuntu runner image
echo "[builder] 🏗 Building ubuntu-runner image…"
docker build -t ubuntu-runner -f Dockerfile.ubuntu-runner .

# 3) Re-activate all existing configs
echo "[supervisor] 🌱 Re-activating all runners under configs/…"
for dir in /home/runner/configs/*; do
  [[ -d "$dir" ]] || continue
  raw="$(basename "$dir")"
  name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')"

  # Skip if container already exists
  if docker ps -a --format '{{.Names}}' | grep -qx "$name"; then
    echo "[supervisor] ↪ Runner '$name' already exists; skipping"
    continue
  fi

  # Build ENV args only if metadata is present
  env_args=()
  if [[ -r "$dir/.url" && -r "$dir/.token" ]]; then
    env_args+=( -e "RUNNER_REPO_URL=$(<"$dir/.url")" )
    env_args+=( -e "RUNNER_TOKEN=$(<"$dir/.token")" )
  else
    echo "[supervisor] ⚠ No .url/.token for '$raw'; relying on existing credentials"
  fi

  echo "[supervisor] ⇢ Launching runner container '$name'…"
  docker run -d \
    --name "$name" \
    "${env_args[@]}" \
    -e RUNNER_NAME="$name" \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v "$dir":/opt/runner-files \
    ubuntu-runner
done

# 4) Keep supervisor alive until dockerd exits
wait "$DOCKERD_PID"

Ubuntu Runner Dockerfile

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
    curl git jq sudo nodejs npm \
    libicu70 libssl-dev libcurl4-openssl-dev \
  && rm -rf /var/lib/apt/lists/*

COPY base-runner /opt/runner-files
WORKDIR /opt/runner-files
RUN sed -i -E \
      -e 's/libssl1\.0\.(2|0|1)/libssl-dev/g' \
      -e 's/libicu7(2|1)/libicu70/g' \
    bin/installdependencies.sh \
  && ./bin/installdependencies.sh

COPY start-runner.sh /usr/local/bin/start-runner
RUN chmod +x /usr/local/bin/start-runner
RUN useradd -m -s /bin/bash runner
USER runner
ENTRYPOINT ["start-runner"]

Runner Wrapper

#!/bin/bash
set -euxo pipefail
: "${RUNNER_REPO_URL:?RUNNER_REPO_URL is required}"
: "${RUNNER_TOKEN:?RUNNER_TOKEN is required}"
: "${RUNNER_NAME:=runner-$(hostname)}"
cd /opt/runner-files
./config.sh \
  --url "$RUNNER_REPO_URL" \
  --token "$RUNNER_TOKEN" \
  --name "$RUNNER_NAME" \
  --work _work \
  --unattended \
  --replace
exec ./run.sh

Dynamic Add-Runner Helper

#!/bin/bash
set -euxo pipefail

usage(){
  echo "Usage: add-runner --url <repo_url> --token <reg_token> [--name <alias>]"
  exit 1
}

URL="" TOKEN="" NAME=""
while [[ $# -gt 0 ]]; do
  case $1 in
    --url)   URL="$2"; shift 2;;
    --token) TOKEN="$2"; shift 2;;
    --name)  NAME="$2"; shift 2;;
    *) usage;;
  esac
done
[[ -n "$URL" && -n "$TOKEN" ]] || usage

# Derive a normalized default name if none given:
if [[ -z "$NAME" ]]; then
  slug="${URL#https://github.com/}"
  slug="${slug//\//_}"
  NAME="$(echo "$slug" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')"
fi

CONFIG_ROOT=/home/runner/configs
BASE_RUNNER=/home/runner/base-runner
RUN_DIR="$CONFIG_ROOT/$NAME"

if [[ ! -d "$RUN_DIR" ]]; then
  echo "[add-runner] Initializing directory for '$NAME'…"
  mkdir -p "$RUN_DIR"

  # Copy the runner bits
  cp -R "$BASE_RUNNER/." "$RUN_DIR"

  # Persist metadata
  printf "%s\n" "$URL"   > "$RUN_DIR/.url"
  printf "%s\n" "$TOKEN" > "$RUN_DIR/.token"

  # Fix permissions so runner user (UID 1000) can write
  chown -R 1000:1000 "$RUN_DIR"
fi

# Skip if the container already exists
if docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
  echo "▶ Runner '$NAME' already exists. To redeploy, run: docker rm -f $NAME && add-runner …"
  exit 0
fi

echo "[add-runner] Spawning runner container '$NAME'…"
docker run -d \
  --name "$NAME" \
  -e RUNNER_NAME="$NAME" \
  -e RUNNER_REPO_URL="$URL" \
  -e RUNNER_TOKEN="$TOKEN" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$RUN_DIR":/opt/runner-files \
  ubuntu-runner

echo "[add-runner] ✅ Launched runner '$NAME'. Logs: docker logs -f $NAME"

Docker Compose

version: "3.8"
services:
  gh-multi-runner:
    build: .
    privileged: true
    security_opt:
      - seccomp:unconfined
    volumes:
      - ./configs:/home/runner/configs
      - type: tmpfs
        target: /var/lib/docker
      - type: tmpfs
        target: /run
    environment:
      - DOCKER_TLS_CERTDIR=
      - GH_RUNNER_VERSION=2.325.0
    restart: unless-stopped

Example Usage

Build the supervisor container and spin it up, then add a runner using a GitHub registration token:

docker compose up -d --build
docker compose exec gh-multi-runner add-runner \
  --url https://github.com/arran4/goa4web \
  --token AAA3IMZJPA7QKYUIFN5SHKDINDWQ4
docker 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:

docker 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.