Setting Up a Self-Hosted GitHub Actions Runner on Linux

Most self-hosted runner tutorials install the package, register it, and stop there. They never actually push a workflow through it and watch the job land. That’s the part that matters, because a runner that’s “configured” and a runner that’s actually accepting jobs from GitHub are two different things, and the gap between them is where most of the real problems live.

This post registers a real runner against a real public repository, runs an actual workflow on it, and shows the job succeeding in GitHub’s own UI next to the matching log lines from the runner itself. Along the way I hit two things worth knowing before you do this yourself: a WSL reboot that knocked the runner offline and how it recovered on its own, and a genuine bug in GitHub’s own dependency script that breaks on AlmaLinux 10 outright.

Everything below ran on Ubuntu 24.04 LTS and AlmaLinux 10.2, under WSL2. The live registration and job run happened on the Ubuntu box, against a public repo on GitHub, with the runner de-registered and its credentials shredded the moment testing finished.

Why self-host a runner at all

GitHub’s hosted runners are fine until they aren’t: no GPU, a fixed and fairly small disk, no access to anything on your internal network (including a box you’d normally reach over SSH), and a per-minute bill on private repos once the free tier runs out. A self-hosted runner is just a machine you own with GitHub’s agent installed on it, listening for jobs. Same workflow YAML, same runs-on: syntax, the work happens on hardware you control instead of a container GitHub spins up and throws away.

That control is also the risk, and it’s worth saying upfront rather than burying it in a security section at the end: a self-hosted runner on a public repository is a genuine attack surface. Anyone can open a pull request against a public repo, and if your workflow runs on pull_request against a self-hosted runner, their code executes on your machine. GitHub’s own docs call this out directly. Fine for a private repo you control every contributor to, dangerous for anything public unless you’re deliberate about which events actually get to use the runner. More on that further down.

Create a dedicated, unprivileged user first

Don’t run the runner as yourself and don’t run it as root. It should be a system account that owns nothing else on the box:

$ sudo useradd --system --create-home --shell /bin/bash gh-runner
$ id gh-runner
uid=995(gh-runner) gid=988(gh-runner) groups=988(gh-runner)

--system gets you a UID below 1000 and a home directory with tight permissions by default, which is exactly what you want for an account whose entire job is executing whatever a workflow file tells it to. Everything from here on runs as this user, not as whoever set the box up.

Download, verify, extract

GitHub publishes the runner as a plain tarball, versioned independently of the OS. Grab the current version and its checksum from the releases page:

$ sudo -u gh-runner -H bash -c '
    mkdir -p ~/actions-runner && cd ~/actions-runner
    curl -o actions-runner-linux-x64-2.337.0.tar.gz -L 
      https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
'
...
100  215M  100  215M    0     0  2468k      0  0:01:29  0:01:29 --:--:-- 2479k

Don’t skip the checksum. This is a tarball that’s about to run with access to your repository and whatever secrets a workflow decides to print:

$ sha256sum actions-runner-linux-x64-2.337.0.tar.gz
70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613  actions-runner-linux-x64-2.337.0.tar.gz

That matched the value GitHub publishes on the release page for this exact file, byte for byte. Extract it and you’ve got the runner:

$ tar xzf actions-runner-linux-x64-2.337.0.tar.gz
$ ls
bin  config.sh  env.sh  externals  run-helper.cmd.template  run-helper.sh.template  run.sh  safe_sleep.sh

Dependencies: where Ubuntu and AlmaLinux actually diverge

The runner ships a script for this, and on Ubuntu it’s a non-event:

$ sudo ./bin/installdependencies.sh
...
libicu74 is already the newest version (74.2-1ubuntu3.1).
-----------------------------
 Finish Install Dependencies
-----------------------------

The E: Unable to locate package libicu76 and libicu75 lines above that in the real output aren’t failures, the script tries a few ICU version numbers until one matches what your Ubuntu release actually ships, and 24.04 lands on 74.

On AlmaLinux 10, the same script doesn’t degrade gracefully, it just doesn’t work:

$ sudo ./bin/installdependencies.sh
------------------------------
The current OS is Fedora based
--Fedora/RHEL/CentOS Version--
AlmaLinux release 10.2 (Lavender Lion)
------------------------------
Can not find 'yum'
Can't install dotnet core dependencies.

Here’s the actual cause, straight from the script:

$ sudo grep -n 'yum' bin/installdependencies.sh
150:    yum install -y lttng-ust openssl-libs krb5-libs zlib libicu

GitHub’s dependency installer hardcodes yum for every RHEL-family distro. AlmaLinux 9 still ships a yum shim that quietly calls dnf underneath, so it works there without anyone noticing. AlmaLinux 10 (like RHEL 10 and current Fedora) dropped that shim, there’s no yum binary at all, only dnf. The script has no fallback, so it just gives up. This isn’t a WSL quirk or something specific to this box, it’s the actual shell script that ships in the runner tarball, and I couldn’t find a single existing tutorial that tests this combination and mentions it. The fix is the exact package list from the script, installed with the tool that’s actually there:

$ sudo dnf install -y lttng-ust openssl-libs krb5-libs zlib libicu
...
Installed:
  libicu-74.2-5.el10_0.x86_64
  lttng-ust-2.13.7-5.el10.x86_64

openssl-libs, krb5-libs and zlib were already present on this minimal AlmaLinux 10 install; only libicu and lttng-ust were actually missing. Confirm the runner itself agrees before moving on:

$ sudo -u gh-runner -H bash -c 'cd ~/actions-runner && ./config.sh --version'
2.337.0

If that command prints a version number instead of a library-loading complaint, the runner’s .NET runtime is genuinely working and you’re clear to register.

Registering against the repository

From the target repo, Settings → Actions → Runners → New self-hosted runner gives you a --url and a short-lived --token, good for about an hour and good for registering exactly one runner:

$ sudo -u gh-runner -H bash -c '
    cd ~/actions-runner
    ./config.sh --unattended 
      --url https://github.com/openlinux01/terraform-iac-aws 
      --token AXXXXXXXXXXXXXXXXXXXXXXXXXXX 
      --name lpf-demo-runner 
      --labels lpf-demo 
      --work _work
'

# Runner Registration
√ Connected to GitHub
√ Runner successfully added
√ Settings Saved.

Three flags worth deliberately setting rather than accepting the defaults. --name because DESKTOP-3NICIT8 (the actual default here, pulled straight from the hostname) tells you nothing six months from now. --labels adds a custom tag on top of the automatic self-hosted, Linux, X64 ones, so a workflow can target this specific machine with runs-on: [self-hosted, lpf-demo] instead of any self-hosted runner that happens to be listening. --work sets where job files land; the default _work is fine unless you’re running multiple runners that need to stay out of each other’s way.

Registration writes two files, both worth protecting since together they authenticate this machine as a runner on your repo:

$ ls -la .runner .credentials
-rw-rw---- 1 gh-runner gh-runner 401 Mar 24 23:43 .runner
-rw-rw---- 1 gh-runner gh-runner 268 Mar 24 23:43 .credentials

Running it as a systemd service

The runner ships its own service installer, which writes a proper unit file rather than making you hand-craft one, and it takes the username to run as:

$ sudo ./svc.sh install gh-runner
Creating launch runner in /etc/systemd/system/actions.runner.openlinux01-terraform-iac-aws.lpf-demo-runner.service
Run as user: gh-runner
$ sudo ./svc.sh start
● actions.runner.openlinux01-terraform-iac-aws.lpf-demo-runner.service
     Active: active (running)

The unit name is auto-generated from the repo and runner name, which is why it’s long. journalctl confirms it’s actually talking to GitHub, not just running:

$ sudo journalctl -u 'actions.runner.*' --no-pager -n 10
runsvc.sh: √ Connected to GitHub
runsvc.sh: Current runner version: '2.337.0'
runsvc.sh: 2026-03-24 18:44:54Z: Listening for Jobs

“Listening for Jobs” is the line to watch for. Anything before that just means the process started; this is the point where it’s genuinely usable. The systemd service management guide covers what enable versus start actually does if either is unfamiliar, and it matters here specifically, because svc.sh install enables the unit, and that turned out to matter sooner than expected.

What happened when the machine restarted mid-test

While setting this up, the WSL VM itself rebooted, not the runner, the whole box. Here’s the journal spanning that gap, unedited:

runsvc.sh: 2026-03-24 18:44:54Z: Listening for Jobs
-- Boot 0d9c189bbe20435e9f34aafd75fb595e --
systemd: Started actions.runner....service
runsvc.sh: √ Connected to GitHub
runsvc.sh: Current runner version: '2.337.0'
runsvc.sh: 2026-03-24 19:11:14Z: Listening for Jobs

Because the service was enabled, it came back on its own with no intervention, and GitHub’s runner list, which had shown it as Offline during the gap, went back to Idle within a few seconds. That’s exactly the point of running this as a real service instead of someone’s tmux session, a runner that only survives until the next reboot isn’t infrastructure, it’s a liability waiting for a maintenance window.

It wasn’t completely clean, though. The very next reconnect attempt hit this:

runsvc.sh: √ Connected to GitHub
runsvc.sh: A session for this runner already exists.
runsvc.sh: Runner connect error: Error: Conflict. Retrying until reconnected.
...
runsvc.sh: √ Connected to GitHub
runsvc.sh: 2026-03-24 19:14:36Z: Runner reconnected.
runsvc.sh: 2026-03-24 19:14:36Z: Listening for Jobs

GitHub’s side hadn’t yet expired the previous session when the new process tried to claim it, a timing thing between the runner disappearing mid-connection and GitHub noticing. It resolved itself in under a minute without anyone touching anything, which is the behaviour you want, but if you see this exact “session already exists” message and it’s still retrying five minutes later instead of thirty seconds, that’s when it’s worth restarting the service by hand.

The real test: an actual workflow, actually running

A runner listening for jobs is not the same as a runner doing anything. Committed to the repo’s default branch:

name: Self-hosted runner test

on:
  workflow_dispatch:

jobs:
  proof:
    runs-on: [self-hosted, lpf-demo]
    steps:
      - name: Prove this ran on the self-hosted box
        run: |
          echo "host: $(hostname)"
          echo "kernel: $(uname -a)"
          echo "user: $(whoami)"
          echo "runner name: $RUNNER_NAME"

workflow_dispatch means it only runs when someone deliberately clicks the button, which matters on a public repo, it can never be triggered by a pull request from a stranger. Triggering it from the Actions tab, GitHub’s UI showed the run finish in 14 seconds with a green check. The runner’s own diagnostic log, on the machine itself, tells the same story from the other side:

$ sudo journalctl -u 'actions.runner.*' --no-pager -n 5
runsvc.sh: 2026-03-24 19:15:34Z: Running job: proof
runsvc.sh: 2026-03-24 19:15:42Z: Job proof completed with result: Succeeded

19:15:34 to 19:15:42, eight seconds, matching the job duration GitHub’s own UI reported. Two independent systems, the GitHub web UI and a log file on a machine on the other side of the world from GitHub’s servers, agreeing to the second. That’s the proof that actually matters, not that config.sh exited zero, but that a job GitHub dispatched really executed on this specific box and reported back.

Worth a look while you’re in there: the job leaves a real directory structure behind, this is where your checked-out code and any build artifacts actually live during a run:

$ find ~/actions-runner/_work -maxdepth 2
_work/terraform-iac-aws/terraform-iac-aws
_work/_PipelineMapping/openlinux01/terraform-iac-aws
_work/_temp
_work/_tool

The security conversation that actually matters

Back to the warning from the top, now with somewhere concrete to point it. This runner sat on a public repository for exactly as long as it took to prove the setup works, then came down. That’s deliberate, and it’s the model worth following if you’re doing this for real rather than a one-off test:

  • Never run self-hosted on pull_request for a public repo. That event runs with a read-only token by default specifically because it can be triggered by anyone, but the code in the PR still executes on your machine. workflow_dispatch, or restricting to push on protected branches only trusted people can write to, keeps strangers out of the equation entirely.
  • pull_request_target is worse, not safer. It runs with full repository secrets and permissions specifically so maintainers can do things like label PRs from forks, and combined with checking out the PR’s own code, that’s a well-documented way to leak secrets to anyone who opens a pull request.
  • Ephemeral runners for anything that isn’t a quick test. The --ephemeral flag on config.sh makes a runner de-register itself after exactly one job. No state, no leftover files, no environment variables from one build bleeding into the next. For real infrastructure this is the difference between “clean up after every job” and “hope nothing accumulates.”
  • The unprivileged user isn’t optional. If a workflow does something it shouldn’t, the blast radius is whatever gh-runner can touch, which should be close to nothing, rather than whatever your own account can touch.
  • Firewall the box’s outbound traffic if it’s sitting anywhere near production systems. A runner only needs to reach GitHub’s API and whatever package registries a build genuinely uses, not your database, not your other servers. The server hardening checklist has the sysctl and firewall groundwork this sits on top of, and fail2ban is worth having in front of SSH on any box you’re exposing like this.

Tearing it down

Stop the service, remove the unit, then de-register from GitHub’s side:

$ sudo ./svc.sh stop
$ sudo ./svc.sh uninstall
Removed "/etc/systemd/system/multi-user.target.wants/actions.runner....service".

./config.sh remove needs its own token too, a different one than the registration token, generated from the same GitHub page. Without one it just hangs waiting for interactive input:

$ ./config.sh remove
Enter runner remove token: Failed: Removing runner from the server
Cannot read keys when either application does not have a console...

With a fresh removal token it’s one line: ./config.sh remove --token <removal-token>. Easier in practice, and just as final: the “…” next to the runner’s name on the repo’s Runners page, then Remove. Either way, don’t leave the local credentials sitting around afterward:

$ shred -u .credentials .credentials_rsaparams .runner

shred -u overwrites the file before unlinking it, rather than just deleting the directory entry and leaving the actual bytes recoverable on disk, which matters slightly more for a file that authenticated as a runner on your repo than it does for most things.

Setup confirmation: every check, in one place

Here’s everything I checked on the test machines, in the order I checked it. If each line matches on your own box, your runner is done. If one doesn’t, the section above it is where to look.

Check Result
Runner binary works on Ubuntu 24.04 ✅ ./config.sh --version printed 2.337.0
Runner binary works on AlmaLinux 10 ✅ 2.337.0, after installing the dependencies with dnf
Registered with the repository ✅ Runner successfully added and Settings Saved
Installed as a systemd service ✅ enabled and active (running)
Connected to GitHub ✅ Connected to GitHub, then Listening for Jobs
Survives a reboot ✅ Came back on its own after a restart and reconnected
Runs a real workflow job ✅ Job proof completed with result: Succeeded
Clean teardown ✅ Service removed, and id gh-runner reports no such user

And here’s the proof itself, straight from the service journal on the Ubuntu machine. Watch the timestamps: the machine had just restarted, the runner reconnected without any help, and about a minute later it picked up a job and finished it.

$ sudo journalctl -u 'actions.runner.*' --no-pager -n 15
runsvc.sh: Starting Runner listener with startup type: service
runsvc.sh: Started running service
runsvc.sh: √ Connected to GitHub
runsvc.sh: 2026-03-24 19:14:36Z: Runner reconnected.
runsvc.sh: Current runner version: '2.337.0'
runsvc.sh: 2026-03-24 19:14:36Z: Listening for Jobs
runsvc.sh: 2026-03-24 19:15:34Z: Running job: proof
runsvc.sh: 2026-03-24 19:15:42Z: Job proof completed with result: Succeeded

Over on GitHub, the same run shows a green tick under the Actions tab, and the runner appears under Settings → Actions → Runners. If your runner shows Offline there, run sudo ./svc.sh status on the machine first. Nine times out of ten the service just isn’t running.

Frequently asked questions

Do I need Docker to use a self-hosted runner?

No. The runner itself is a plain binary; Docker only matters if your workflow uses container-based actions or a container: key in the job definition. If you do add Docker later, the runner user needs the docker group, which is worth knowing is functionally root-equivalent access to the host, one more reason the runner account should own nothing else.

Can one runner serve multiple repositories?

A repo-level runner, the kind registered in this post, serves one repository. For several repos under the same account, register at the organization level instead (Organization Settings → Actions → Runners), and use runner groups to control which repositories are allowed to use it.

Why did config.sh –version fail with a libicu error on AlmaLinux?

Because installdependencies.sh calls yum unconditionally on RHEL-family systems, and AlmaLinux 10 doesn’t ship yum, only dnf. Install the exact packages from the script by hand: sudo dnf install -y lttng-ust openssl-libs krb5-libs zlib libicu.

Is it safe to use a self-hosted runner on a public repo?

Only if you’re careful about which events can trigger it. workflow_dispatch and push to protected branches are fine, since only people you trust can fire them. pull_request and especially pull_request_target let anyone who opens a PR run code on your machine.

What’s the actual difference between a self-hosted runner and GitHub’s own?

GitHub’s hosted runners are fresh, disposable containers, every job starts clean and the machine is thrown away afterward. A self-hosted runner is a real machine you maintain, patch, and are responsible for securing, in exchange for hardware GitHub doesn’t offer, network access GitHub can’t have, and no per-minute billing.

Where this leaves you

Dedicated user, verified download, dependencies that actually differ by distro in a way that breaks silently if you’re not checking, a systemd service that proved it survives a reboot on its own, and a job that ran and reported back with matching timestamps on both ends. That’s a runner you can trust because you watched it work, not because the install script exited without complaining.

Avatar photo

Asif Khan

I am a DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.