The pipeline describes the work; the runner does it
A workflow file is a set of instructions; a runner is the machine that carries them out. GitHub-hosted runners are temporary machines maintained by GitHub; a job requests one with a label such as ubuntu-latest. A runner executes one job at a time. If no matching runner is available, the job waits in a queue. Self-hosted runners are machines your team operates and connects to GitHub.
GitHub-hosted runners are requested with runs-on, for example ubuntu-latest. Teams can group self-hosted runners and control which repositories may use them. You usually request a runner by its labels rather than naming a machine. GitHub schedules the job on an eligible available runner.
Someone runs the machine. Decide who.
- Runner
- A machine that executes one GitHub Actions job. GitHub-hosted runners are managed by GitHub; self-hosted runners are machines you connect, patch, and secure. Use runner groups and labels to control which jobs can use self-hosted machines.
GitHub-hosted or self-hosted — who owns the machine
There are two kinds of runner, and the whole of this part turns on the difference between them, because it is really a question about who is responsible for the machine. A GitHub-hosted runner is a machine Microsoft owns, patches, and hands you fresh. Each time your pipeline runs, you get a brand-new virtual machine for each job; the job runs; and the machine is discarded the moment the job finishes. Anything the job wrote to disk — the checked-out code, a cached dependency, a leaked secret — is gone with it, because the next job starts on a different, clean VM. You never log in, never patch it, never see it. Maintenance and image upgrades happen automatically, and you always get the latest version of the image you named.1
A self-hosted runner is the opposite trade. It is a machine you provide — a VM in your subscription, a box under a desk, a container you run — on which you install the runner software yourself. It persists between runs, which is both its point and its price: caches and installed tools carry over from job to job, so builds can be faster, but nothing gets patched, cleaned, or hardened unless you do it. The machine is now yours to own, forever, with everything that word implies — operating-system updates, disk space, the tools your pipelines assume are present, and the security of a box that, by design, runs whatever your pipelines tell it to.
Hold the two side by side before you choose. The table below is the honest ledger of what you are trading, and the column that quietly matters most is the last one.
| GitHub-hosted | Self-hosted | |
|---|---|---|
| Maintenance & patching | GitHub's; maintained images, temporary runner per job, nothing for you to update | Yours; OS updates, runner version, disk, and installed tools are all your standing chore |
| Network reach | Runs on public Microsoft infrastructure; reaches private resources only through extra plumbing you build | Sits inside your own network, so it can reach private endpoints and on-prem systems directly |
| Custom / licensed software | The image is what it is; anything extra is installed per run, costing minutes every time | Install once and it persists — the right home for large toolchains and licensed software |
| Cost model | Usage and concurrency follow your GitHub plan; no machines to patch | No per-minute charge for the runner, but you pay to run, store, and secure the machine yourself |
| Security burden | Clean per-job VM discarded after use; blast radius resets every run | A persistent box that runs arbitrary pipeline code — patch, isolate, and least-privilege it like any exposed host |
Read the table as a default and an exception. The default is GitHub-hosted: a clean machine, maintained by someone else, with the blast radius reset after every job, is the correct choice for almost every workload almost all of the time. The exception is self-hosted, and it earns its keep only when one of a short list of real needs applies — which is the subject of §4.
Labels, runner groups, and concurrency limits
Self-hosted GitHub runners use labels and runner groups. Add labels that truthfully describe the machine, then request the required labels with runs-on. A runner group controls which repositories and workflows may use its machines. Keep the runner image patched and isolated, and do not give untrusted pull-request code access to a machine that can reach private networks.
Self-hosted runners use labels rather than automatic capability discovery. Describe a runner honestly with labels such as self-hosted, linux, or a carefully governed custom label. The workflow asks for the labels it needs. Runner groups control access by repository and workflow; labels help GitHub choose an eligible machine.
GitHub Actions usage and concurrent-job limits depend on repository visibility, account plan, organisation policy, and runner type. Jobs may wait when usage or concurrency is exhausted, or when every matching self-hosted runner is busy. Check the repository's Actions billing page and the current plan documentation rather than relying on a fixed allowance from an older tutorial.
Measure queue time and job duration before changing runner capacity. GitHub-hosted larger runners and self-hosted runners have different billing and administration trade-offs. Extra concurrency can shorten a queue while increasing cost or exhausting shared services.
Play it through
Give the private database deployment a restricted runner path. Then adjust the simulated slot count and see the illustrative queue change. This game uses a training wait model, not GitHub pricing or measured platform limits. DNS, routing and database permission still need verification.
When to self-host — the honest short list
Self-hosting a runner is taking on a maintenance liability, so the bar for doing it should be a genuine need, not a habit or a hunch that it will be "cheaper" or "more control." There is a short list of reasons that actually clear the bar. Learn it as a checklist, because in an interview and on the job the valuable move is not "I can set up a self-hosted runner" — anyone can — it is knowing the four or five cases where you should, and defaulting to hosted everywhere else.
- Private-network reach
- The deploy target has no public endpoint — a database behind a private link, an on-prem system, a resource reachable only from inside your VNet. A self-hosted runner that lives in that network can reach it; a GitHub-hosted one cannot without extra plumbing. This is the most common legitimate reason by far.
- Specialised or licensed software
- Your build needs a large toolchain, a specific SDK, or licensed software that is expensive or slow to install fresh every run. Install it once on a machine you keep, and every job starts with it already present.
- Large caches or artifacts
- Builds that lean on a big dependency cache or a heavy working set benefit from a machine whose disk persists between runs, rather than re-downloading gigabytes onto a clean VM each time.
- Specific hardware
- The job needs more CPU, memory, GPU, or a particular architecture than the hosted images offer. You control the machine's size and shape when it is yours.
Notice what is not on the list: "it feels cheaper," "we want control," "we already have a spare VM." Those are how estates accumulate unpatched, forgotten runners. And note the sting in the tail of every reason above: a self-hosted runner runs whatever your pipelines tell it to, on a machine that persists, often with a line into your private network — which makes it exactly the kind of standing, powerful host you learned to distrust with self-hosted GitHub runners back in Class Twenty-Four. The instinct is identical: patch it, isolate it, scope its permissions to the least it needs, and never let a long-lived runner hold more access than the job in front of it requires. A self-hosted runner is a tool you reach for deliberately, keep short, and secure like the liability it is.
The 2019 runner nobody had logged into
Settling where Basecamp's pipelines run turns up a machine everyone had forgotten: a self-hosted runner VM stood up in 2019, still registered, still picking up the warehouse deploy, and — the reader confirms with a wince — not patched since roughly the year it was built. It sits inside Basecamp's own network, which is precisely why it was ever created: the warehouse system it deploys to has no public endpoint, so a GitHub-hosted runner genuinely cannot reach it. The private-network reason is real; the 2019 machine is not the way to honour it.
So the reader splits the decision. The warehouse deploy keeps a self-hosted runner, because the need is legitimate — but the old VM is retired and replaced with a fresh one: current OS, latest runner, locked into an isolated subnet with a network path to the warehouse and nothing else, and a least-privilege identity that can deploy to that one target and go no further. Every other pipeline in the acquired estate — the storefront builds, the internal tooling, the reporting jobs, none of which needs private reach — is moved onto GitHub-hosted runners, so their machines are Microsoft's problem and their blast radius resets every run. One self-hosted runner, kept for one honest reason and secured like the exposed host it is; everything else handed back to the fleet. The subscription-wide azure-prod secret is noted and left alone — it is a credential problem, and its reckoning waits for 39i.
"Why is the build queued for twenty minutes?"
A team lead pings you: builds are crawling, everything sits "queued." You check GitHub Actions usage and concurrency details: available jobs are busy, and additional runs are waiting. First confirm whether the queue comes from account limits, a workflow concurrency rule, or a busy self-hosted runner group. Then choose a fix that matches the cause: adjust a concurrency rule, improve or split a slow job, add eligible runner capacity, or review the plan. What you do not do is quietly stand up a self-hosted runner to "get around the limit" — that trades a visible cost for an invisible, unpatched liability, and it is the exact instinct §4 is warning you against. The seniority is diagnosing the constraint out loud, not smuggling in a machine nobody will remember to maintain.
Why this part is worth more than it looks
Runners feel like plumbing — the boring layer beneath the pipeline you actually wrote — and that is exactly why understanding them marks you out. Most engineers can write a YAML pipeline; far fewer can explain, calmly, why the build is queued, how a runner group controls access, or why the innocent-looking self-hosted runner someone added last year is now the softest target in the estate. Those are the questions a senior asks in an interview precisely because they separate people who copied a runs-on: line from a sample from people who know what it points at.
Carry three sentences out of here and you can hold that conversation. Hosted is the default: a clean machine you do not maintain, discarded after every job. Self-hosted is a deliberate exception, taken only for private-network reach, specialised software, big caches, or specific hardware — and secured like the exposed, long-lived host it is. And concurrent jobs are the concurrency you buy or enable, the reason a queue forms and the honest lever when it does. Know those, and "where does the work actually run?" stops being a gap and starts being a thing you can draw.
Read the runner evidence
The queue numbers below are a model, not a measurement of real runners. Use the fictional evidence below. This is a desk exercise; it does not provision Azure resources or measure a live service.
Job A: GitHub-hosted runner; private DB name resolves; connection times out Job B: restricted self-hosted runner group; private route present; login denied Neither observation proves a successful deploy
Identify which failure is network-related and which has reached authentication.
Choose the runner group allowed to run this deployment; keep untrusted jobs out.
Record effective route, DNS and permission evidence before rerunning.
Check your reasoning after you finish
A valid DNS answer does not create a route. Job B reached a service that rejected login, so moving its runner again is not the first fix. Restrict runner-group access and check credentials and database permissions.
Examination
Four drills, then two situations. Write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored.
B — the fresh-VM-per-job model is the whole point of GitHub-hosted. Each job gets a brand-new virtual machine, and that machine is discarded the moment the job finishes, so anything written to its file system — a cache, checked-out code, a leaked token — is gone before the next job starts on a different clean VM. This is a feature, not a limitation: it is why the blast radius resets every run and why you never patch these machines. A describes a self-hosted runner, whose persistence is exactly what makes caches carry over — and exactly what makes it a standing security liability. C invents a maintenance step you never perform on hosted runners; Microsoft patches them. D invents an OS split that does not exist. If you need the cache to survive, you use the pipeline's caching feature or a self-hosted runner — you do not assume the disk is still there.
B — private-network reach is the single most common legitimate reason to self-host. A GitHub-hosted runner runs on public Microsoft infrastructure and cannot reach a resource with no public endpoint without extra plumbing; a self-hosted runner living inside your VNet reaches it directly, which is precisely why the need justifies the maintenance cost here. A is the right default in general but wrong for this specific requirement — the default yields to a genuine need. C is the dangerous half-truth: self-hosted runners are not inherently more secure — they are a persistent host running arbitrary pipeline code, so they carry more security burden, not less; you pick one here despite that, and then patch and isolate it. D is simply false: network location is the entire point. The discipline is choosing self-hosted for the real reason and then treating the machine like the exposed host it is.
GitHub schedules jobs on available runners; self-hosted runner groups and labels control access and matching; runner limits depend on account plan and settings. Those three are the load-bearing facts. The two rejects are the confusions that cost new organisations an afternoon. GitHub-hosted usage and concurrency depend on plan, repository visibility, and account settings. Check GitHub Actions billing and current limits before diagnosing a queued job. Each runner executes one job at a time; multiple eligible runners can run separate jobs concurrently.
# runner plan — new product team
1. Default all builds to GitHub-hosted runners;
let Microsoft patch and discard the machines.
2. We have a leftover VM spare, so let's register it
as a self-hosted runner to save a bit of money —
no particular need, it's just sitting there.
3. The one deploy that hits an on-prem system keeps a
self-hosted runner, patched and in an isolated subnet.
4. Check the repository visibility, GitHub plan, and
Actions billing/concurrency settings before scaling.
Line two — "no particular need, it's just sitting there" is exactly the reason that is not on the list. A self-hosted runner is a persistent machine that runs arbitrary pipeline code; standing one up without a genuine need buys you an unpatched, forgotten host — the softest target in the estate — to save an amount of money that rounds to nothing. That is how the 2019 runner in the case file came to exist. The other lines are the part done right: hosted as the default because the machines are Microsoft's problem and reset every run (A inverts it — self-hosting everything multiplies the liability); a self-hosted runner kept for the honest reason of private-network reach and then patched and isolated (C misreads the one legitimate case); and checking the repository plan, visibility, and Actions limits before scaling (D is sound). The rule: self-host for a named need on the short list, or not at all.
The trap is that "speed and control" sound like reasons but are not on the list. The honest short list for self-hosting is private-network reach, specialised or licensed software, large persistent caches, or specific hardware. "Feels slow" and "we'd own it" are none of those — and owning the machine is the cost, not the benefit. Name that first: you are not being offered a faster build, you are being offered a maintenance liability with a vague upside.
Price both sides concretely. Move to self-hosted and you take on OS patching, runner upgrades, disk management, and — the part that bites later — the security of a persistent box that runs arbitrary pipeline code, which now needs to be patched, isolated, and least-privileged like any exposed host. Against that, the "speed" is usually solvable without any of it: if a slow build is the real complaint, the cause is a slow stage or a queue, not the fleet — and the fix is caching, splitting the job, or buying another concurrent job, all of which keep the machine Microsoft's problem.
Redirect to the real question. Ask what "slow" actually means and check Actions usage, queue time, and concurrency. If builds queue, check concurrency limits, usage, and matching runners before deciding whether to buy capacity. If one build is slow, profile and cache it. Agree to self-host only if a genuine need from the list appears; until then, the default stands. You have honoured the goal — faster, smoother builds — while refusing to pay for it with an unpatched runner nobody will remember exists in a year.
The move is to split one decision into two. "It works, leave it" collapses two separate questions: should this deploy use a self-hosted runner? and is this particular 2019 machine acceptable? The first answer is yes — the warehouse system has no public endpoint, so private-network reach is a real, listed reason, and a GitHub-hosted runner genuinely cannot do the job. Concede that plainly so the colleague knows you are not on a hosted-everything crusade.
Then reject the machine, not the approach. An unpatched host that has run arbitrary pipeline code inside your network since 2019, with a line to a production system, is exactly the standing liability this part warns about — the same suspicion you learned to apply to self-hosted runners in Class Twenty-Four. "It works" describes function, not safety; the machine can work perfectly and still be the softest way into the warehouse. The need is legitimate; this instance of meeting it is not.
Replace, do not remove. Stand up a fresh runner — current OS, latest runner software, an isolated subnet with a path to the warehouse and nothing else, and a least-privilege identity scoped to that one deploy. Cut the old VM over and retire it. The warehouse keeps its private reach; the estate loses an unpatched host it had forgotten it owned. Framed for the colleague: we are not changing how the warehouse deploys — we are changing which machine is trusted to do it, because "it works" was never the same as "it is safe."
Five things worth carrying out of this part
- A runner is the machine that runs a job — one job at a time; runner groups control access to self-hosted runners, so a free one picks it up. Your runs-on: ubuntu-latest pointed at GitHub-hosted runner fleet.
- GitHub-hosted runners are a fresh, clean VM per job, discarded after, patched and maintained by Microsoft — no state survives. Self-hosted runners are machines you own, run, and patch, whose persistence is both the benefit and the liability.
- Hosted is the default. Self-host only for a named need: private-network reach, specialised or licensed software, large persistent caches, or specific hardware — and secure the machine like the exposed, long-lived host it is.
- Capabilities and demands match jobs to self-hosted runners; with hosted runners you pick an image instead. A runner runs one job at a time — concurrency is a matter of concurrent jobs and more runners.
- Runner usage and concurrent-job limits depend on GitHub plan, repository visibility, and settings; verify the current account limits in GitHub.
- Runner availability, included minutes, concurrency, and larger-runner options depend on the current GitHub plan and repository settings. Confirm the account's limits in GitHub billing and runner documentation before estimating capacity.
- GitHub runner minutes and concurrency depend on plan, repository visibility, and account settings. Confirm current limits on GitHub documentation and the organization billing page; plan and repository visibility affect the available usage. ↩
Choose a suitable GitHub runner path and explain labels, runner groups, private reachability and concurrency limits.
Play the lesson above to stamp this class.