Self-Hosted Runners: When and How to Use Them
GitHub-hosted runners are fresh virtual machines that GitHub provisions, runs your job on, and destroys. A self-hosted runner is a machine you control instead — a server in your data center, a virtual machine in your own cloud account, or a container in your own cluster — that you register with a repository or organization so GitHub Actions can send jobs to it. You take on the operational burden (patching, scaling, security) in exchange for capabilities GitHub-hosted runners cannot offer: access to your private network, specialized hardware, custom software, or lower cost at high volume.
This lesson assumes you already know how workflows, jobs, and steps fit together. It focuses on the decision of when a self-hosted runner is the right tool, and how to configure and secure one without turning your CI system into an attack surface.
Overview / How it works
A self-hosted runner is just the actions-runner application running on a machine you manage. You install it, register it against a repository, organization, or enterprise using a short-lived registration token generated from the GitHub UI or API, and then start it as a process or service. Once registered, the runner polls GitHub for queued jobs whose runs-on labels match the labels assigned to that runner. When a match is found, GitHub dispatches the job and the runner executes it directly on that machine’s operating system, with that machine’s network access, installed tools, and file system.
That last point is the whole story in one sentence: a self-hosted runner executes workflow code with the permissions and network reach of the machine it runs on. GitHub-hosted runners are ephemeral and isolated by default — a fresh VM per job, destroyed afterward. Self-hosted runners are often persistent, reused across many jobs, and sitting inside your internal network. That difference drives almost every decision in this lesson.
Typical reasons teams adopt self-hosted runners:
- Network access — the job needs to reach an internal artifact server, database, or on-premises system that isn’t reachable from the public internet.
- Specialized hardware — GPUs for machine learning builds, ARM or embedded targets, or licensed software that can’t be installed on a throwaway VM.
- Cost and scale — at very high job volume, running your own compute (especially spare capacity you already own) can be cheaper than paying per-minute for hosted runners.
- Compliance — some environments require build and deploy steps to happen on infrastructure the organization directly controls and audits.
None of these justify self-hosting a runner for a public, open-source repository that accepts pull requests from anyone. That combination — self-hosted runner plus untrusted external contributors — is the single riskiest pattern in GitHub Actions, and it is covered in detail below.
Syntax or workflow structure
You target a self-hosted runner the same way you target a hosted one: through runs-on. The difference is that self-hosted runners are identified by labels you define, not by a fixed image name like ubuntu-latest.
runs-on: [self-hosted, linux, x64, internal-net]
Every label in that list must match a label on the runner for the job to be scheduled there. GitHub also automatically applies the self-hosted label plus labels for the runner’s OS and architecture, so you can match broadly (just self-hosted) or narrowly (a specific custom label like gpu or prod-deploy). Organizations can group runners into runner groups, which restrict which repositories and workflows are even allowed to see a given group of machines — this is the primary access-control mechanism for shared self-hosted fleets, and it matters more than the label match itself.
Registration happens outside the workflow file, through the GitHub UI (repository or organization Settings → Actions → Runners → New self-hosted runner) or the REST API. GitHub gives you a short-lived registration token and a small setup script that downloads the runner application, configures it with that token, and optionally installs it as a background service.
Examples
Example 1: Reaching an internal service
A build step needs to reach an artifact server that only exists inside the company network. A GitHub-hosted runner has no route to it; a self-hosted runner sitting on that network does.
name: Internal Build
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: [self-hosted, linux, x64, internal-net]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build against internal artifact server
run: |
curl -sSf https://artifacts.internal.example.com/health
make build
Expected behavior: the job is only scheduled on a runner registered with all three labels — self-hosted, linux, and internal-net. Because that machine lives on the internal network, the curl health check and the build’s dependency downloads succeed where they would time out on a GitHub-hosted runner. The explicit permissions: contents: read block keeps the job’s GITHUB_TOKEN read-only even though this workflow only pushes to main, which normally implies a trusted context.
Example 2: Scoped deploy after tests pass
Deployment should never be self-hosted-only by default just because it’s convenient — it should be self-hosted because it needs something a hosted runner can’t provide (here, network-adjacent access to production), and it should only run after tests pass and a human or automated gate approves it.
name: Deploy to Production
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: [self-hosted, linux, prod-deploy]
environment:
name: production
url: https://app.example.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
- name: Health check
run: curl -sf https://app.example.com/healthz
Expected behavior: test runs on a disposable, GitHub-hosted VM — there’s no reason to burn self-hosted capacity or expose internal access for a step that just runs npm test. deploy only starts once test succeeds, and because it targets the production environment, it pauses for any protection rules configured on that environment (for example, required reviewers) before the self-hosted runner picks it up. id-token: write is scoped in so the deploy step can exchange a short-lived OIDC token for cloud credentials instead of relying on a long-lived secret. The health check after deploy gives you an immediate, automated signal of whether the rollout worked, which is what makes a fast rollback possible if it didn’t.
Example 3: Gating self-hosted jobs on pull requests
Sometimes a self-hosted runner genuinely needs to run integration tests triggered by a pull request. The safe pattern requires an explicit, auditable gate rather than letting any pull_request event reach the runner.
name: Restricted Self-Hosted Job
on:
pull_request:
types: [labeled]
permissions:
contents: read
jobs:
integration-test:
if: >
github.event.label.name == 'safe-to-test' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, linux, integration]
steps:
- uses: actions/checkout@v4
- run: ./run-integration-tests.sh
Expected behavior: the job only executes when a maintainer (someone with write access, since only they can apply labels) adds the safe-to-test label, and only when the pull request’s head repository matches the base repository — meaning it did not come from a fork. Nothing runs on the self-hosted runner just because an external contributor opened a pull request. This is a mitigation, not a guarantee: it still requires the maintainer applying the label to have actually reviewed the code first, since the label itself doesn’t inspect the diff.
Step by step
- Decide whether you actually need a self-hosted runner. If the only reason is “it’s free capacity we already have,” weigh that against the ongoing cost of patching, monitoring, and securing the machine yourself.
- Register the runner: in the repository or organization Settings, open Actions → Runners → New self-hosted runner, and follow the generated setup script, which downloads the runner package and runs a configuration command with a short-lived registration token.
- Assign meaningful labels during configuration (for example
linux,gpu,prod-deploy) so workflows can target the specific capability they need instead of any available machine. - At the organization level, place the runner in a runner group and restrict that group to the specific repositories or workflows allowed to use it. This is the real access boundary — labels alone are not a security control.
- Install the runner as a service (or run it with the
--ephemeralflag so it deregisters and requires re-registration after each job) rather than leaving it running interactively in a terminal. - Add an explicit
permissions:block to every workflow that uses the runner, granting only what that job’s steps actually need. - For anything triggered by pull requests, gate execution so forked, untrusted code cannot reach the runner without review, as shown in Example 3.
- Monitor runner logs and machine-level activity the same way you’d monitor any server with access to your internal network or deploy credentials.
Common Mistakes
Mistake 1: Running a self-hosted job on every pull request, including forks.
on: pull_request
jobs:
build:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- run: ./build-and-test.sh
Anyone can open a pull request from a fork. The default pull_request trigger checks out and runs that fork’s code, and here it does so directly on your self-hosted machine — giving an untrusted contributor arbitrary code execution with the network access, file system, and any locally cached credentials your runner has. This is a well-documented supply-chain risk, and it gets worse if the workflow is switched to pull_request_target, which runs with access to repository secrets and a token with write permissions even for fork PRs. Fix: use the label-and-same-repository gate from Example 3, or simply keep self-hosted runners off any workflow reachable by external pull requests and use GitHub-hosted runners for that part of CI instead.
Mistake 2: Omitting the permissions block and relying on the default token scope.
Without an explicit permissions: key, the GITHUB_TOKEN for a job can default to broad read/write access across the repository, depending on the repository’s settings. On a self-hosted runner, that token is exposed to whatever code the job executes on a machine that may also have other credentials nearby. Fix: add a minimal, explicit permissions block to the workflow or job, such as contents: read, and add write scopes only for the specific permission a step needs, such as id-token: write for OIDC.
Mistake 3: Reusing a persistent runner across jobs without cleaning its state.
A long-lived self-hosted runner keeps its file system between jobs by default. Build artifacts, cached credentials, or environment variables set by one job can leak into the next job that happens to land on the same machine — including jobs from different workflows or, worse, different repositories sharing a runner group. Fix: run the runner with the --ephemeral flag so it processes exactly one job and then deregisters, or run jobs inside containers on the runner so each job starts from a clean, pinned image rather than the host’s accumulated state.
Best Practices
- Prefer GitHub-hosted runners by default; reach for self-hosted only when you need network access, hardware, or licensing a hosted runner can’t provide.
- Never point a self-hosted runner at a public repository’s default
pull_requesttrigger. Treat fork pull requests as untrusted code until a maintainer has reviewed them. - Use runner groups to restrict which repositories and workflows can even see a given runner — this is a stronger boundary than labels.
- Run runners as ephemeral, single-job instances where possible, or inside containers, so state never persists between untrusted or unrelated jobs.
- Set explicit, minimal
permissions:on every job, especially ones running on self-hosted infrastructure with broader network reach. - Use OIDC (
id-token: write) to obtain short-lived cloud credentials instead of storing long-lived cloud keys as repository secrets. - Prefer image digests over mutable tags when a self-hosted runner pulls container images for a job — a tag can be repointed to different content later, while a digest always resolves to the exact bytes you tested.
- Gate any deploy job behind a protected environment with required reviewers, and always follow deployment with an automated health check so a failed rollout is caught immediately rather than discovered by users.
- Patch and monitor self-hosted machines like any other server with network access and credentials — they are part of your production attack surface, not a disposable CI detail.
Practice Exercises
- Write a workflow with two jobs: one running on
ubuntu-latestthat lints and tests code, and a second job that only runs on[self-hosted, linux]after the first succeeds, gated behind astagingenvironment. Explain in comments why the split between hosted and self-hosted makes sense here. - Take the unsafe example from Mistake 1 and rewrite it so a self-hosted GPU runner only executes for pull requests from the same repository, after a maintainer applies a label. Identify what specific risk your change removes.
- Design (in words, no real credentials) the runner group layout for an organization with three repositories: one public open-source project, one internal tool, and one that deploys to production. Decide which repositories should be allowed to use self-hosted runners at all, and why.
Summary
Self-hosted runners trade GitHub’s isolation and disposability for access to your own network, hardware, and scale — and that trade means the runner inherits real privileges that a workflow author can misuse, intentionally or not. Reach for one only when a hosted runner genuinely can’t do the job, scope it with runner groups and minimal permissions, keep it ephemeral or containerized, and never let untrusted fork pull requests reach it unreviewed. Combined with protected environments, health checks after deploy, and short-lived OIDC credentials instead of static secrets, a self-hosted runner can be a safe and powerful part of your pipeline rather than an open door into your infrastructure.
