GitHub Actions in Depth

Runners & Execution Environment

18 min Lesson 2 of 30

Runners & Execution Environment

In Lesson 1 you learned that a workflow is a YAML file composed of jobs and steps triggered by events. But who actually runs those steps? The answer is a runner — a server (physical, virtual, or container) that listens for work from GitHub Actions, executes each step inside your job, and reports the result back. Every decision you make about runners — which type, which OS, how many, how they are networked — has direct consequences for your pipeline's speed, cost, security posture, and compliance story.

GitHub-Hosted Runners

GitHub maintains a global fleet of ephemeral virtual machines. Every job that runs on a GitHub-hosted runner starts from a freshly imaged VM — there is no state left over from a previous job, by any team or any repository. This is the primary security property of hosted runners: each run is a clean slate.

The three major families and their current specs (billed to your Actions minutes quota or paid per-minute for larger runners):

  • ubuntu-24.04 / ubuntu-22.04 — Linux (x64). 4 vCPU, 16 GB RAM, 14 GB SSD. The workhorse for most CI workloads. Fastest boot, cheapest per-minute, best tooling preinstalled.
  • windows-2022 / windows-2025 — Windows Server (x64). 4 vCPU, 16 GB RAM. Required for .NET Framework targets, MSBuild, and Windows-specific tests. Billed at 2× the Linux rate.
  • macos-15 / macos-14 — macOS (arm64 on M1 hardware). Required for iOS and macOS app builds (Xcode). Billed at 10× the Linux rate — the most expensive hosted option by far.

GitHub also offers larger runners — 8, 16, 32, or 64 vCPU — for organizations that need them. These require an organization or enterprise plan and are configured under Settings > Actions > Runner groups.

Key idea — pin your runner OS version. The label ubuntu-latest is a floating pointer that GitHub advances when a new LTS is ready. In the past, GitHub migrated ubuntu-latest from 20.04 to 22.04 and broke pipelines that relied on system package versions. Always pin to an explicit version (ubuntu-24.04) in production workflows so upgrades are deliberate, not accidental.

The runner environment ships with a large software manifest — Node.js, Python, Java, Docker, kubectl, Terraform, and dozens more — but the installed versions change with each new image release. For any tool your pipeline depends on, install it explicitly in a step (using an actions/setup-* action) rather than relying on whatever happens to be preinstalled.

Self-Hosted Runners

A self-hosted runner is any machine you provision, register with GitHub, and maintain yourself. The GitHub Actions runner agent is a small open-source Go binary (github.com/actions/runner) that polls the GitHub API for jobs assigned to it. It runs on Linux, Windows, macOS, and ARM.

The three canonical reasons teams move to self-hosted runners:

  1. Network access. Your integration tests, deployment scripts, or security scans must reach private resources — an on-premise database, an internal artifact registry, a VPC-hosted Kubernetes cluster — that are not reachable from GitHub's public IP ranges.
  2. Cost at scale. At high volume (thousands of CI minutes per day), the per-minute cost of GitHub-hosted runners exceeds the amortized cost of owning or renting dedicated compute. Many large engineering organizations run their runners on spot/preemptible VMs to cut cost further.
  3. Custom hardware. GPU-accelerated ML training, Apple Silicon for iOS native builds, FPGA targets, or ARM hardware-in-the-loop testing all require machines you bring yourself.
Production pitfall — self-hosted runners are a security boundary you own. GitHub-hosted runners are ephemeral and isolated. Self-hosted runners are persistent and shared (unless you explicitly configure ephemeral mode). A malicious pull request from a fork can execute arbitrary code on your runner if you accept pull requests from untrusted contributors. Never run self-hosted runners on public repositories unless you have automated every security control listed in GitHub's hardening guide (ephemeral runners, no cross-job state, no secrets at rest on the machine). For internal-only repositories, self-hosted runners are standard practice.

Registering a self-hosted runner takes under five minutes:

# Run on the machine you want to register as a runner. # Navigate to: GitHub repo (or org) > Settings > Actions > Runners > New self-hosted runner # Download the runner agent (Linux x64 example — check the UI for the current version URL) mkdir actions-runner && cd actions-runner curl -o actions-runner-linux-x64-2.316.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.316.0/actions-runner-linux-x64-2.316.0.tar.gz tar xzf ./actions-runner-linux-x64-2.316.0.tar.gz # Configure (the token is generated in the GitHub UI, valid for 1 hour) ./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \ --token AABBT3EXAMPLE \ --labels self-hosted,linux,x64,production \ --name runner-prod-01 \ --work _work \ --unattended # Install as a systemd service so it survives reboots sudo ./svc.sh install sudo ./svc.sh start # Verify it is listening sudo ./svc.sh status

In production, never register a single runner. Register a fleet behind a label and let GitHub distribute jobs across the fleet. Use runner groups to control which repositories can access which runners — a critical isolation boundary in multi-team organizations.

Runner Groups: Access Control at Scale

Runner groups are an organization-level (or enterprise-level) construct that lets you assign runners to groups and then grant specific repositories access to those groups. This is how big-tech engineering organizations prevent a team's repository from accidentally (or intentionally) scheduling jobs on another team's expensive GPU runners or, worse, on runners that have production credentials loaded.

# Example: restrict a runner group to only the repositories that need it # Set via the GitHub API (or UI: Org Settings > Actions > Runner groups) curl -X PUT \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/orgs/MY_ORG/actions/runner-groups/42/repositories \ -d '{ "selected_repository_ids": [123456789, 987654321] }' # In the workflow, target the group by its label (not by group name): # jobs: # deploy: # runs-on: [self-hosted, production-gcp] # # The runner registered with the label "production-gcp" must belong to # the runner group that grants access to this repository. # Jobs from other repositories will queue indefinitely -- they never match.
Pro practice — ephemeral self-hosted runners. Configure self-hosted runners with --ephemeral (available since runner v2.293). In ephemeral mode, the runner deregisters itself after completing a single job, and your infrastructure-as-code (Terraform, Pulumi, or a custom controller) provisions a fresh VM for the next job. This gives you the security property of GitHub-hosted runners (no cross-job state) while keeping the benefits of self-hosted (network access, custom hardware). Tools like actions-runner-controller (ARC) for Kubernetes automate this pattern at scale.

Containers as Jobs: The container Key

GitHub Actions supports running an entire job inside a Docker container, rather than directly on the runner host. This is different from a step that happens to run a Docker command — the entire job executes inside the specified image, giving you a fully reproducible environment that matches your production container exactly.

GitHub Actions Runner Architecture: Hosted vs Self-Hosted vs Container Job GitHub Actions Service GitHub-Hosted ubuntu-24.04 VM Ephemeral & Isolated Fresh VM per job No residual state Self-Hosted Your VM / Bare Metal Private Network Access Custom labels & groups You manage lifecycle Container Job Docker image as job env Exact prod image Service containers Reproducible env Choosing a Runner Type Public repo / quick start Private network / GPU / cost at scale Exact runtime match / DB sidecar needed → GitHub-Hosted → Self-Hosted (ephemeral mode) → Container Job + service containers
Three runner models in GitHub Actions: hosted VMs (fully managed, ephemeral), self-hosted machines (network access, your responsibility), and container jobs (Docker image as the job environment).

The container key at the job level wraps every step in a Docker container. Steps do not run on the host OS — they run inside the container, with the workspace mounted in. This means if you use a python:3.12-slim image, every step has exactly that Python version, regardless of what the underlying runner OS has installed. No actions/setup-python needed.

# .github/workflows/test-in-container.yml # The entire job runs inside the specified Docker image. # The runner host only needs Docker installed. name: Test in container on: [push, pull_request] jobs: pytest: runs-on: ubuntu-24.04 # The host runner — must have Docker container: image: python:3.12-slim # Every step runs inside this container options: --cpus 2 # Pass extra Docker run flags if needed # Service containers — sidecar containers available over a Docker bridge network. # The hostname is the service key (postgres, redis). services: postgres: image: postgres:16-alpine env: POSTGRES_DB: testdb POSTGRES_USER: app POSTGRES_PASSWORD: secret options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7-alpine options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-retries 5 env: DATABASE_URL: postgresql://app:secret@postgres/testdb REDIS_URL: redis://redis:6379 steps: - uses: actions/checkout@v4 - name: Install dependencies run: pip install --no-cache-dir -r requirements.txt - name: Run tests with coverage run: pytest --cov=. --cov-report=xml -q - name: Upload coverage uses: codecov/codecov-action@v4 with: files: ./coverage.xml

Service containers spin up alongside the job container and are reachable by hostname (postgres, redis) via Docker's internal bridge network. The --health-* options ensure Actions waits for the service to be ready before running your steps — a critical detail that prevents flaky tests caused by a race between your app code and the database not yet accepting connections.

Pro practice — use service containers instead of mocking. The most common source of false-green CI results is tests that mock the database or cache layer. The mock returns what you told it to return, not what the real system does. Service containers let you run against a real Postgres, real Redis, and real RabbitMQ in CI — the same topology as production, with no extra infrastructure to manage. This approach catches an entire class of bugs (query planner behavior, serialization edge cases, connection pool exhaustion) that mocks will never surface.

Runner Selection Decision Framework

In production, runner selection follows a clear decision tree:

  • Start with GitHub-hosted (ubuntu-24.04) for everything. This is the right default. The cost is predictable, the maintenance burden is zero, and the security model is strong.
  • Move to self-hosted only when you have a concrete requirement that hosted runners cannot satisfy: private network access, custom hardware, or volume-driven cost savings. Use ephemeral runners (ARC or a custom controller) to preserve the security properties of hosted runners.
  • Add container: when your test environment must match a specific runtime exactly, or when you need sidecar services (databases, message brokers, SMTP servers) that are costly to mock correctly.
  • Use runner groups as soon as you have more than one team or more than one sensitivity tier of runner (e.g., runners with production cloud credentials vs. runners with no credentials at all).
Common failure mode — stale toolchain on a long-lived self-hosted runner. Teams that run a single persistent self-hosted runner frequently hit a failure mode where a workflow that worked six months ago silently breaks because something changed on the runner: a system Python was upgraded, a tool was uninstalled by another job, or disk space ran out. The fix is to treat runners as cattle, not pets. Automate runner provisioning with Terraform or a cloud image pipeline, bake your tool versions into the image, and replace rather than patch. If you cannot do that yet, at minimum install every tool your pipeline needs explicitly in your workflow steps — never depend on ambient state.

Understanding runners deeply is foundational for everything that follows in this tutorial — caching strategies, matrix builds, OIDC-based cloud auth, and secrets scoping all interact directly with where and how your jobs execute. In Lesson 3 we move to expressions and contexts — the templating language that lets your workflow logic adapt dynamically to the event, branch, and runner environment at runtime.