Set Up a Self-Hosted GitHub Actions Runner on Ubuntu

Written by: Bagus Facsi Aginsa
Published at: 11 Jun 2026


GitHub Actions makes continuous integration feel almost effortless. You push code, a workflow fires, and a few minutes later you know whether your tests passed. By default those jobs run on GitHub-hosted machines, which are clean virtual machines GitHub spins up and throws away for every run. That convenience is great until you hit its limits: you need a bigger machine, you want to cache large dependencies between runs, you need access to a private database on your network, or you simply want to stop paying for build minutes on a busy repository.

This is where a self-hosted runner comes in. Instead of borrowing GitHub’s machines, you point your workflows at your own Ubuntu server. The job runs on hardware you control, with the network access, disk, and tools you decide on.

In this tutorial you will install a self-hosted runner on Ubuntu, register it with a GitHub repository, turn it into a proper background service that survives reboots, and write a workflow that actually uses it. This guide is for developers and DevOps engineers who already use GitHub and want more control over where their CI/CD jobs run. You only need basic Linux command-line skills and a GitHub account with admin rights on a repository.


How GitHub Actions Runners Work

Before touching any commands, it helps to understand the moving parts.

A workflow is a YAML file in your repository under .github/workflows/. It describes one or more jobs, and each job is a list of steps (commands or prebuilt actions). When a trigger happens, like a push or a pull request, GitHub queues the jobs.

A runner is the agent that actually executes a job. It is a small program that connects out to GitHub, waits for work, pulls down the job, runs the steps, and reports the results back. The key detail is the direction of the connection: the runner reaches out to GitHub over HTTPS. GitHub never connects inbound to your server, so you do not need to open any firewall ports or expose your machine to the internet. That single fact makes self-hosted runners much safer to operate than people expect.

There are two kinds of runners:

GitHub-hosted runners are fresh, disposable virtual machines managed by GitHub. Every job starts from a clean slate. They are simple and require zero maintenance, but they cost build minutes on private repositories and you cannot customize the hardware or reach into your private network.

Self-hosted runners are machines you own and register yourself. They keep their state between runs, so dependency caches and Docker layers stick around and builds get faster. You can give them as much CPU, RAM, and disk as you like, install any tooling, and let them reach internal services that GitHub’s cloud could never touch.

The trade-off is responsibility. A self-hosted runner is a long-lived machine, so you have to keep it patched, and because it keeps state between jobs you must be careful about what you run on it. We will cover the security side properly later on.


Prerequisites

Before you start, make sure you have the following:

  • An Ubuntu machine (this guide was tested on Ubuntu 22.04 and 24.04). It can be a VPS, a bare-metal box, or a VM on your network.
  • At least 2 CPU cores, 2 GB of RAM, and a few GB of free disk. Builds are hungry, so more is better.
  • A GitHub account with admin access to the repository where you want the runner.
  • Outbound HTTPS (port 443) access to github.com, which almost every network already allows.
  • Basic command-line comfort and sudo privileges on the server.

If your workflows are going to build container images, install Docker first. My earlier guide on getting started with Docker and Docker Compose on Ubuntu walks through that cleanly.


Step 1: Create a Dedicated User for the Runner

It is tempting to run everything as your own login or as root, but a CI runner executes whatever code lands in your workflows. You want it boxed into its own unprivileged account so a misbehaving job cannot wander around the rest of the system.

Create a dedicated user called gha:

sudo adduser --disabled-password --gecos "" gha

The --disabled-password flag means nobody can log in to this account with a password, which is exactly what we want for a service account. Now switch into it to do the rest of the setup:

sudo su - gha

Your shell prompt should now show you are working as gha. Everything in the next two steps happens inside this account.


Step 2: Download and Unpack the Runner Software

GitHub publishes the runner agent as a small tarball. The exact download URL and version are shown on the registration page in GitHub, but you can also grab the latest release manually. First create a working directory:

mkdir ~/actions-runner && cd ~/actions-runner

Download the current Linux x64 release. Replace the version number with whatever is current when you read this:

curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.323.0/actions-runner-linux-x64-2.323.0.tar.gz

Extract it into the current directory:

tar xzf actions-runner-linux-x64.tar.gz

List the contents to confirm it unpacked correctly:

ls

You should see scripts like config.sh, run.sh, and a bin directory. These are the tools you will use to register and start the runner.


Step 3: Get a Registration Token from GitHub

The runner needs to prove to GitHub that you are allowed to attach it to your repository. You do that with a short-lived registration token.

In your browser, go to your repository on GitHub, then click Settings, then Actions in the left sidebar, then Runners. Click the New self-hosted runner button and choose Linux as the operating system.

GitHub shows you a page with the exact commands, including a config.sh line that contains a registration token. That token looks something like AABBCCDDEEFF... and it expires after about an hour, so do not save it for later.

Copy the URL of your repository and the token. Back in your terminal (still as the gha user, inside ~/actions-runner), run the configuration script:

./config.sh --url https://github.com/your-username/your-repo --token AABBCCDDEEFFGGHHIIJJ

The script asks a few questions:

  • Name of the runner group: press Enter to accept the default.
  • Name of this runner: give it something descriptive like ubuntu-build-01. This name shows up in the GitHub UI.
  • Additional labels: add custom labels such as self-hosted,linux,docker. Labels are how workflows pick which runner to use, so choose them thoughtfully.
  • Work folder: press Enter to accept the default _work.

When it finishes you will see a message confirming the runner connected and settings were saved. Over in the GitHub Runners page, your new runner now appears with an Offline status, because we have configured it but have not started it yet.


Step 4: Test the Runner Interactively

Before turning the runner into a background service, run it in the foreground once to make sure everything works:

./run.sh

You should see output similar to this:

√ Connected to GitHub

Current runner version: '2.323.0'
Listening for Jobs

Refresh the GitHub Runners page and the status flips to Idle with a green dot. The runner is now waiting for work. Press Ctrl+C to stop it for now. Running in the foreground is only good for a quick test, because the moment you close your terminal the runner dies. Next we fix that.


Step 5: Install the Runner as a systemd Service

A real runner should start automatically on boot and restart if it crashes. The runner ships with a helper that wires it into systemd for you. This step needs root, so exit back to your normal user first:

exit

You are now your sudo-capable user again. Move into the runner directory and install the service, telling it to run as the gha user:

cd /home/gha/actions-runner
sudo ./svc.sh install gha

Then start it:

sudo ./svc.sh start

Check that the service is healthy:

sudo ./svc.sh status

You should see it reported as active (running). The runner now survives reboots and restarts on failure. You can also manage it with the standard systemctl and journalctl tools. To follow its logs live:

sudo journalctl -u "actions.runner.*" -f

Watching these logs is the fastest way to see jobs arrive and run in real time.


Step 6: Write a Workflow That Uses Your Runner

A runner sitting idle is no use until a workflow targets it. The magic word is in the runs-on field. Instead of runs-on: ubuntu-latest, which means “use a GitHub-hosted machine,” you reference your labels.

In your repository, create a file at .github/workflows/ci.yml with the following content:

name: CI on self-hosted runner

on:
  push:
    branches: [ main ]
  workflow_dispatch:

jobs:
  build:
    runs-on: [self-hosted, linux]
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Show where we are running
        run: |
          echo "Running on $(hostname)"
          echo "Working directory: $(pwd)"
          uname -a

      - name: Run a simple build step
        run: |
          echo "Pretend we are building the project..."
          ls -la

The important line is runs-on: [self-hosted, linux]. GitHub matches that list of labels against your registered runners and sends the job to one that has all of them. The workflow_dispatch trigger lets you launch the workflow by hand from the Actions tab, which is handy for testing.

Commit and push this file to your main branch. Open the Actions tab in your repository, and you will see the workflow start. If you still have journalctl open on the server, you will watch the job arrive and run on your own machine. The Show where we are running step prints your server’s hostname, which is satisfying proof that the work happened locally and not in GitHub’s cloud.


Common Mistakes and Troubleshooting

The job stays queued forever. If your workflow hangs on “Waiting for a runner,” the labels in runs-on do not match any online runner. Double-check the labels you set in Step 3 against the ones in the workflow. Also confirm the runner shows as Idle and not Offline in the GitHub UI.

“Must not run with administrator privileges” during config. The config.sh script refuses to run as root on purpose. Run it as your dedicated gha user, not with sudo. Only the svc.sh install step needs root.

The runner went offline after a reboot. This usually means you tested with ./run.sh but never installed the systemd service. Go back to Step 5. If the service is installed but not starting, inspect the logs with sudo journalctl -u "actions.runner.*" --no-pager to see the actual error.

Docker commands fail with a permission error. If your workflow builds images, the gha user needs to be in the docker group. Add it and restart the service:

sudo usermod -aG docker gha
sudo ./svc.sh stop && sudo ./svc.sh start

Be aware that membership in the docker group is effectively root access on the host, so only do this on a runner you trust and isolate.

The registration token expired. Registration tokens live for about an hour. If config.sh reports an invalid token, just generate a fresh one from the GitHub Runners page and try again.

Disk slowly fills up. Because self-hosted runners keep state, the _work directory and Docker’s cache grow over time. We deal with this in the best practices below.


Best Practices

Never run untrusted code on a self-hosted runner. This is the single most important rule. Public repositories are dangerous here: anyone can open a pull request, and on a misconfigured project that PR could run arbitrary commands on your machine. GitHub itself recommends self-hosted runners only for private repositories. If you must use them on a public repo, require manual approval for workflows from outside contributors in the repository’s Actions settings.

Treat the runner as disposable when you can. State that persists between jobs speeds up builds but also lets one job leave behind data or malware that affects the next. For sensitive pipelines, run each job inside a fresh container or clean the workspace at the end. The actions/checkout action already cleans the repo before each run, but caches and temp files outside it do not get wiped automatically.

Keep the work directory under control. Add a cleanup step or a scheduled job that prunes old build artifacts and Docker images. A simple cron entry like this keeps Docker from eating your disk:

docker system prune -af --filter "until=168h"

Patch the host regularly. A self-hosted runner is a long-lived server, so apply security updates on a schedule. If you manage several runners, automating this with a configuration tool pays off quickly. My guide on getting started with Ansible on Ubuntu shows one clean way to keep a fleet of machines consistent.

Keep the runner agent updated. The agent can auto-update itself by default, which is good, but verify it stays current. Outdated runners eventually stop being able to connect to GitHub.

Use labels to route jobs deliberately. As you add runners, labels become your traffic control. A runner with a GPU might carry a gpu label, while a beefy build box carries high-memory. Workflows then request exactly the capabilities they need.

Scale out, not just up. You can register many runners against the same repository or, on GitHub organizations, share a pool across repositories. Several modest machines often serve a team better than one giant one, because jobs run in parallel instead of queuing.


Conclusion

You now have a self-hosted GitHub Actions runner installed on Ubuntu, locked into its own unprivileged user, running as a systemd service that comes back after reboots, and wired into a workflow that proves jobs execute on your own hardware. Along the way you saw why the runner’s outbound-only connection keeps your firewall closed, how labels route jobs to the right machine, and which security habits keep a long-lived runner from becoming a liability.

From here, the natural next steps are to add Docker to your runner so it can build and push images, register additional runners and route jobs with labels, and explore caching strategies that take advantage of the persistent disk. If you run your workloads on Kubernetes, you might also look into the Actions Runner Controller, which manages ephemeral runners as pods and gives you the disposability of GitHub-hosted machines with the control of self-hosted ones. Either way, you have taken your CI/CD pipeline off the rented cloud and onto infrastructure you fully control.