</>CodeWithKarani

What a runner actually is: GitHub-hosted vs self-hosted

Karani GeoffreyKarani Geoffrey9 min read

The phrase "self-hosted runner" is badly named and it costs people a week. It sounds like you are hosting GitHub, or running a copy of GitHub Actions, or standing up some piece of infrastructure that GitHub then talks to. Every one of those readings is wrong, and each one leads you to ask your client for a public IP address, a DNS record or an inbound firewall rule that you do not need.

Here is the sentence that fixes it, and it is worth reading twice:

A runner is a small program that repeatedly asks GitHub whether there is any work for it, and executes whatever it is given, on whatever machine it happens to be installed on.

That is all. It is a client, not a server. It initiates, it is never contacted. And the moment that is true for you, deploying to a client's server that you cannot reach, that has no public address and that sits behind a corporate firewall stops looking like a trick and starts looking like the obvious arrangement. It is the mechanism behind deploying Frappe apps to a client server you are not allowed to touch, and it is the one idea in this series you cannot skip.

a runner is a polling client. GitHub-hosted runners are throwaway virtual machines in Microsoft's cloud. A self-hosted runner is the same agent binary running on your own box, and because it dials out, no inbound connection to that box is ever required.

  • Outbound HTTPS on 443 to github.com is the only network requirement. No public IP, no port forward, no VPN.
  • runs-on: is how a job chooses a runner, by label. That single key decides which machine your steps execute on.
  • Register with config.sh, install as a service with svc.sh, and run it as the frappe user, never as root.
  • The cost: the machine is persistent, not clean. State from one job survives into the next, and push access to the repository becomes command execution on that box.

The direction of the arrow is the whole thing

Draw the two models next to each other and the argument makes itself.

Connection direction under a push deployment and a self-hosted runner Two panels with identical layout. In the left panel, labelled push deployment, an arrow travels downward from GitHub through the client firewall into the client server, and the firewall is drawn with a gap marked as an inbound hole. In the right panel, labelled self-hosted runner, an arrow travels upward from the client server out through an unbroken firewall to GitHub, because outbound connections are already permitted. The verdict strips record that the first requires the client to open a port and the second requires nothing. Push deployment: GitHub reaches in Self-hosted runner: the server reaches out GitHub-hosted runner An ephemeral VM in Microsoft's cloud github.com Holds the job queue, waits to be asked Inbound hole opened here Client firewall Unbroken. Outbound is already allowed. Client firewall dev.client.local Now reachable from the internet on port 22 dev.client.local, runner installed Still unreachable from the internet. Nothing changed. What the client must agree to A firewall change, a public address or a bastion, and a credential held by a third party's CI system. What the client must agree to Installing one service, which they own, which they can stop at any moment without asking you.
Identical layout, one difference: which end starts the conversation. Every other property of the two models follows from that arrow.

The second panel is not a workaround. It is the normal way the runner is designed to work, and it is how every self-hosted runner on the internet operates. Nothing is being tunnelled, nothing is being reversed, no clever proxy is involved. The runner simply behaves like every other program on that server that talks to the internet - like apt, like pip, like the browser on the receptionist's desk.

GitHub-hosted runners: rented and thrown away

When you write runs-on: ubuntu-latest, GitHub provisions a fresh virtual machine, runs your job on it, and destroys it. You get a clean filesystem every time, a large pre-installed toolchain, and no state whatsoever from the previous run. For testing, this is close to ideal and I use it for every test job I write.

Its limitation is exactly its strength: it is somewhere else. That VM lives in Microsoft's cloud with an IP address you do not control and cannot predict. For it to touch your client's server, the client's server has to be reachable from that cloud - which means a public address, an inbound rule, and a permanent hole that exists for the twenty minutes a month you deploy and the rest of the month besides.

Self-hosted runners: the same binary, your machine

A self-hosted runner is not a different product. It is the same agent, distributed as a tarball, that you unpack on any machine you like. That machine can be a laptop, a Hetzner box, a Raspberry Pi under a desk in Industrial Area, or your client's dev server. The agent does not know or care. It authenticates to GitHub with credentials created during registration, and then it polls.

The network requirement is one line: outbound HTTPS to GitHub on port 443. That is a rule almost every corporate firewall already permits, because without it nobody could install packages or read documentation. If the client's network runs an outbound allowlist rather than blanket egress, they will need to permit github.com and its supporting hostnames, and GitHub publishes those ranges through its meta API so their network team can be precise about it.

Firewall posture of the client server under each deployment model Two columns comparing the firewall rules required on the client server. Under the push model the inbound list gains SSH on port 22 reachable from outside the network, marked as risk. Under the self-hosted runner model the inbound list is unchanged from what the application already needed, and only one outbound rule is used, HTTPS to GitHub, which was already permitted. Push model, SSH deployment Pull model, self-hosted runner Inbound rules required on dev.client.local Inbound rules required on dev.client.local 443/tcp from staff, for the application itself 443/tcp from staff, for the application itself 22/tcp from the CI provider, or from anywhere Nothing. The list is unchanged. Outbound rules used Outbound rules used 443/tcp to package mirrors, as before 443/tcp to package mirrors, as before none added 443/tcp to github.com, usually already allowed The question a security team actually asks Not "do you use CI". It is "what new way in does this create". In the right-hand column the honest answer is none.
The same posture diagram appears again when we weigh SSH deployment and the pull model. Learn to read it once.

How a job actually gets picked up

People imagine GitHub sending the job to the runner. It does not. The runner asks, and GitHub holds the question open until it has an answer - a long poll, the same technique chat applications used before websockets.

Sequence of a self-hosted runner picking up a job A sequence diagram with two lifelines. The runner on the client server opens an outbound HTTPS long poll to GitHub asking for work. GitHub holds the connection open and answers that there is nothing, and the runner asks again. A tag is then pushed to the repository. The next poll is answered with a job assignment. The runner executes the workflow steps locally as the frappe user. Logs and status are streamed back over the same outbound connection. Runner on dev.client.local github.com holds the job queue 1. Outbound HTTPS on 443: is there any work for me? 2. Nothing yet. Connection held open, then closed. Ask again. 3. Tag v1.4.0 pushed 4. The next poll is answered with a job assignment 5. Runs the steps locally, as frappe 6. Logs and exit status streamed back on the same outbound connection Every arrow that carries data starts at the runner. GitHub never initiates anything, because it cannot.
Step 5 is where your deploy script runs, on the client's machine, with no connection ever having been made into it.

One property falls out of this that people find counterintuitive at first: if the runner is offline when you push a tag, nothing fails. The job queues. When the machine comes back the runner polls, finds the job waiting, and runs it. That is usually what you want, and occasionally it is a surprise at 3am when a server that has been off for a fortnight comes back and immediately deploys a release from two weeks ago.

Registering one

Registration happens on the client's machine, as the frappe user, using a token the client generates in the repository's settings under Actions, Runners, New self-hosted runner. That token is short-lived - it expires in an hour - so it is safe to send them a set of instructions and let them fetch the token themselves. This matters: it means the client can complete the install without you ever holding a credential.

The commands GitHub shows on that page are generated for the current runner version. Take the version from there rather than from any article, including this one. The shape is:

sudo -u frappe -i
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.328.0/actions-runner-linux-x64-2.328.0.tar.gz
tar xzf ./actions-runner-linux-x64.tar.gz

Then register it, unattended, so the whole thing can be pasted in one go:

./config.sh \
  --url https://github.com/your-org/your_app \
  --token AXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
  --name dev-client-local \
  --labels frappe,dev-client-local \
  --unattended --replace

Two of those flags earn their place. --unattended suppresses the interactive prompts, which is what makes this pasteable by someone who is not you. --replace means re-running it after a rebuild takes over the existing registration instead of failing with a name clash.

Note what --labels does and does not do. It adds to the defaults, which are always self-hosted, Linux and X64. So this runner answers to self-hosted, Linux, X64, frappe and dev-client-local. And runs-on with a list means all of these labels, not any of them:

    runs-on: [self-hosted, dev-client-local]

That job will only ever run on that one machine. When you have runners for three clients, this is what keeps client A's deployment off client B's server, and it is worth being specific rather than relying on self-hosted alone.

Running it as a service, as the right user

Started by hand with ./run.sh, the runner dies when the SSH session closes. Install it as a systemd service instead. The script needs root to write the unit file, and takes the account it should run as as an argument:

cd /home/frappe/actions-runner
sudo ./svc.sh install frappe
sudo ./svc.sh start
sudo ./svc.sh status

What svc.sh writes is an ordinary systemd unit, so if the runner keeps dying and you want to read the file rather than guess, a systemd unit file that actually keeps a service running explains every directive in it.

That writes a unit into /etc/systemd/system/ named after the repository and runner, so it starts on boot and restarts if it crashes. Check it landed:

systemctl list-units 'actions.runner.*' --all

The user argument is not optional in spirit. If you omit it, the service runs as whoever invoked sudo, and on a lot of servers that is a person with sudo rights - which means your deploy script runs with those rights too. On a Frappe box the correct answer is always frappe, the account that owns the bench. A runner executing as root inside a bench directory will silently create root-owned files in sites/ and apps/, and you will spend a Saturday afternoon fixing ownership before anything works again. There is more on why in the anatomy of a Frappe bench.

What this costs you

Three things, and none of them are small.

The machine is persistent, not clean. GitHub's own documentation is blunt that self-hosted runners have no guarantee of running in ephemeral clean virtual machines. Files written by one job are still there for the next. A Python package installed for a test in March is still installed in July. This is occasionally convenient and frequently the reason a workflow passes on your runner and fails everywhere else. If a job needs a clean state, make the job create it.

You now own an agent that needs updating. The runner auto-updates itself by default, but the machine underneath it does not, and version floors are real. actions/checkout@v5 requires runner 2.327.1 or newer, and with Node 20 removed from Actions in September 2026 a runner nobody has touched since last year will start failing on jobs that used to work. Put it on someone's list.

Push access to the repository is now command execution on the client's server. The runner will faithfully execute whatever the workflow file says, and the workflow file lives in a repository you control. That is not a subtle risk and it is not hypothetical: GitHub warns that a self-hosted runner can be persistently compromised by untrusted code in a workflow, which is why they tell you never to attach one to a public repository. It is manageable, but it has to be managed deliberately. That is the subject of hardening a self-hosted runner, where push access is treated as the code execution path it is.

The gate that turns that risk from a property into a decision is an approval the client controls, which is what secrets, environments and required reviewers is about. And if you have not yet written a workflow file at all, the anatomy of a workflow, job and step is the place to start.

Frequently asked questions

Does a self-hosted runner need a public IP or an open inbound port?
No. The runner opens an outbound HTTPS connection to GitHub on port 443 and holds it open waiting for work. Nothing connects in. A server behind NAT with no port forwarding and no public address runs a self-hosted runner perfectly well, which is the entire reason this approach exists.
What happens to the runner if the server reboots or the internet drops?
If you installed it as a service with svc.sh, systemd starts it again on boot. If the connection drops mid-poll the runner reconnects on its own. If it is offline when you push a tag, the job sits queued and runs when the runner comes back, which is usually what you want and occasionally a surprise.
Can one runner serve several repositories?
A runner registered to a repository serves only that repository. Registering at organisation level lets many repositories use it, which is convenient and is exactly what you should not do on a client's server. Keep it registered to the single repository that deploys that client's app.
Is a self-hosted runner safe?
It is safe from the network direction problem and it introduces a different problem. The runner executes whatever the workflow file in your repository says, so anyone who can push to that repository can run commands on the machine. That is a real risk with real mitigations, and it needs its own discussion rather than a reassuring sentence here.
#GitHub Actions#CI/CD#DevOps#Deployment#systemd
Keep reading

Related articles