Skip to content
C Codeloom
CI/CD

CI/CD Self-Hosted Runners Tutorial

A practical guide to running your own CI/CD runners. Learn when self-hosting beats cloud runners, how to set them up safely, and how to keep them healthy in production.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • When self-hosted runners make sense
  • How a runner connects to the control plane
  • How to register a runner end-to-end
  • Common security and scaling pitfalls
  • Patterns for keeping runners ephemeral

Prerequisites

  • Basic familiarity with GitHub Actions or a similar CI system

What and Why

A self-hosted runner is a machine you control that executes CI jobs queued by a hosted control plane like GitHub Actions, GitLab, or Buildkite. Instead of paying per minute for cloud runners, you provide the compute. The control plane still schedules, logs, and authenticates jobs, but the actual docker build or pytest runs on your hardware.

The reasons to self-host are concrete. You need GPUs the cloud runners do not offer. You need to reach private networks behind a VPN. You want to reuse a fat build cache that takes ten minutes to warm. Or your monthly minutes bill has crossed the cost of a small VM. If none of those apply, stay on hosted runners.

Mental Model

Think of a runner as a long-poll worker. It registers once, then opens a persistent connection to the control plane and waits to be handed a job. When a job arrives, it downloads the workflow, runs each step in a shell, and streams logs back. There is no inbound port to open. The runner makes the outbound connection.

This is important because it shapes your network design. A runner inside a private VPC can reach internal services while still talking to GitHub through a NAT gateway. You never expose the runner publicly.

Hands-on Example

Register a single Linux runner against a GitHub repository:

mkdir actions-runner && cd actions-runner
curl -O -L https://github.com/actions/runner/releases/download/v2.317.0/actions-runner-linux-x64-2.317.0.tar.gz
tar xzf actions-runner-linux-x64-2.317.0.tar.gz

./config.sh \
  --url https://github.com/your-org/your-repo \
  --token AAAA... \
  --labels self-hosted,linux,gpu \
  --unattended

sudo ./svc.sh install
sudo ./svc.sh start

Then in a workflow file target it with runs-on:

jobs:
  train:
    runs-on: [self-hosted, linux, gpu]
    steps:
      - uses: actions/checkout@v4
      - run: nvidia-smi
      - run: python train.py
Registration (one time):
runner -> config.sh -> POST register -> control plane
                                       (stores runner + labels)

Job execution (every job):
developer -> push -> control plane queue
runner    -> long-poll  ->  control plane
                            hands job + token
runner    -> clone repo, run steps, stream logs -> control plane
Runner registration and job flow

Common Pitfalls

The first pitfall is running untrusted code. If your repo accepts pull requests from forks and you self-host, a malicious PR can run arbitrary commands on your runner. Either restrict self-hosted runners to private repos, or gate forked PRs behind a manual approval.

The second is persistent state. Runners that survive between jobs accumulate cached dependencies, Docker images, and sometimes secrets left in environment files. A later job can read them. The safest pattern is ephemeral runners that register, run one job, and self-terminate.

The third is the silent runner. The process dies, the control plane shows it as offline, but no one notices because jobs just queue. Always wire up a heartbeat alert or a queue-depth alert.

Practical Tips

Use labels to route jobs. A label like gpu-a100 keeps GPU jobs off your CPU fleet. Combine labels: runs-on: [self-hosted, linux, gpu-a100] matches only runners with all three.

Prefer ephemeral runners. Pass --ephemeral during config so the runner exits after one job. Pair this with an autoscaler like actions-runner-controller on Kubernetes that spins up a fresh pod for each job.

Cache outside the runner. Use a shared S3 bucket or a registry mirror so that even ephemeral runners get fast builds. Do not rely on local disk that disappears with the runner.

Rotate registration tokens. They are short-lived by default. Build your provisioning script to fetch a fresh token from the API each time it boots a runner.

Pin the runner version. Auto-update is convenient until a release breaks your workflow at 2 a.m. Pin and upgrade on your schedule.

Wrap-up

Self-hosted runners trade billing simplicity for control. You get faster builds, custom hardware, and private network access, but you take on patching, scaling, and isolation. The two ideas that make this sustainable are ephemeral execution and label-based routing. Start with one runner, prove the savings or capability you needed, then automate the lifecycle before you grow the fleet.