In my homelab, I run a Lenovo ThinkCentre M920 (i5, 32GB RAM) for various tasks—including a self-hosted GitHub Actions runner. The setup is straightforward and works out of the box.

Private repositories only

GitHub recommends using self-hosted runners only for private repositories, as public repos pose security risks.

Overview

StepWhatTime
1Set up the server (LXC, user, Docker)~5 min
2Create and configure the runner on GitHub~3 min
3Run a test job to verify~1 min
4Configure as a systemd service~1 min
5(Optional) Add pre/post job hooks~2 min

Setting up the server

In my setup, I’m using Proxmox, so I spun up a new LXC container with Docker pre-installed:

bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh)"

Disk space

Depending on the actions you plan to run, I recommend allocating at least 8GB of disk space.

Once your machine is ready, create a new user and set a password:

root@runner-server:~$ useradd -m -G sudo -s /bin/bash runner
root@runner-server:~$ sudo passwd runner

If you’ll be running Docker builds, make sure to add the new user to the docker group to avoid permission issues:

root@runner-server:~$ sudo usermod -a -G runner docker

You can verify the user’s Docker access by running:

runner@runner-server:~$ docker info
Client: Docker Engine - Community
 Version:    28.0.4
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.22.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.34.0
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Creating the runner

Head to your repository: Settings → Actions → Runners, then click “New self-hosted runner” to start the setup process.

On the next screen, choose your operating system and follow the step-by-step instructions provided to download and configure the runner.

When setting up the runner, GitHub will prompt you for the following:

Group name

Enter the name of the runner group to add this runner to: [press Enter for Default]

If you have multiple runners, you can group them. Unless you have a specific setup in mind, just press Enter to use the “Default” group.

Runner name

Enter the name of runner: [press Enter for runner-server]

This will be the name of your self-hosted runner—helpful for identifying it later, especially if you have more than one.

Labels

This runner will have the following labels: 'self-hosted', 'Linux', 'X64'
Enter any additional labels (ex. label-1,label-2): [press Enter to skip]

These are the labels. You can add custom ones to help target specific runners when defining your workflows—for example, ubuntu, docker, or lxc.

jobs:
  build:
    # The type of runner that the job will run on
    runs-on: [self-hosted, Linux, X64, label-1]

Labels matter

Labels are how your workflows find the right runner. If you have multiple runners with different capabilities (e.g. one with Docker, one without), use labels to route jobs to the correct machine.

Work folder

Enter name of work folder: [press Enter for _work]

This sets the default working directory for your workflows on the self-hosted runner. You can safely leave it as-is.

Once you’ve followed GitHub’s setup steps, just start the runner—you should see something like this:

runner@runner-server:~/actions-runner$ ./run.sh
 
 Connected to GitHub
 
Current runner version: '2.323.0'
2025-04-10 12:16:52Z: Listening for Jobs

And in your repository under Settings → Actions → Runners, you should now see your self-hosted runner listed and marked as Idle.

Now we just need to setup a job.

Running a test job

For this test I’m using a simple job:

on:
  workflow_dispatch:
 
jobs:
  build:
    # The type of runner that the job will run on
    runs-on: self-hosted
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Run a one-line script
        run: echo Hello, world!
 
      - name: Run a multi-line script
        run: |
          echo Add other actions to build,
          echo test, and deploy your project.

Once you’ve created the job, head to the Actions tab in your repository and click “Run workflow” to trigger it manually.

You should see:

Requested labels: self-hosted
Job defined at: self-hosted-runner-test/.github/workflows/test-job.yml@refs/heads/main
Waiting for a runner to pick up this job...
Job is about to start running on the runner: runner-server (repository)

And in your runner-server:

runner@runner-server:~/actions-runner$ ./run.sh
 
 Connected to GitHub
 
Current runner version: '2.323.0'
2025-04-10 12:41:12Z: Listening for Jobs
2025-04-10 12:42:16Z: Running job: build
2025-04-10 12:42:29Z: Job build completed with result: Succeeded

In GitHub, you can also monitor the runner’s current job execution:

Running as a service

Instead of manually starting the runner with ./run.sh, you can configure it to run as a service. To do this, first install the runner as a service:

./svc.sh install USERNAME

In our example, since we added a new user runner:

runner@runner-server:~/actions-runner$ sudo ./svc.sh install runner
[sudo] password for runner:
Creating launch runner in /etc/systemd/system/actions.runner.self-hosted-runner-test.runner-server.service
Run as user: runner
Run as uid: 1000
gid: 1000
Created symlink /etc/systemd/system/multi-user.target.wants/actions.runner.self-hosted-runner-test.runner-server.service /etc/systemd/system/actions.runner.self-hosted-runner-test.runner-server.service.

Now you just need to start the service:

runner@runner-server:~/actions-runner$ sudo ./svc.sh start
 
/etc/systemd/system/actions.runner.self-hosted-runner-test.runner-server.service
 actions.runner.self-hosted-runner-test.runner-server.service - GitHub Actions Runner (self-hosted-runner-test.runner-server)
     Loaded: loaded (/etc/systemd/system/actions.runner.self-hosted-runner-test.runner-server.service; enabled; preset: enabled)
     Active: active (running) since Thu 2025-04-10 08:50:00 -04; 11ms ago
   Main PID: 65251 (runsvc.sh)
      Tasks: 2 (limit: 38247)
     Memory: 1.6M
        CPU: 7ms
     CGroup: /system.slice/actions.runner.self-hosted-runner-test.runner-server.service
             ├─65251 /bin/bash /home/runner/actions-runner/runsvc.sh
             └─65253 ./externals/node20/bin/node ./bin/RunnerService.js
 
Apr 10 08:50:00 docker systemd[1]: Started actions.runner.self-hosted-runner-test.runner-server.service - GitHub Actions Runner (self-ho…nner-server).
Apr 10 08:50:00 docker runsvc.sh[65251]: .path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Hint: Some lines were ellipsized, use -l to show in full.

Always run as a service

Running as a systemd service ensures your runner starts automatically after reboots and restarts itself if it crashes. There’s no reason to use ./run.sh manually in production.

Running scripts before/after

For more complex jobs, you might need to run scripts before or after a job. You can do this using two environment variables:

  • ACTIONS_RUNNER_HOOK_JOB_STARTED: Runs before the job starts.
  • ACTIONS_RUNNER_HOOK_JOB_COMPLETED: Runs after the job finishes.

Here’s an example script. Save it outside your runner’s working directory:

runner@runner-server:~$ cd ~
runner@runner-server:~$ echo 'ls -la' > list-files.sh
runner@runner-server:~$ chmod +x list-files.sh

Go to your runner’s directory and create a .env file:

runner@runner-server:~$ cd actions-runner/
runner@runner-server:~/actions-runner$ echo "ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/list-files.sh" > .env
runner@runner-server:~/actions-runner$ cat .env
ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/list-files.sh

If you’re running the runner as a service, restart it to apply the hook changes:

runner@runner-server:~$ sudo ./svc.sh stop
runner@runner-server:~$ sudo ./svc.sh start

You should see the script’s output in your build log:

Common use cases for hooks

Hooks are great for workspace cleanup, sending notifications, collecting metrics, or setting up environment variables that your jobs depend on.

Considerations

There’s a few considerations when running your self-hosted runner:

Keep these in mind

  • Security & access control: Restrict which workflows can use your runner (e.g. disable forks, enforce branch protections), run it on a hardened host, and secure the runner’s registration token
  • Updates & maintenance: You’re responsible for OS and software patches—keep the runner app up-to-date and automate workspace cleanup to avoid disk exhaustion
  • Resource sizing & scaling: Ensure your machine has enough CPU, memory, and disk
  • Networking & connectivity: The runner needs outbound access to GitHub’s APIs (and any artifact registries). If you’re behind a proxy or firewall, configure proxy settings and open required ports
  • Workspace hygiene: By default each job clones into the working directory; auto-remove or periodically prune old workspaces to prevent disk bloat