# GitHub Actions Self-Hosted Runners: Complete Setup and Security Guide

> How to set up, secure, and scale GitHub Actions self-hosted runners on your own VPS or GCP instance — with ephemeral mode, runner groups, and the security practices that matter.

URL: https://www.matthewswong.com/en/blog/github-actions-runners/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2025-06-10
- Updated: 2026-10-17
- Category: DevOps

GitHub-hosted runners are convenient but have limitations that matter at scale: 6-10 GB of RAM, no persistent tool caches, limited concurrent runners on free plans, and public runner IPs that some internal services reject. A common reason to run self-hosted runners on a small DigitalOcean Droplet is twofold: to access private GCP resources without exposing them to GitHub's public runner IPs, and to cache Docker layers and npm packages between builds, which can cut build times substantially. This guide covers the complete setup with security practices that don't cut corners.

## Why Self-Hosted Runners and When to Use Them

GitHub-hosted runners are the right default for most projects — zero maintenance, automatically updated, and available in multiple sizes and operating systems. Self-hosted runners add value when: your CI/CD needs access to resources inside a private network (VPC, on-premises services); you need persistent caches that survive between runs (Docker layer cache, npm cache); you need specific hardware (GPU for ML training, NVMe for I/O-intensive tests); or you're hitting GitHub's concurrency limits on the free or Team plan. GitHub's free plan limits you to 20 concurrent jobs; self-hosted runners have no concurrency limits beyond your hardware.

### Security Model: The Most Important Consideration

Self-hosted runners execute arbitrary code from your repositories — including code from pull requests if you've enabled that. GitHub's own documentation recommends never using self-hosted runners for public repositories, because a malicious PR could run code on your infrastructure. For private repositories, the risk is reduced but not eliminated — a compromised developer account could push malicious workflow code. The most important security controls: run runners in ephemeral mode (each job gets a fresh, isolated environment); never grant runners access to secrets beyond what a specific job needs; use runner groups to limit which repositories can use which runners.

### Ephemeral Mode: The Right Way to Run Self-Hosted

Starting March 2026, GitHub recommends running self-hosted runners in ephemeral mode: --ephemeral flag means the runner processes one job and then de-registers itself. The runner manager (Actions Runner Controller for Kubernetes, or a simple systemd service restart for VPS) provisions a fresh runner for the next job. This prevents state from one job affecting another — no leftover environment variables, no residual files, no shared secrets between runs. On a DigitalOcean Droplet, implement ephemeral runners with a systemd service that restarts the runner after each job completes using --once --ephemeral flags.

> From my experience: place your self-hosted runners in the same region as your primary GCP resources. Our runners are in DigitalOcean Singapore, and they pull Docker images from our Artifact Registry in asia-southeast1 (Singapore). Intra-datacenter transfers (within Singapore) are fast and free on DigitalOcean, and GCP's internal network is fast for GCP-to-GCP traffic. This cache locality alone reduced our Docker image pull time from 45 seconds to 8 seconds per build — a 35% overall build time reduction.

## Installation and Configuration on Ubuntu

Setting up a self-hosted runner requires: creating a runner registration token in GitHub (Settings → Actions → Runners → New self-hosted runner), downloading and configuring the runner agent, and setting up the runner as a systemd service. The runner communicates with GitHub via HTTPS (port 443 outbound only) — no inbound ports need to be opened on your firewall. The runner registration token is short-lived (1 hour) — generate it immediately before running the config script. Store the runner's .credentials file securely and never commit it to version control.

### Runner Labels and Workflow Targeting

Self-hosted runners can have custom labels that workflows use to target specific runner types. A runner with label [self-hosted, linux, x64, gcp-sg] can be targeted with runs-on: [self-hosted, gcp-sg] in your workflow YAML. Use labels to distinguish runners by capability (gpu, high-memory, gcp-access) and by environment (staging-runner, prod-runner). Only production deployment jobs should run on runners with production credential access. Development and test jobs can run on runners with read-only access. This label-based routing is how you implement environment isolation without maintaining separate runner fleets for every workflow.

> **Never give self-hosted runners persistent credentials via environment variables**

> When I first set up our self-hosted runners, I configured the GCP service account key as an environment variable in the runner's systemd service file. This meant every job on that runner had full GCP access, regardless of what the job actually needed. When a dependency-scanning job ran and pulled a dependency from a compromised source, that compromised code theoretically had access to our GCP service account. The correct approach: use GitHub's OIDC token to authenticate to GCP dynamically, per workflow. The runner itself has no persistent credentials — each workflow authenticates with a short-lived token scoped to that specific workflow's needs. GitHub Actions OIDC + GCP Workload Identity Federation eliminates the need for any static service account keys on runners.

## Scaling: Actions Runner Controller for Kubernetes

For teams with more than 3-4 self-hosted runners or highly variable CI/CD load, Actions Runner Controller (ARC) is GitHub's recommended Kubernetes-based autoscaling solution. ARC watches the GitHub Actions queue and scales runner pods up and down based on pending job count. Runners are ephemeral Kubernetes pods — created for a job and destroyed after. ARC integrates with GKE, EKS, and AKS. The overhead of Kubernetes is justified when you need to scale from 2 to 20 runners dynamically — manual VPS scaling would require provisioning and configuring each runner individually.

## Build Caching with Self-Hosted Runners

The primary performance advantage of self-hosted runners over GitHub-hosted runners is persistent caching. Docker layer cache: configure your runner's Docker daemon to use a local cache directory. Subsequent builds that use the same base layers skip the pull entirely. npm/yarn cache: the cache action works on self-hosted runners by caching to a local directory rather than GitHub's cache service. For a Next.js project with 500MB of node_modules, caching the dependency install step saves 90-120 seconds per build. Python venv, Go modules, Maven local repository — all benefit from the same persistent cache strategy.

## My Take: Self-Hosted Runners Pay Off After 100+ Daily Builds

For small projects with 5-20 CI runs per day, GitHub-hosted runners are simpler and the cost is negligible. Self-hosted runners start making sense around 50-100 daily builds where build time savings and cache efficiency add up, or when you have specific network access requirements. A small self-hosted runner (for example a $12/month DigitalOcean Droplet with 2 vCPU and 4GB RAM) can handle a steady stream of CI jobs and may pay for itself in time savings within the first month. The runner update cadence (GitHub now enforces minimum runner versions) requires attention — a weekly cron job that checks the runner version and updates it if needed is a simple way to handle it.

## Sources & Further Reading

- GitHub Docs — Self-Hosted Runners Reference — https://docs.github.com/en/actions/reference/runners/self-hosted-runners
- GitHub Docs — Using Self-Hosted Runners in a Workflow — https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/using-self-hosted-runners-in-a-workflow
- WarpBuild — A Complete Guide to Self-Hosting GitHub Actions Runners — https://www.warpbuild.com/blog/self-hosting-github-actions

## Source links

- https://docs.github.com/en/actions/reference/runners/self-hosted-runners
- https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/using-self-hosted-runners-in-a-workflow
- https://www.warpbuild.com/blog/self-hosting-github-actions

## FAQ

### When does it make sense to switch from GitHub-hosted runners to self-hosted runners?

Self-hosted runners start paying off around 50–100 daily builds, where build time savings and cache efficiency add up. They are also the right choice when your CI/CD jobs need access to private network resources — such as private GCP services — that GitHub's public runner IPs cannot reach.

### Why should self-hosted runners be run in ephemeral mode?

Ephemeral mode (--ephemeral flag) means each runner processes exactly one job and then de-registers itself, so no state carries over between jobs. This prevents leftover environment variables, residual files, or shared secrets from one run contaminating the next, which is the most important isolation guarantee for self-hosted runners.

### What is the correct way to give a self-hosted runner access to GCP without storing static credentials?

Use GitHub Actions OIDC tokens combined with GCP Workload Identity Federation. Each workflow authenticates dynamically with a short-lived, scoped token rather than a static service account key, so the runner itself holds no persistent credentials. This eliminates the risk of every job on the runner having full GCP access regardless of what it actually needs.

### How much build time improvement can caching provide on a self-hosted runner?

Persistent caching on self-hosted runners can significantly cut build times. In the example described in the post, caching Docker layers and npm packages reduced build time from 8 minutes to under 3 minutes, and co-locating the runner with the Docker registry (both in Singapore) reduced image pull time from 45 seconds to 8 seconds — a 35% overall build time reduction.

### When should a team adopt Actions Runner Controller (ARC) instead of simple VPS-based runners?

ARC is recommended for teams running more than 3–4 self-hosted runners or dealing with highly variable CI/CD load, since it autoscales ephemeral Kubernetes runner pods based on the pending job queue. For smaller setups, a $12/month DigitalOcean Droplet running 40–60 jobs per day is sufficient and simpler to manage.

---

- HTML version: https://www.matthewswong.com/en/blog/github-actions-runners/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/github-actions-runners/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
