To serve qemu boot images to GitHUB runners. Something simple like:

sudo apt-get install nginx
sudo mkdir /var/www/html/boot_images
cd /var/www/html
sudo wget
cd /var/www/html/boot_images
sudo wget
sudo wget

Self-hosted Runners

There are limits to what you can do on the runners hosted by GitHUB. This is where GitHUB self-hosted runners comes into play. You can BYOD to the GitHUB Actions. This can be HW in your garage, basement, attic, or, often more conveniently, provided via cloud-hosting or bare-metal host.

This explores the latter approaches: virtual and bare-metal providers.


To the authors knowledge, then the only cloud-provider supporting nested-virtualization is Digital-Ocean. That is, atleast for low-tier virtual machines e.g less than 100USD/mo per instance.

Nested virtualization is needed as the GitHUB-runner will start up a custom qemu-instance for NVMe emulation. All the droplet types work, however, it is usually nice with more than a single core in the VM, 8GB+, Storage, that is, when running qemu-instances.

It will of course vary based on your specific use-case.

A more cost-effective solution is deploying multiple runners on the same machine.


Hetzner is a great place to get hosted bare-metal, at the time writing this then a AX41-NVMe (priced at 51.3EUR/mo) system gives you a machine with the following specs:

  • CPU: AMD Ryzen 5 3600 ~ 6 Cores / 12 Threads

  • MEM: 64GB DDR4

  • STORAGE: 2x 512GB SSD (Samsung 980PRO)

In Digital-Ocean terms, this hardware can provide SIX INSTANCES of what in DO terms are:

  • Droplet Type: Dedicated CPU

    • General Purpose

    • CPU: 2 Core

    • MEM: 8GB

    • STORAGE: 25GB

  • Cost for this instance at the time of writing: 63USD/mo.

Basically getting six times the resources, plus some to spare.

System Setup

The machine will be named ghrbox with a numeral postfix, specifically ghrbox01

Deploy your keys:

ssh-copy-id root@<SYSTEM_IP>

Update it and install various needed libraries and tools:

apt-get -qy update
apt-get -qy upgrade
apt-get -qy dist-upgrade
apt-get -qy install \
    cloud-utils \
    git \
    htop \
    libvirt-daemon-system \
    qemu \
    qemu-system \
    qemu-utils \
    time \
    tree \

Install Docker according to the official documentation:

Give the machine a name:

# Set the hostname
hostnamectl set-hostname ghrbox01

# Update it in /dev/hosts
vim /dev/hosts

Log into the machine and create a new non-root user named ghr:

adduser ghr
usermod -aG sudo ghr
usermod -aG libvirt ghr
usermod -aG docker ghr

The GitHUB Action-runner will be executing as this user.

Add your keys to system for the ghr user as well:

ssh-copy-id ghr@<SYSTEM_IP>

Actions Runner

Log into the system as the ghr user, and setup a couple environment variables, as they will be needed for the subsequent commands:

# This is the username of the user created previously
export RUNNER_USER=ghr

# Name of the runner / prefix
export RUNNER_NAME=bgtrunner

# Number of github runners
export RUNNER_COUNT=12

# This is the URL of your GitHUB Project e.g.
export URL=

# This is a GitHUB Runner token, get this from the project-settings/runners page
export TOKEN=

Log out and log in as the ghr user.

Install GitHUB-action-runner:

# Create a home for the runner
mkdir actions-runner && cd actions-runner

# Download the runner
curl -o actions-runner-linux-x64-2.304.0.tar.gz -L

# Extract it
tar xzf ./actions-runner-linux-x64-2.304.0.tar.gz

Setup runners:

for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do cp -r actions-runner "${RUNNER_NAME}${NR}"; done;

Register runners:

for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; ./ --url ${URL} --unattended --disableupdate --replace --name "${RUNNER_NAME}${NR}" --token ${TOKEN}; popd; done

Install as a service, start and check them:

# Service(s): install
for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; sudo ./ install ${RUNNER_USER}; popd; done

# Service(s): start
for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; sudo ./ start; popd; done

# Service(s): status
for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; sudo ./ status; popd; done

Stop and uninstall services:

# Services: stop
for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; sudo ./ stop; popd; done

# Services: uninstall
for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; sudo ./ uninstall; popd; done

Remove all runners:

for NR in $(seq -f "%02g" 1 $RUNNER_COUNT); do pushd "${RUNNER_NAME}${NR}"; ./ remove --token ${TOKEN}; popd; done;


Fetching pull-requests without adding remotes:

git fetch upstream pull/49/head:ghpr-49