What You Will Set Up

By the end of this guide you will have a working GitHub Actions self-hosted runner on an Ubuntu 22.04 or 24.04 server. Your workflows will execute on your own machine instead of GitHub-hosted runners, which means no per-minute billing, full control over installed software, and access to private networks, GPUs, and build caches that GitHub's cloud can't see.

This is for DevOps engineers, backend developers, and teams whose CI jobs need special hardware, persistent caches, or access to internal services like private registries and VPNs. If you just need a basic Linux CI environment, GitHub-hosted runners are simpler — self-hosted runners earn their keep when the defaults aren't enough.

Prerequisites

  • An Ubuntu 22.04 or 24.04 server with sudo access (2 vCPU and 4 GB RAM is a comfortable minimum).
  • A GitHub repository (or organization) where you have Admin rights — you'll create a registration token there.
  • Outbound HTTPS (port 443) to github.com and *.actions.githubusercontent.com.
  • About 30 minutes.

Step 1 — Create a Dedicated Runner User

Never run the runner as root. A compromised workflow job would inherit root privileges, so we create a locked-down service account:

sudo useradd -m -s /bin/bash github-runner
sudo usermod -aG sudo github-runner
sudo su - github-runner

useradd -m creates the account with a home directory, usermod -aG sudo adds it to the sudo group (handy for apt in jobs that need it), and su - github-runner switches into it so everything from here on is owned by the right user.

Step 2 — Install the Runner's Dependencies

The runner needs curl, tar, jq, and a few libraries. Update apt and install the runner's own dependency script dependencies first:

sudo apt update
sudo apt install -y curl tar jq libicu-dev

curl downloads the runner package, tar extracts it, jq helps with JSON wrangling inside workflows, and libicu-dev satisfies the .NET runtime the runner agent is built on. The runner also ships bin/installdependencies.sh, which pulls the remaining .NET bits automatically.

Step 3 — Download and Extract the Runner

Create a directory for the agent and fetch the latest release. Get the exact versioned commands from your repository page (Settings → Actions → Runners → New self-hosted runner, pick Linux x64) — GitHub prints the current version for you. The generic form is:

mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner.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.tar.gz

This downloads the official agent tarball from the runner releases page and extracts it. Always cross-check the version number against the "New self-hosted runner" page for your repo, because the download URL changes with every release — using the commands GitHub generates for you is the most reliable path.

Check the integrity hash GitHub shows next to the download command (a sha256sum value). Running echo "<hash> actions-runner.tar.gz" | sha256sum --check confirms the tarball wasn't tampered with in transit.

Step 4 — Register the Runner with GitHub

Still as the github-runner user, install the remaining dependencies and run the interactive configuration. Replace <REPO_URL> with your repository URL and <TOKEN> with the one-time registration token shown on the "New self-hosted runner" page:

sudo ./bin/installdependencies.sh
./config.sh --url https://github.com/<OWNER>/<REPO> --token <TOKEN>

installdependencies.sh installs the .NET runtime pieces the agent needs (it must run with sudo). config.sh pairs this machine with your repository: --url points at the repo, --token is the short-lived registration token (valid for one use, roughly an hour — generate a fresh one if it expires).

You will be asked for a runner name, group, and labels. Accept the defaults or add useful labels — labels decide which jobs land on this machine:

Enter the name of the runner group to add this runner to: [press Enter]
Enter the name of runner: [press Enter]
Enter any additional labels (ex. label-1,label-2): ubuntu,self-hosted
Enter the name of work folder: [press Enter]

Labels like ubuntu,self-hosted let workflows target this runner with runs-on: [self-hosted, ubuntu]. Keep labels simple and consistent — a label mismatch is the most common reason jobs sit in the queue forever.

Step 5 — Install the Runner as a Systemd Service

Running ./run.sh works for testing, but it dies with your SSH session. Install the systemd service so the runner starts on boot and restarts on failure:

sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status

svc.sh install creates a systemd unit (named actions.runner.<owner>-<repo>.<runner-name>.service), start launches it, and status confirms it's active. Note the sudo here is intentional — the service must be installed by root, but it still executes jobs as the github-runner user you configured with.

Enable it explicitly so it survives reboots (the install step usually handles this, but verify):

sudo systemctl enable actions.runner.* --now
systemctl is-active 'actions.runner.*'

Step 6 — Test It with a Real Workflow

Add this workflow file to your repository at .github/workflows/self-hosted-test.yml and push it:

name: Self-hosted runner test
on: [push]
jobs:
  hello:
    runs-on: [self-hosted, ubuntu]
    steps:
      - uses: actions/checkout@v4
      - run: echo "Hello from $(hostname)"
      - run: cat /etc/os-release

The key line is runs-on: [self-hosted, ubuntu] — it must match labels you assigned in Step 4. When you push, go to your repo's Actions tab: the job should pick up within seconds and print your server's hostname in the log.

Verify It Worked

  • Service check: sudo ./svc.sh status (or systemctl status 'actions.runner.*') shows active (running).
  • GitHub UI check: Settings → Actions → Runners lists the new runner with a green Idle status.
  • Workflow check: the test workflow above completes with your server's hostname and Ubuntu version in its logs.
  • Reboot check: sudo reboot, then after the machine comes back, confirm the runner shows Idle in GitHub again — this catches the most common production failure before it matters.

Common Issues & Fixes

1. Runner shows Offline after a reboot

Cause: the service wasn't installed, or systemd skipped it at boot.
Fix: re-run sudo ./svc.sh install from the actions-runner directory, then sudo systemctl enable 'actions.runner.*' --now. Verify with systemctl is-enabled 'actions.runner.*' — it must print enabled.

2. Jobs queue forever and never start

Cause: the workflow's runs-on labels don't match the runner's labels.
Fix: on the server, check .runner file labels with cat .runner | head -5, or re-run ./config.sh --unattended --url … --token … --labels ubuntu,self-hosted after removing the old config with ./config.sh remove --token <TOKEN>. Keep label sets identical between config and workflow.

3. Registration token expired during setup

Cause: tokens from the "New self-hosted runner" page are single-use and expire quickly.
Fix: reload Settings → Actions → Runners → New self-hosted runner to get a fresh token and re-run ./config.sh. Nothing else on the server needs to change.

4. svc.sh start fails with "must be run as root" or permission errors

Cause: svc.sh needs root for systemd integration, but the runner files are owned by your user.
Fix: run exactly sudo ./svc.sh install and sudo ./svc.sh start from the runner directory. If you ran the install as the wrong user, uninstall with sudo ./svc.sh uninstall, fix ownership with sudo chown -R github-runner:github-runner ~/actions-runner, and install again.

5. Runner can't reach GitHub (timeouts in config.sh or jobs)

Cause: outbound HTTPS blocked by a firewall, or a corporate proxy is in play.
Fix: allow github.com and *.actions.githubusercontent.com on port 443. Behind a proxy, export https_proxy before configuring, or add --proxyurl / --noproxy flags to ./config.sh. The runner only needs outbound connections — no inbound ports to open.

Conclusion

You now have a production-ready self-hosted runner: registered with your repository, labeled for targeted workflows, and running as a systemd service that survives reboots. From here, the upgrades that pay off most are runner autoscaling (ephemeral runners with --ephemeral plus a small cloud autoscaler), one runner group per environment for repository- and organization-level runners, and monitoring the _diag logs so a sick runner pages you before your team notices.

Need help with CI/CD infrastructure? XpertBees designs, builds, and maintains self-hosted build farms, GitHub Actions migrations, and DevOps automation for teams of every size — get in touch and we'll set up yours the right way.