Skip to content
Français
CI/CD & Automatisation medium

GitHub-hosted versus self-hosted runners

35 min de lecture

Read this page in French

Choosing between GitHub-hosted and self-hosted runners depends on your constraints: performance, security, cost and access to internal resources. This guide gives the criteria to decide.

What you will learn

  • Compare GitHub-hosted and self-hosted on the criteria that matter
  • Identify the ideal use cases for each kind
  • Compute the break-even point of a self-hosted runner
  • Combine the two in a hybrid architecture
  • Migrate gradually a workflow to self-hosted

A quick comparison

Before the detail, this table lays out the seven criteria that separate the two kinds of runner.

CriterionGitHub-hostedSelf-hosted
MaintenanceNoneYours
Start-up20-40 sInstant when pre-warmed
EnvironmentClean on every jobPersistent
CostBilled minutes (private)Infrastructure to pay for
NetworkInternet onlyInternal network access
SecurityGuaranteed isolationYour responsibility
SpecsFixed (2 vCPU / 8 GB on a private repository, 4 vCPU / 16 GB on a public one)Customisable

When to choose GitHub-hosted

The runner managed by GitHub is the default choice, and it stays so until a strong constraint pulls you away. The three subsections below detail the situations where it wins, the shape of a workflow using it, and what GitHub genuinely handles on your behalf.

Ideal use cases

These four situations share one thing: nothing in the build requires leaving the disposable machine GitHub provides. The first is the clearest, since on a public repository minutes are free and unlimited: paying for infrastructure would be absurd. The other three come down to a trade-off between the cost of minutes and the often underestimated cost of running a fleet of machines.

  1. Open source projects: free and unlimited
  2. Standard builds: Node.js, Python, Java with no exotic dependencies
  3. A team without ops: no infrastructure to maintain
  4. Critical security: guaranteed isolation between jobs

A typical configuration

A workflow on a managed runner fits in one line, runs-on: ubuntu-24.04. The rest of the example applies the course's security baseline: permissions: {} at workflow level then minimal rights per job, actions pinned by SHA, and persist-credentials: false on the checkout so the GITHUB_TOKEN does not stay in the runner's git configuration. Note ubuntu-24.04 rather than ubuntu-latest: the image behind latest changes without notice and breaks reproducible builds.

permissions: {}
jobs:
test:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test

The advantages in detail

What you buy with a managed runner is not compute power: it is operational work you do not do. The three blocks below implicitly price that transfer of workload. The most decisive is the second, isolation: every job starts on a fresh virtual machine, destroyed at the end, which a persistent runner cannot offer without extra configuration.

Zero maintenance:

  • automatic software updates;
  • security patches applied by GitHub;
  • no servers to manage.

Perfect isolation:

  • a fresh VM on every job;
  • no risk of contamination between jobs;
  • secrets unreachable across runs.

Pre-installed software:

When to choose self-hosted

A self-hosted runner is justified when the job needs something GitHub's disposable machine cannot provide: a network route, a hardware component, or a volume of minutes that makes the billing unreasonable. Outside those three motives, you inherit an operational burden with nothing in return.

Ideal use cases

The first two motives are structural: no workflow setting works around them, you need a machine of your own. The next three are economic or regulatory, and therefore negotiable depending on your volumes and your sector. Mind the third: the saving on minutes is only real beyond the break-even point computed below, and it does not count the on-call time for the runners themselves.

  1. Internal network access: databases, private registries
  2. Specialised hardware: GPU, ARM, large amounts of RAM
  3. Long or frequent builds: savings on minutes
  4. A persistent cache: avoiding re-downloading dependencies
  5. Legal constraints: data that must not leave the network

A typical configuration

On a self-hosted runner, runs-on no longer names an image but a list of labels the runner must all carry. GitHub then looks for a registered machine declaring self-hosted, linux and x64 at once; if none matches, the job stays pending indefinitely with no explicit error message. The rest of the example shows the pattern to copy for an internal registry: the username and password travel through an env: block fed by secrets, and --password-stdin keeps the password out of the command line visible in the logs.

permissions: {}
jobs:
build:
runs-on: [self-hosted, linux, x64]
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build with access to the internal registry
env:
IMAGE_TAG: ${{ github.sha }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
echo "$REGISTRY_PASSWORD" | docker login registry.internal.company.com \
--username "$REGISTRY_USER" --password-stdin
docker build -t "registry.internal.company.com/app:$IMAGE_TAG" .

The advantages in detail

These three families of gains do not hold equally well. Performance and the local cache are measurable from the first build. Network access is a qualitative gain: it removes tunnels and firewall rules, and therefore recurring work. Predictable cost, on the other hand, deserves a caveat: it only becomes an advantage beyond the break-even point, and it counts neither runner updates nor the time spent diagnosing a full disk.

Performance:

  • no cold start when the runner is pre-warmed;
  • a local cache (dependencies, Docker images);
  • tailored hardware (NVMe SSD, 128 GB RAM).

Network access:

  • direct deployment to an internal Kubernetes;
  • tests against internal databases;
  • no tunnel or VPN to configure.

Predictable cost:

  • no per-minute billing;
  • worthwhile for large volumes.

Comparing the costs

Cost is often the deciding factor. Let us compare the per-minute billing of hosted runners with the fixed cost of a self-hosted machine.

GitHub-hosted (a private repository)

Two figures drive the bill: the monthly quota included in your plan, and the per-minute rate beyond it. Minutes are only counted on private repositories; on a public one with standard runners, consumption is free. The rate also depends on the system: at the standard two-core rate, a Windows minute costs about 1.7 times a Linux minute, and a macOS minute about ten times. A Windows job started out of habit where Linux would do therefore multiplies the bill without anyone noticing.

Included minutes per month (private repositories):
- Free : 2,000
- Pro / Team : 3,000
- Enterprise Cloud : 50,000
Price beyond the quota (standard 2-core runners):
- Linux x64 : $0.006/min
- Linux arm64 : $0.005/min
- Windows : $0.010/min
- macOS : $0.062/min

Example: 10,000 Linux minutes a month on a Free plan means 8,000 billed minutes, that is $48 a month.

Self-hosted

Against that variable cost, self-hosted offers a fixed cost. The comparison is only honest if you bring the VM back to its real usage: a machine rented by the month is paid for 24 hours a day, nights and weekends included, when it builds nothing. In return, it serves several repositories at once, which moves the calculation to the organisation level rather than the project one.

A Linux VM (4 CPU, 16 GB) at a cloud provider:
- about $80-150 a month depending on the provider
- can serve several repositories

Break-even point: in the order of 15,000 to 27,000 Linux minutes a month, counting only the VM rental. Redo the calculation with your real rates: the threshold drops sharply as soon as Windows or macOS jobs enter the total, and it rises again if you value the time spent operating the runners.

Your own calculation

Look at your current minutes in Settings > Billing > Actions to estimate whether self-hosted pays off for you.

Comparing the performance

Beyond cost, speed clearly separates the two options, both at job start-up and during the build.

Start-up time

That delay is the time between the workflow trigger and the first useful log line. It is billed on managed runners and it repeats on every job, not every workflow: a matrix of ten jobs pays the start-up ten times. On a pipeline of twenty short jobs, those few tens of seconds weigh more than the compilation itself. The "Warm start" column has no value on the GitHub-hosted side because the VM is destroyed after every job: there is no warm state to reuse.

KindCold startWarm start
GitHub-hosted20-40 sN/A (always cold)
Self-hosted0-5 s0 s (pre-warmed runner)

Build time with a cache

The word "cache" covers two very different mechanisms depending on the runner. On GitHub-hosted, actions/cache downloads an archive from GitHub's cache service then unpacks it onto the VM disk: the cost is network, and it grows with the size of node_modules. On self-hosted, the dependencies are already on the local disk from one job to the next, and restoring is nearly instant. That persistence has a direct downside: a job can inherit an artifact left by a previous one, which makes builds less reproducible and opens the way to cache poisoning.

# GitHub-hosted: downloads the cache on every job
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
# Restore time: 10-30 s depending on size
# Self-hosted: a local cache on disk
# Restore time: under 1 s

A hybrid architecture

The best approach is often to combine both:

permissions: {}
jobs:
# Fast tests on GitHub-hosted
lint:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm run lint
# A heavy build on self-hosted
build:
needs: lint
runs-on: [self-hosted, linux, docker]
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: docker build -t app .
# An internal deployment on self-hosted
deploy:
needs: build
runs-on: [self-hosted, production-network]
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: kubectl apply -f k8s/

Decision criteria

To decide quickly, tick the boxes matching your situation: the column collecting the most wins.

Choose GitHub-hosted if

A single box ticked here is sometimes enough: an open source project has no reason to pay for infrastructure, and a team with no operational capacity will not hold a runner fleet over time. The other criteria accumulate rather than impose themselves.

  • The project is open source
  • Builds run for under 10 minutes
  • No internal network access is needed
  • The team has no ops capacity
  • Isolation between jobs is critical

Choose self-hosted if

Conversely, the two criteria specialised hardware and internal network access are blocking: if either is ticked, no workflow optimisation will spare you hosting your own runners. The other three are volume or compliance criteria, to be run through the break-even calculation. Do not forget the security constraint mentioned above: ticking these boxes on a public repository does not make self-hosted acceptable.

  • More than 15,000 minutes a month
  • A GPU or specialised hardware is needed
  • Internal network access is required
  • Very long builds (over 30 minutes)
  • Data localisation constraints

Migrating to self-hosted

There is no need to switch everything at once. A successful migration is gradual: you target a few workflows, test, then extend.

Step 1: identify the candidate workflows

Start with the workflows that are:

  • the longest (maximum savings);
  • in need of internal network access;
  • the most frequent.

Step 2: deploy a test runner

A first runner on a disposable machine lets you validate the network and the dependencies before committing to anything. Four points deserve attention in the sequence below. First, the SHA-256 fingerprint: GitHub publishes the one for each archive in the release notes, and checking it before extracting avoids running an archive altered in transit. Then the registration token obtained in Settings > Actions > Runners > New self-hosted runner: it expires after an hour and is single-use. Finally, ./run.sh occupies the terminal; for lasting use, install the runner as a service with ./svc.sh install then ./svc.sh start, under a dedicated unprivileged account.

Fenêtre de terminal
# Create the working directory
mkdir actions-runner && cd actions-runner
# Download the runner archive (pinned version)
curl -O -L https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
# Check the fingerprint published in the v2.337.0 release notes
echo "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613 actions-runner-linux-x64-2.337.0.tar.gz" | sha256sum --check
# Extract only if the check prints "OK"
tar xzf ./actions-runner-linux-x64-2.337.0.tar.gz
# Register the runner (the token is valid one hour, collect it in the repository settings)
./config.sh --url https://github.com/OWNER/REPO --token AAAAA...
# Start in the foreground to validate, before switching to a service
./run.sh

The fingerprint above applies to version 2.337.0 on x64. For any other version or architecture, take the one published on the matching release page: never reuse a fingerprint from an earlier version, the check would fail anyway.

Step 3: migrate gradually

The switch happens workflow by workflow, keeping the old runs-on value commented out just above. That detail is not cosmetic: when the self-hosted runner goes down, the jobs stay queued without failing, and rolling back must fit in one uncommented line. Run the first migrated workflow on a test branch, compare its duration with the previous runs, then extend.

jobs:
build:
# Phase 1: test on self-hosted
# runs-on: ubuntu-24.04
runs-on: [self-hosted, linux, x64]

Key points

  • GitHub-hosted means zero maintenance and guaranteed isolation, ideal for 90 % of cases.
  • Self-hosted means your machines, for GPUs, the internal network and large volumes.
  • The break-even point of self-hosted sits beyond 15,000 Linux minutes a month, to be recalculated with your own rates.
  • A hybrid architecture combines both: fast tests hosted, heavy builds self-hosted.
  • A self-hosted runner on a public repository is a major risk; keep it for private repositories.

Next steps

  • Ephemeral runners: how to regain, on self-hosted, the fresh-machine-per-job property that makes GitHub runners valuable.
  • Runner isolation: segmenting jobs by labels, groups and network once the self-hosted fleet serves several teams.
  • Runner maintenance: the recurring cost this page's comparison leaves out of the budget, updates, monitoring and scaling.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn