← Back to Blog
DevOps2026-05-10·74 min read

Self-Hosting GitLab Runners: What the Docs Don't Tell You

By 우병수

Architecting Deterministic CI/CD: A Production Guide to Self-Hosted GitLab Runners

Current Situation Analysis

Continuous integration pipelines are the nervous system of modern software delivery, yet most teams treat runner infrastructure as an afterthought. The default path—relying on platform-provided shared runners—works until it doesn't. The failure mode is rarely catastrophic; it's cumulative. Queue times inflate during peak deployment windows, CI/CD minute allowances evaporate within days, and compliance frameworks begin flagging execution environments that sit outside organizational control.

The core misunderstanding lies in conflating convenience with reliability. Shared runners abstract away infrastructure management, but they introduce three hidden costs:

  1. Queue Volatility: Job scheduling is competitive. During standard business hours, pipelines frequently sit in pending state for 30–60 minutes while higher-priority or larger-tier projects consume available slots.
  2. Minute Economics: Free tiers typically allocate 400 CI/CD minutes monthly. Paid tiers charge incrementally per 1,000 additional minutes. Teams running integration suites that average 8–12 minutes per execution will exhaust allowances rapidly, converting a fixed operational expense into a variable, unpredictable one.
  3. Compliance Drift: Regulatory frameworks (PCI-DSS, HIPAA, SOC 2) require auditable execution boundaries. When secrets, build artifacts, and proprietary code execute on multi-tenant infrastructure, audit trails become fragmented. Reviewers routinely reject shared runner logs as insufficient evidence of controlled environments.

Self-hosting shifts the burden from variable consumption to fixed infrastructure ownership. You gain deterministic queue times, unlimited execution capacity, and full environmental control. In exchange, you assume responsibility for version synchronization, capacity planning, network topology, and executor isolation. The trade-off is intentional: predictable performance requires operational discipline.

WOW Moment: Key Findings

The decision to self-host isn't just about avoiding queue delays. It fundamentally alters your CI/CD cost structure, security posture, and deployment velocity. The following comparison illustrates the operational divergence between shared and self-hosted execution models.

Approach Queue Latency (P95) Cost Model Compliance Alignment Operational Overhead
Shared Runners 15–45 min (peak) Variable (per 1k mins) Low (multi-tenant) Minimal (platform managed)
Self-Hosted (Fixed) <2 min Fixed (compute/VM) High (isolated boundary) Moderate (versioning, patching, scaling)
Self-Hosted (Auto-scaled) <1 min Fixed + burst compute High (isolated boundary) High (orchestration, health checks)

Why this matters: Self-hosted runners transform CI/CD from a consumption-based utility into a deterministic engineering asset. Once provisioned, queue latency drops to sub-2-minute thresholds regardless of team size. Compliance audits gain clear execution boundaries. The operational overhead is front-loaded: initial setup requires network validation, token management, and executor tuning, but long-term maintenance stabilizes around version pinning and capacity monitoring. Teams that treat runner infrastructure as first-class citizens consistently report 40–60% reductions in pipeline feedback loops.

Core Solution

Building a production-ready self-hosted runner requires deliberate architectural choices. The following implementation prioritizes isolation, version stability, and network resilience.

Step 1: Environment Provisioning & Network Topology

Start with a clean Ubuntu 22.04 LTS instance. The distribution provides stable apt repository integration, predictable systemd behavior, and extensive community troubleshooting coverage. RHEL-family distributions require manual repository configuration and SELinux policy adjustments, which introduce unnecessary friction during initial deployment.

Network configuration is frequently overlooked. The runner requires outbound TCP connectivity on port 443 to reach your GitLab control plane. However, artifact and cache routing operates independently. If your GitLab instance delegates storage to S3-compatible object storage, the runner downloads and uploads artifacts directly to that endpoint. Runners deployed in isolated VPCs or behind restrictive egress firewalls must include explicit outbound rules for the S3 bucket region. Failure to configure this results in silent job failures with unhelpful error messages.

Step 2: Binary Deployment & Version Pinning

Never install runners via ad-hoc shell scripts or third-party repositories. GitLab maintains official package repositories with GPG verification and clean upgrade paths. Use the official repository bootstrap script, then install a pinned version.

#!/usr/bin/env bash
set -euo pipefail

RUNNER_VERSION="16.11.0"
REPO_SCRIPT_URL="https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh"

echo "Bootstrapping official GitLab Runner repository..."
curl -fsSL "${REPO_SCRIPT_URL}" | sudo bash

echo "Installing pinned runner version: ${RUNNER_VERSION}"
sudo apt-get install -y "gitlab-runner=${RUNNER_VERSION}"

echo "Preventing automatic upgrades..."
sudo apt-mark hold gitlab-runner

Version pinning is non-negotiable in production. Unattended package upgrades can restart the runner daemon mid-execution, orphaning active jobs and triggering duplicate webhook deliveries. The apt-mark hold directive ensures the binary remains at a known-compatible state until you deliberately schedule an upgrade window.

Step 3: Token Generation & Non-Interactive Registration

GitLab 16.x introduced a breaking change to runner authentication. Legacy registration tokens (--registration-token) were deprecated and fully removed in 17.x. The current workflow requires generating an authentication token via the UI, which produces a glrt- prefixed secret.

Navigate to Admin → CI/CD → Runners → New instance runner (or group/project scope as needed). Generate the token, then register using a non-interactive, environment-driven approach:

#!/usr/bin/env bash
set -euo pipefail

export RUNNER_URL="https://gitlab.example.com"
export RUNNER_AUTH_TOKEN="glrt-xxxxxxxxxxxxxxxxxxxx"
export RUNNER_EXECUTOR="docker"
export RUNNER_BASE_IMAGE="node:20-bookworm"
export RUNNER_TAGS="docker,linux,production"
export RUNNER_DESCRIPTION="ci-worker-prod-01"

sudo gitlab-runner register \
  --non-interactive \
  --url "${RUNNER_URL}" \
  --token "${RUNNER_AUTH_TOKEN}" \
  --executor "${RUNNER_EXECUTOR}" \
  --docker-image "${RUNNER_BASE_IMAGE}" \
  --tag-list "${RUNNER_TAGS}" \
  --description "${RUNNER_DESCRIPTION}" \
  --docker-volumes "/var/run/docker.sock:/var/run/docker.sock:rw" \
  --docker-volumes "/tmp/gitlab-runner-cache:/cache"

The --non-interactive flag is critical for infrastructure-as-code workflows. It eliminates manual prompts, ensures deterministic configuration, and allows registration to be embedded in provisioning pipelines. The glrt- token format is mandatory for 16.x+ instances. Verify your GitLab version at the instance footer before executing legacy runbooks.

Step 4: Executor Architecture & Isolation Rationale

The Docker executor is the default recommendation for production environments. It provides process isolation, reproducible build environments, and clean state teardown between jobs. Shell executors share the host filesystem and user space, creating permission conflicts and state leakage.

When pipelines require Docker-in-Docker (DinD) for image builds, hardware requirements shift dramatically. A single DinD daemon consumes approximately 3GB RAM during layer pulls and container initialization. Running two concurrent DinD jobs on a 4GB instance triggers the OOM killer. Provision at least 4 cores and 8GB RAM for DinD workloads, and cap concurrency at 2 in the runner configuration.

Step 5: System User & Permission Boundary

The installer automatically creates a gitlab-runner system user and group. This account executes all jobs under the shell executor and manages Docker socket interactions. Build artifacts, cache directories, and mounted volumes must grant read/write access to this user. Root-owned files from previous cron jobs or manual deployments will cause Permission denied failures that appear mysterious until ownership is audited.

Pitfall Guide

1. Token Format Mismatch

Explanation: Attempting to register with a legacy --registration-token on GitLab 17.x+ returns 401 Unauthorized. The authentication model shifted to glrt- prefixed tokens. Fix: Generate tokens via the UI under CI/CD → Runners. Use --token (not --registration-token) in registration commands. Verify instance version before executing runbooks.

2. SELinux Socket Blocking

Explanation: On RHEL/Rocky/Alma systems, SELinux enforces mandatory access controls that silently block Docker socket mounts. Jobs fail with generic execution errors while avc: denied appears in audit logs. Fix: Temporarily run sudo setenforce 0 during setup to isolate the issue. For production, apply correct SELinux labels (chcon -Rt container_runtime_exec_t /var/run/docker.sock) or configure permissive policies for the runner service.

3. Unpinned Runner Upgrades

Explanation: System package managers automatically update the runner binary during maintenance windows. A mid-pipeline restart orphan jobs, corrupt cache state, and trigger duplicate deployment webhooks. Fix: Always pin the package version (apt-mark hold or dnf versionlock). Schedule upgrades during low-traffic windows and validate compatibility against your .gitlab-ci.yml syntax.

4. S3 Egress Blind Spots

Explanation: Runners download artifacts and caches directly from S3-compatible storage, bypassing the GitLab control plane. Restrictive egress firewalls block these requests, causing silent job failures. Fix: Audit network policies and add explicit outbound rules for your object storage endpoint. Test connectivity with curl -I https://<s3-endpoint> from the runner host before registering.

5. Shell Executor Permission Leaks

Explanation: The gitlab-runner system user lacks privileges to access root-owned directories. Jobs attempting to write to /opt, /var/www, or previous build artifacts fail with permission errors. Fix: Audit file ownership before registration. Use chown -R gitlab-runner:gitlab-runner /path/to/build/dir or configure volume mounts with explicit UID/GID mapping. Prefer Docker executor for strict isolation.

6. Concurrent Job Overcommit

Explanation: Setting concurrent = 10 on a 4-core/8GB machine with DinD workloads causes memory exhaustion. The OOM killer terminates the Docker daemon, corrupting active builds. Fix: Calculate RAM requirements per job type. Basic jobs: ~1GB. DinD jobs: ~3GB. Set concurrent to floor(total_ram / max_job_ram). Monitor with htop during peak loads.

7. Cache Invalidation Neglect

Explanation: Stale dependency caches accumulate across branches, causing build inconsistencies and disk bloat. Runners do not automatically prune expired cache entries. Fix: Implement cache key strategies based on lockfile hashes (package-lock.json, poetry.lock). Configure cache:paths explicitly. Schedule periodic cache cleanup via cron or GitLab CI scheduled pipelines.

Production Bundle

Action Checklist

  • Provision Ubuntu 22.04 LTS instance with minimum 4-core/8GB RAM for DinD workloads
  • Configure outbound TCP 443 to GitLab control plane and S3 artifact endpoint
  • Install pinned runner binary via official apt/rpm repository
  • Generate glrt- authentication token via GitLab UI (16.x+ compatible)
  • Register runner using --non-interactive flag with Docker executor
  • Verify gitlab-runner system user owns build directories and cache mounts
  • Set concurrent limit based on available RAM and job type requirements
  • Implement cache key strategy tied to dependency lockfile hashes

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Small team, basic lint/unit tests 2-core/4GB VM, Docker executor Sufficient for lightweight jobs, minimizes idle compute Low (~$4-8/mo)
Medium team, DinD image builds 4-core/8GB VM, Docker executor + DinD service Prevents OOM kills, supports concurrent image compilation Medium (~$15-25/mo)
Enterprise, compliance-bound Dedicated VMs, Docker executor, isolated VPC Meets audit requirements, full network control, deterministic performance High (~$30-60/mo)
Variable load, cost-sensitive Auto-scaled runner pool + spot instances Matches capacity to demand, reduces idle spend Variable (optimized)

Configuration Template

# /etc/gitlab-runner/config.toml
concurrent = 2
check_interval = 0
log_level = "info"

[[runners]]
  name = "ci-worker-prod-01"
  url = "https://gitlab.example.com"
  token = "glrt-xxxxxxxxxxxxxxxxxxxx"
  executor = "docker"
  environment = ["DOCKER_TLS_CERTDIR="]
  limit = 0

  [runners.custom_build_dir]
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "s3.amazonaws.com"
      BucketName = "ci-cache-prod"
      BucketLocation = "us-east-1"
      Insecure = false

  [runners.docker]
    tls_verify = false
    image = "node:20-bookworm"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = [
      "/cache",
      "/var/run/docker.sock:/var/run/docker.sock:rw"
    ]
    shm_size = 0
    pull_policy = "if-not-present"

Quick Start Guide

  1. Bootstrap Repository: Execute the official apt repository script and install the pinned runner binary. Apply apt-mark hold to prevent automatic upgrades.
  2. Generate Token: Navigate to your GitLab instance → Admin → CI/CD → Runners → New instance runner. Copy the glrt- prefixed token.
  3. Register Runner: Run the non-interactive registration command with Docker executor, base image, and required volume mounts. Verify daemon status with systemctl status gitlab-runner.
  4. Validate Pipeline: Trigger a test job with image: node:20-bookworm and script: echo "Runner operational". Confirm execution completes in under 2 minutes and artifacts upload successfully to your storage endpoint.