Self Hosted Runners
Every job this course has run so far has executed on a machine GitHub owns, provisions, and throws away after the job finishes. That’s convenient, but it’s not the only option — GitHub Actions can just as easily run your jobs on a machine you control. This chapter stands one up, runs a real job on it, and then spends just as much time on why you’d think twice before doing this for real.
Warning
This chapter provisions a real EC2 instance that costs money while it’s running. Don’t reuse the EC2 instance from the earlier plain-EC2 deployment chapter — that one serves real traffic, and mixing “serves real traffic” with “runs untrusted CI jobs” on the same box is bad practice worth avoiding even in a demo. Stand up a fresh, minimal instance dedicated solely to this exercise, and follow the teardown steps at the end so it doesn’t keep running after you’re done.
What A Self-hosted Runner Actually is
A GitHub-hosted runner is a fresh VM GitHub spins up for your job and destroys afterward. A self-hosted runner is just a persistent machine — yours, running anywhere with outbound internet access — with GitHub’s runner agent software installed on it, registered against a specific repository (or organization). Once registered, it sits there listening for jobs, and any workflow that targets it with runs-on: executes directly on that machine instead of a GitHub-provisioned one.
Provisioning A Dedicated Instance
Launch a small EC2 instance whose only job is being a runner — nothing else runs on it. A t3.micro running Ubuntu is plenty for this exercise:
- In the EC2 console, launch a new instance.
- Choose an Ubuntu Server LTS AMI.
- Instance type:
t3.micro. - Create or reuse a key pair you can SSH in with.
- Security group: allow inbound SSH (port 22) from your own IP only — this runner doesn’t need to accept any other inbound traffic, since it initiates its own outbound connection to GitHub rather than receiving anything from it.
- Launch, and note the instance’s public IP once it’s running.
SSH in once it’s up:
ssh -i /path/to/your-key.pem ubuntu@<instance-public-ip>Registering The Runner
In the repository, go to Settings → Actions → Runners → New self-hosted runner. Select Linux and x64. GitHub generates a set of commands with a short-lived registration token baked in — copy those exactly rather than typing them from memory, since the token is single-use and time-limited, and the runner package version GitHub points you at may be newer than any version written down here.
The shape of what you’ll run, on the EC2 instance, looks like this:
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz
tar xzf ./actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/<owner>/<repo> --token <token-from-github-ui>config.sh asks a few interactive questions — accept the defaults for runner group and work folder, and give it a name you’ll recognize later, like devstuffs-ci-runner. When it finishes, it also asks whether to add any labels; the default self-hosted label is enough for this exercise.
Rather than running the agent in the foreground (which stops the moment you close your SSH session), install it as a systemd service so it keeps listening in the background:
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh statusBack in Settings → Actions → Runners, you should now see the runner listed with a green Idle status — confirmation that it’s connected and waiting for work.
Running A Job On It
Any existing workflow works for this — the point is just to see a job land on your machine instead of GitHub’s. Add a small throwaway workflow, or temporarily change runs-on: on an existing simple one:
name: Self-Hosted Runner Check
on:
workflow_dispatch:
jobs:
check:
runs-on: self-hosted
steps:
- name: Prove it
run: |
echo "Running on: $(hostname)"
echo "As user: $(whoami)"
uname -aTrigger it manually (Actions tab → select the workflow → Run workflow), then watch the run. The log output will show the EC2 instance’s own hostname and OS details — proof the job actually executed there, not on a GitHub-provisioned VM.
Note
Don’t point the real ECS deploy workflow at this runner. It’s a throwaway instance for this exercise only, and the deploy pipeline should keep running on GitHub-hosted runners unless you have a deliberate, specific reason to change that — which is exactly what the rest of this chapter is about.
The Tradeoffs
| Scope | GitHub-hosted runners | Self-hosted runners |
|---|---|---|
| Setup | Zero — available immediately | You provision, patch, and maintain the machine |
| Hardware | Fixed set of standard (and paid larger) configurations | Anything you can run: specific GPUs, more RAM, custom architectures |
| Software | Fresh, clean environment every run | Whatever you install — can pre-cache large dependencies, but also accumulates state between runs unless you deliberately reset it |
| Network access | Public internet only | Can reach private networks, internal services, on-prem resources |
| Cost model | Billed per minute (with free monthly minutes on most plans) | You pay for the machine directly, plus — as of March 2026 — a small per-minute GitHub Actions platform charge on private repositories |
| Security posture | Ephemeral and isolated by default | Persistent machine, your responsibility to secure and isolate |
The Security Implication That Matters Most Here
GitHub’s own documentation is blunt about this one: self-hosted runners are recommended for private repositories only, because a pull request from a fork of a public repository can execute arbitrary code on your self-hosted runner machine. This ties directly back to the earlier chapter on fork PR dangers — a workflow triggered by pull_request (as opposed to pull_request_target) already runs with the incoming PR’s code, and on a GitHub-hosted runner that’s relatively low-stakes, since the runner is thrown away the moment the job ends. On a self-hosted runner, that same untrusted code is now executing on a machine that persists, that you provisioned, and that might have network access to things you actually care about protecting.
GitHub backs this up structurally, not just with a warning: public repositories can’t use runners in a runner group by default, and you have to deliberately override that setting to allow it. Treat that default as a hint, not an obstacle to route around. If you ever do need a self-hosted runner reachable from a public repository’s workflows, scope it tightly — no network access beyond what the job strictly needs, ephemeral rather than persistent where possible, and never assigned to run workflows triggered by PRs from forks you don’t control.
When Self-hosting is Actually Worth It
Given the added operational burden, self-hosting only pays off in a few specific situations:
- Hardware GitHub-hosted runners don’t offer — a specific GPU for a build or test step, an architecture GitHub doesn’t provide, or simply more CPU/RAM than the standard tiers give you without paying for a larger hosted runner.
- Network-adjacent workloads — jobs that need to reach something on a private network GitHub’s runners can’t route to, without standing up a separate connectivity bridge.
- Steady, heavy, predictable usage — if you’re consistently burning far more minutes than your plan’s free allowance covers, the economics can tip toward owning the hardware outright, especially for workloads that keep a runner busy near-continuously rather than idling most of the time.
For a low-to-moderate-traffic pipeline like the ECS deploy workflow in this course, none of those apply — it’s exactly the kind of job GitHub-hosted runners are built for, and self-hosting it would trade a small amount of setup and no maintenance for ongoing patching, security responsibility, and a machine that costs money whether or not it’s doing anything.
Tearing It Down
Deregister the runner first, from the EC2 instance itself, so GitHub stops listing it as available:
cd ~/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh uninstall
./config.sh remove --token <a-fresh-removal-token-from-the-github-ui>Settings → Actions → Runners should no longer list it once that finishes. If the instance is unreachable and you can’t run the removal command, you can also remove a runner directly from the GitHub UI — open the runner’s entry under Settings → Actions → Runners and use the remove option there.
Then terminate the instance itself from the EC2 console (or aws ec2 terminate-instances --instance-ids <instance-id>), so it stops costing anything.
Warning
A stopped-but-not-terminated EC2 instance still incurs EBS storage charges, and a registered-but-offline runner will just show as unreachable in GitHub without costing you anything on the GitHub side — but the AWS bill doesn’t care whether the runner agent is running, only whether the instance exists. Terminate it; don’t just stop it.