Skip to main contentClaim $5 in free credit — one-time, per account. Claim $5 free
Krova CloudKrova Cloud

Run a GitHub Actions runner on a Cube

Self-host a GitHub Actions runner on a Cube as a systemd service that survives reboots — outbound-only, with no published ports and nothing exposed to the internet.

GitHub's hosted runners are fine until you want more CPU than they give you, a build cache that survives between jobs, or a machine you control. A self-hosted runner on a Cube gives you all three: root, persistent disk, and a cost you can predict.

The property that makes a Cube a particularly good host for this is that a runner is outbound-only. It long-polls GitHub over HTTPS and GitHub never connects back to it. So you publish no ports at all — SSH is enough to set it up, and your CI machine is never reachable from the internet.

The steps below are short. Three of them have a trap that will stop you dead with an error that does not describe the actual problem — Step 3, Step 4 and Step 5. Those are the ones worth reading rather than pasting.

Before you start

Use private repositories only. This is GitHub's own guidance and it is not a formality: anyone can fork a public repository and open a pull request whose workflow runs arbitrary code on your runner. A self-hosted runner attached to a public repo is a machine you have handed to strangers.

Registering at the organisation level puts the runner in the default runner group, which only private repositories can reach. Leave it there, or scope it deliberately with --runnergroup.

  • A Cube running Ubuntu 24.04. Start at 2 vCPU / 4 GB / 20 GB and grow it once you know the workload.
  • An SSH key pair, as with any Cube.
  • Admin rights on the organisation or repository you are registering against.

Size for your builds, not for the runner. The runner process itself idles at a few megabytes; compilation, tests and Docker layers are what consume the Cube. Its _work directory grows without bound as jobs check out repositories and cache toolchains, so plan a cleanup step or extra disk before it matters.

Step 1 — Create the Cube

Create a Cube with the Ubuntu 24.04 image. If your workflows use container:, services:, or call docker directly, use Ubuntu 24.04 + Docker instead — it is the same image with Docker Engine already installed.

You can also start plain and add Docker later; nothing about the Cube prevents it. Adding the runner user to the docker group does mean any job can become root on the Cube, so make that choice on purpose rather than by habit.

Step 2 — Connect over SSH

The Cube's Connect tab shows the exact command, with the host and port already filled in:

ssh root@<cube-host> -p <port>

If your key is not your default one, point at it explicitly with -i ~/.ssh/your_key. A Permission denied (publickey) here means the connection reached the Cube and only the key was rejected — the network path is fine.

Step 3 — Create a user for the runner

The runner's config.sh refuses to run as root and exits with Must not run with sudo. There is an environment variable that overrides this. Do not use it. A runner executes whatever a workflow tells it to, and running that as root means a single bad job owns the whole Cube.

adduser --disabled-password --gecos "" runner
install -d -o runner -g runner /home/runner/actions-runner

GitHub's hosted runners give workflows passwordless sudo, and plenty of published actions assume it. If you want that parity:

echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
chmod 440 /etc/sudoers.d/runner

That does make any job root-equivalent on the Cube. Skip it if you would rather discover which workflows genuinely need it.

The trap: never use an interactive su

Every command from here on runs as runner, and the obvious way to do that — typing su - runner and then pasting the rest — silently corrupts the whole sequence.

su - runner starts an interactive shell. The lines you pasted after it are still sitting in your terminal's input buffer, so that new shell reads them as its own input, in an order you did not intend. In practice a stray exit logs you straight back out and the remaining commands run as root, in root's home directory — which is how you end up staring at Must not run with sudo again after carefully creating a non-root user.

Use the non-interactive form instead. It runs one command as runner and returns:

su - runner -c '<command>'

Step 4 — Download and verify the runner

Open Settings → Actions → Runners → New self-hosted runner on your organisation or repository and choose Linux and x64. That page always shows the current version, its checksum and a registration token, so take the download and checksum lines from it rather than from any guide — this one included.

Choosing the right platform there is load-bearing. Each package has its own valid checksum, so downloading the macOS build onto a Linux Cube passes verification perfectly and then fails later with an error about something else entirely. Cubes are x86-64 Linux: you want actions-runner-linux-x64.

Then run it as the runner user, substituting the version and checksum GitHub showed you:

RUNNER_VERSION=2.336.0
RUNNER_SHA=<checksum-from-github>
TARBALL=actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
URL=https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${TARBALL}

su - runner -c "cd ~/actions-runner \
  && curl -fsSL -O '${URL}' \
  && echo '${RUNNER_SHA}  ${TARBALL}' | sha256sum -c - \
  && tar xzf '${TARBALL}' \
  && ls -d bin externals"

You want OK from the checksum, then bin externals.

Step 5 — Install the .NET dependencies

The runner is a .NET application and a minimal Ubuntu image does not ship what it needs. This step is not optional: config.sh checks these libraries before it does anything else and aborts with Dependencies is missing for Dotnet Core 6.0 if they are absent. The runner package includes the installer, which runs as root:

cd /home/runner/actions-runner && ./bin/installdependencies.sh

It installs libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 and libicu74.

A run of E: Unable to locate package libicu80 libicu75 is normal. The script tries ICU versions newest-first and stops at the one your distribution has; on Ubuntu 24.04 that is libicu74, several lines down. The line that matters is Finish Install Dependencies at the end.

Step 6 — Register the runner

The registration token from that GitHub page expires in about an hour, so do Steps 4 and 5 first and fetch the token immediately before this one.

su - runner -c 'cd ~/actions-runner && ./config.sh \
  --url https://github.com/<your-org> \
  --token <registration-token> \
  --name <runner-name> \
  --labels krova,cube \
  --work _work \
  --unattended --replace'

--unattended is worth keeping even when you are typing by hand: it makes registration fail loudly on a bad token instead of dropping into an interactive prompt that will swallow whatever you paste next.

self-hosted, linux and x64 are applied automatically — --labels adds to them. Use --url https://github.com/<org>/<repo> instead for a single repository.

Success looks like √ Connected to GitHub, √ Runner successfully added and √ Settings Saved.

Step 7 — Run it as a service

Without this the runner only lives as long as your SSH session. svc.sh is generated by Step 6 with the service name baked in, so it does not exist until registration has succeeded. It runs as root, from the runner directory, and takes the username to run as:

cd /home/runner/actions-runner
./svc.sh install runner
./svc.sh start
./svc.sh status

The unit is enabled, so the runner comes back on its own after a Cube restart.

Step 8 — Verify it actually runs a job

First confirm the service is up and listening:

systemctl is-active 'actions.runner.*'
journalctl -u 'actions.runner.*' -n 30 --no-pager

You want active, and a Listening for Jobs line in the journal.

That is the runner reporting its own health, which is not the same as a job succeeding. Prove the rest by landing one. Commit this to a private repository the runner can see, then run it from the Actions tab:

name: cube-runner-smoke
on: workflow_dispatch

jobs:
  smoke:
    runs-on: [self-hosted, krova]
    steps:
      - uses: actions/checkout@v7

      - name: Where am I
        run: |
          echo "host: $(hostname)"
          echo "user: $(whoami)"
          echo "arch: $(uname -m)"
          df -h /

      - name: Passwordless sudo
        run: sudo -n true && echo "sudo OK"

      - name: Egress
        run: curl -sS -o /dev/null -w 'github: %{http_code}\n' https://api.github.com

That exercises the four things the steps above only implied: that jobs reach the runner, that checkout can clone, sudo works, and the Cube has outbound network from inside a job.

actions/checkout needs git, which a minimal image does not have. Install it before the first run:

command -v git || apt-get install -y git

Where to go next

  • Container jobs. Recreate the Cube from Ubuntu 24.04 + Docker, or install Docker on the existing one, then usermod -aG docker runner.
  • Disposable runners. Add --ephemeral at registration and the runner accepts one job then deregisters, so nothing leaks between jobs. It needs re-registering per job, so pair it with automation.
  • More runners. Each one is its own Cube with its own name. Give them a shared label and GitHub will distribute jobs across them.
  • Snapshot the Cube once the runner is configured, so rebuilding it later is a restore rather than this page again.