Running a self-hosted GitHub Actions runner on Proxmox LXC

How I run a private GitHub Actions runner in my homelab using Proxmox LXC, Docker and systemd.

In my homelab, a Lenovo ThinkCentre M920 (i5, 32GB RAM) handles a few background jobs. One of them is a self-hosted GitHub Actions runner running inside a Proxmox LXC.

I wanted this runner isolated from the rest of the host, but still able to run Docker-based jobs. An LXC is enough for that in my setup: small, easy to rebuild, and simple to keep online with systemd.

Private repositories only

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

What This Setup Covers

The flow is:

  • create an LXC with Docker available
  • create a dedicated runner user
  • register the runner in GitHub
  • run a small workflow to verify it picks up jobs
  • install it as a systemd service
  • optionally add pre/post job hooks

Setting up the server

In Proxmox, I created 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 the container is ready, create a dedicated user for the runner:

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

If the runner will execute Docker builds, add that user to the docker group:

root@runner-server:~$ sudo usermod -aG docker runner

Log in as runner and verify Docker access before registering anything in GitHub:

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

In the repository, go to Settings -> Actions -> Runners and click “New self-hosted runner”.

GitHub Actions runners settings page with the New self-hosted runner button highlighted

Choose the operating system and follow GitHub’s commands to download and configure the runner.

GitHub add self-hosted runner page with Linux and x64 selected

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. I keep this one in the default group unless I need repository-specific routing.

Runner name

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

This is the name that appears in GitHub when the runner is idle or executing a job.

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]

Labels are how workflows target a runner. For this LXC, labels like docker or lxc are useful if you have more than one self-hosted machine.

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 workflow checkouts. I leave it as _work.

Once you’ve followed GitHub’s setup steps, 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

Back in Settings -> Actions -> Runners, the runner should appear as Idle.

GitHub runners list showing runner-server online with self-hosted, Linux and X64 labels

With the runner registered, the next step is a test job.

Running a test job

For the first test, I use a tiny manual workflow:

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.

After creating the workflow, open the Actions tab and trigger it manually with “Run workflow”. GitHub Actions workflow page with the manual Run workflow menu open

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

GitHub also shows the current job on the runner page:

GitHub runner detail page showing the build job currently in progress on runner-server

GitHub Actions build log showing setup, checkout and script steps succeeding

Running as a service

After the manual test works, install the runner as a service:

./svc.sh install USERNAME

Since the runner runs as the runner user:

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.

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.

Run long-lived runners as a service

For a runner that should stay online, I prefer systemd: it starts after reboot and restarts after crashes. I only use ./run.sh for quick testing.

Running scripts before/after

For jobs that need setup or cleanup, GitHub supports scripts before or after a job. The runner reads two environment variables:

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

Here is a dummy script. Save hooks outside the runner’s working directory so a checkout cannot overwrite them:

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

Then create a .env file inside the runner directory:

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:

GitHub Actions build log showing a runner hook script listing repository files

Common use cases for hooks

I use hooks for workspace cleanup, sending notifications, collecting metrics, or setting up environment variables that jobs depend on.

Things I Keep An Eye On

There are a few things I keep in mind when running a self-hosted runner:

Watch out

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: Make sure 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
  • Storage: If your runner’s LXC needs access to shared storage from the Proxmox host, see how to share CIFS mounts with unprivileged LXC containers

Comments

Back to top