Files

Self-Hosted Runner

Description

The GitHub Actions self-hosted runner is an agent that runs GitHub Actions workflow jobs on your own infrastructure instead of GitHub-hosted machines.

Overview

This role provisions a dedicated machine as one or more GitHub Actions self-hosted CI runners for Infinito.Nexus. Each runner instance runs as an ephemeral Docker container (DooD — Docker out of Docker): the container mounts the host's /var/run/docker.sock and uses it to start a fresh Infinito DinD container for each CI job, then tears it down when the job finishes.

Registration tokens are fetched from the GitHub API at container start time via RUNNER_API_TOKEN (a GitHub PAT with repo scope). The token is stored in a per-instance .env file on disk (mode 0600, owned by github-runner).

The RUNNER_DISTRIBUTION variable selects distro-specific package installation tasks (Debian, Ubuntu, Arch Linux, or Fedora/EL). The role is designed to be driven by the make runner-ci-deploy target; see the end-to-end guide below.

Cosmos

The diagram places Self-Hosted Runner in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).

flowchart LR
    subgraph role [svc-runner 🐳🐝]
        svc_runner["runner"]
        svc_buildkit["buildkit ❌"]
        svc_act_runner["act-runner ❌"]
    end

Solid 1:1 edges are fixed relationships; dashed 0..1 edges are conditional (enabled only in matching deployments); red 0..0 edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in meta/main.yml.

Features

  • Self-hosted: Run CI jobs on your own server without consuming GitHub-hosted runner minutes.
  • Multi-instance: Provisions N runner instances per host via RUNNER_COUNT; each handles one CI job at a time.
  • Ephemeral containers: Each runner instance is a Docker container (restart: unless-stopped). After each job the container re-registers with GitHub as a fresh ephemeral runner (--ephemeral).
  • DooD isolation: Runner containers mount the host Docker socket. Each job gets its own Infinito DinD container with a unique subnet, container names, and Docker volume — parallel jobs never conflict.
  • Automatic registration: The runner container fetches a short-lived GitHub registration token from the API at startup using RUNNER_API_TOKEN.
  • Multi-distro: Supports Debian, Ubuntu (apt), Arch Linux (pacman), and Fedora/EL (dnf) via distro-specific task files.
  • Idempotent: Re-running the deploy rebuilds the image and restarts containers cleanly without manual cleanup.
  • CI workflow ready: All deploy-test workflows route to GitHub-hosted runners by default; set CI_SELF_HOSTED_RUNNER_COUNT to overflow jobs to your runners.

Quick Setup

Development

Clone, set up the workstation, and deploy Self-Hosted Runner onto the local stack:

git clone https://github.com/infinito-nexus/core.git
cd core
make onboard
make compose-deploy mode=reinstall apps=svc-runner full_cycle=false

Production

Run the published image to provision the inventory and deploy Self-Hosted Runner to a managed server (the mounted volume persists the inventory):

APP=svc-runner
HOST=<your-server>
DOMAIN=<your-domain>
TLS_MODE=self_signed
SSH_PUBLIC_KEY="<your-ssh-public-key>"

docker run --rm -it \
  -v "$PWD/inventories:/etc/infinito.nexus/inventories" \
  -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
  ghcr.io/infinito-nexus/core/debian bash -c '
    INVENTORY=/etc/infinito.nexus/inventories/production
    infinito administration inventory provision "$INVENTORY" \
      --inventory-file "$INVENTORY/devices.yml" \
      --host "$HOST" \
      --include "$APP" \
      --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" &&
    infinito administration deploy dedicated "$INVENTORY/devices.yml" \
      --password-file "$INVENTORY/.password" \
      --diff -vv'

End-to-end guide

Prerequisites

  • A GitHub Personal Access Token (PAT) with repo scope, exported as RUNNER_API_TOKEN (or GH_TOKEN) on the control machine.
  • SSH access to the target host (key-based authentication).
  • make install completed on the control machine (installs Ansible and Python dependencies).

Step 1 — Provision runner instances on the target server

RUNNER_API_TOKEN=ghp_... make runner-ci-deploy HOST=runner.example.com DISTRO=ubuntu

For a fork, supply your GitHub username so runners register against your repository:

RUNNER_API_TOKEN=ghp_... make runner-ci-deploy HOST=runner.example.com DISTRO=ubuntu OWNER=youruser

Follow the live deploy log in a separate terminal:

tail -f /tmp/infinito-runner-deploy.log

When complete, verify the runners appear in GitHub:

gh api repos/youruser/infinito-nexus/actions/runners -q '.runners[].name'

They should be listed as Idle in github.com/<owner>/infinito-nexus/settings/actions/runners.

Step 2 — Activate self-hosted CI routing

Set the CI_SELF_HOSTED_RUNNER_COUNT repository variable so the deploy-test matrix routes overflow jobs to your runners:

make runner-ci-enable COUNT=2
# or for a fork:
make runner-ci-enable COUNT=2 OWNER=youruser

This variable persists until explicitly changed. Confirm it is set at: github.com/<owner>/infinito-nexus/settings/variables/actions

Step 3 — Push and observe

Push any commit. The runners will pick up jobs routed to [self-hosted, linux] once activated.

Deactivating

To route all CI back to GitHub-hosted runners (e.g. if the server goes down):

make runner-ci-disable
# or for a fork:
make runner-ci-disable OWNER=youruser

Make targets

Target Parameters Description
make runner-ci-deploy HOST, DISTRO, COUNT (optional), PORT, OWNER, REPO Provision runner instances on the target host.
make runner-ci-enable COUNT, OWNER, REPO Set CI_SELF_HOSTED_RUNNER_COUNT repo variable to activate split routing.
make runner-ci-disable OWNER, REPO Zero the variable — all CI reverts to GitHub-hosted runners.

CLI parameters

The make runner-ci-deploy target invokes python -m cli.administration.deploy.runner. You can also call it directly:

python -m cli.administration.deploy.runner <hostname> \
    --distribution <os> \
    --roles svc-runner \
    [--runner-count N] \
    [--owner youruser] \
    [--repo infinito-nexus] \
    [--port 22] \
    [--output /tmp/infinito-runner-deploy.log]
Parameter Required Default Description
hostname yes — Target server hostname or IP address.
--distribution yes — OS: debian, ubuntu, archlinux, fedora, centos.
--roles yes — Roles to deploy; svc-runner is always prepended.
--runner-count no auto (vCPUs ÷ 2) Number of runner instances to provision.
--owner no role default (infinito-nexus) GitHub user or org the runners register with.
--repo no role default (core) Repository name the runners register with.
--port no Ansible default (22) SSH port of the target host.
--output no /tmp/infinito-runner-deploy.log File path for deploy stdout/stderr.

Role variables

Variable Default Description
RUNNER_GITHUB_OWNER meta/services.yml GitHub user or organisation that owns the target repository.
RUNNER_GITHUB_REPO meta/services.yml Repository name the runners register with.
RUNNER_API_TOKEN GH_TOKEN env var GitHub PAT with repo scope for runner registration. Stored in per-instance .env (mode 0600).
RUNNER_NAME {{ inventory_hostname }} Base name; each instance is named <RUNNER_NAME>-<N>.
RUNNER_LABELS self-hosted,linux,{{ RUNNER_DISTRIBUTION }} Comma-separated labels assigned to every runner instance.
RUNNER_INSTALL_DIR /opt/github-runner Base installation directory; instances land in <dir>/<N>/.
RUNNER_USER github-runner System user account that owns all runner files and processes.
RUNNER_COUNT auto (ansible_processor_vcpus // RUNNER_CPUS) Number of runner instances; auto-scales to available CPU cores.
RUNNER_CPUS 2 CPU limit per runner instance (matches GitHub-hosted 2-core quota).
RUNNER_DOCKER_BASE /mnt/docker Base path for per-instance Docker volume directories.
RUNNER_PROJECT_PREFIX runner Prefix for per-instance Docker Compose project names and INFINITO_RUNNER_PREFIX.
RUNNER_SYSCTL_CONF /etc/sysctl.d/99-github-runner.conf Path to the sysctl config file written for inotify tuning.

RUNNER_DISTRIBUTION is required and has no default; it is passed automatically by the CLI.

Testing

The role self-validates via the scripts in files/test/, discovered and run by test-e2e-cli during deploy. test.sh orchestrates two complementary checks:

  • local.sh — fully local, no external GitHub. Checks the runner containers are healthy and the DooD socket works, then container cps the repo into a runner, builds the Infinito image locally, and deploys web-app-dashboard (all other services disabled) inside a sealed, throwaway Docker daemon that shares the runner's network namespace. The sandbox is torn down on exit, so the host stack is never touched and no GitHub/GHCR access is required. On the async test pass the heavy deploy is skipped (it is validated once on the sync pass).
  • external.sh — real job-pickup smoke test (optional). Uses RUNNER_API_TOKEN to confirm the runners registered and are online via the GitHub API, dispatches a smoke workflow, and polls it to completion. Skipped automatically when no token is present.

In short: local.sh proves the runner can build and deploy entirely on its own hardware; external.sh proves it can receive real jobs from GitHub.

Architecture

Every runner instance is an ephemeral container that mounts its host's docker socket (DooD). For each CI job it starts a fresh Infinito DinD container with a unique subnet and volume, runs the job inside it, and tears it down; the runner then re-registers with GitHub as a fresh ephemeral runner.

flowchart LR
    gh[GitHub Actions] -->|job| r1[runner-1]
    gh -->|job| rn[runner-N]
    subgraph host [host node]
        r1 -->|/var/run/docker.sock| dockerd[(host dockerd)]
        rn -->|/var/run/docker.sock| dockerd
        dockerd --> dind1[DinD container<br/>job 1]
        dockerd --> dindn[DinD container<br/>job N]
    end

Compose mode

One host runs the whole fleet: the role builds the infinito-runner image locally, renders one compose project and starts RUNNER_COUNT instances.

flowchart TB
    subgraph host [single host]
        build[build infinito-runner image]
        fleet[compose up:<br/>runner-1 .. runner-N]
        build --> fleet
    end

Swarm mode

The fleet is deployed on every cluster node, but not as a docker stack service (workload: node-local in meta/services.yml): each runner needs the docker socket and the registered identity of exactly its node. The image is built once on the manager and distributed through the registry dependency (svc-registry-docker), the only part that runs as a swarm service.

flowchart TB
    subgraph cluster [swarm cluster]
        subgraph mgr [manager]
            build[build infinito-runner image]
            reg[(registry<br/>swarm service)]
            mfleet[node-local compose:<br/>runner-1 .. runner-N]
        end
        subgraph wrk [each worker]
            wfleet[node-local compose:<br/>runner-1 .. runner-N]
        end
    end
    build -->|push| reg
    reg -->|pull| mfleet
    reg -->|pull| wfleet

Because no stack service exists, the swarm test gates that poll docker service ls (converge, seed, drain, assert) skip themselves via the workload: node-local declaration (scripts/tests/deploy/swarm/utils/_context.sh maps node-local to HAS_SWARM_SERVICE=false); the deploy itself, the registry distribution chain and the CLI e2e suite run in full.

Further Resources

Credits

Implemented by Alejandro Roman Ibanez. Part of the Infinito.Nexus Project and maintained by Kevin Veen-Birkenbach. Licensed under the Infinito.Nexus Community License (Non-Commercial).