GitHub-hosted runners are convenient, but limited in CPU, RAM and free minutes, and start from a clean machine every time. A self-hosted runner on your own VPS gives you more power, persistent caches (Docker layers, dependencies), access to private networks, and no per-minute billing. It also means you are responsible for its security.

Security first

A runner executes whatever code a workflow tells it to. Rules:

  • Use self-hosted runners only for private repositories (or trusted organisations). On a public repository, anyone could open a pull request that runs code on your server.
  • Run the runner as an unprivileged user on a dedicated VPS, not on your production server.
  • Prefer ephemeral runners (one job, then reset) for stronger isolation.
  • Don’t store long-lived secrets on the runner host. Pass them through GitHub secrets. See GitHub Actions secrets.

Requirements

  • Ubuntu or Debian VPS. 2–4 vCPU and 4–8 GB RAM suit most builds. More for heavy compilation or many parallel jobs. See vCPU explained.
  • Outbound HTTPS to GitHub. No inbound ports are needed: the runner polls GitHub for jobs.

Install the runner

In GitHub: repository (or organisation) → Settings → Actions → Runners → New self-hosted runner. It shows download and configuration commands with a one-time token, similar to:

sudo useradd -m -s /bin/bash runner
sudo -iu runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L https://github.com/actions/runner/releases/download/v<version>/actions-runner-linux-x64-<version>.tar.gz
tar xzf actions-runner.tar.gz
./config.sh --url https://github.com/your-org/your-repo --token <TOKEN> --labels vps,docker --name runner-1 --unattended
exit

Install it as a systemd service (from the runner directory, as root):

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

The runner now shows as Idle in GitHub’s settings. See systemctl commands.

Use it in workflows

jobs:
  build:
    runs-on: [self-hosted, linux, vps]
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:${{ github.sha }} .

Labels route jobs to the right runners (for example docker, arm64, gpu).

Docker access

For Docker builds, add the runner user to the docker group:

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

The docker group is root-equivalent on that host, another reason to keep the runner on a dedicated VPS. Rootless Docker reduces the risk. See Docker rootless mode.

Persistent Docker layer caches are a big speed win compared with hosted runners. See GitHub Actions Docker builds.

Keep it clean

Self-hosted runners don’t reset between jobs (unless ephemeral):

  • workspace files persist in _work/. Use actions/checkout with clean: true (the default);
  • Docker images and build cache accumulate. Schedule cleanup:
# /etc/cron.d/runner-cleanup
0 4 * * * runner docker system prune -af --filter "until=72h" > /dev/null 2>&1

See Docker disk cleanup.

Ephemeral runners

./config.sh --ephemeral … registers a runner that takes one job, then unregisters. Combine it with a script, or containers, that recreate a fresh runner after each job. Tools such as Actions Runner Controller (Kubernetes) automate this at scale.

Updates and monitoring

  • The runner auto-updates itself by default. Keep the OS updated too. See automatic security updates.
  • Watch disk space and job queues. Offline runners show in the GitHub UI.
  • Logs: journalctl -u actions.runner.* and _diag/ in the runner directory.

Quick reference

Task Command
Configure ./config.sh --url … --token … --labels …
Install service sudo ./svc.sh install runner && sudo ./svc.sh start
Use in workflow runs-on: [self-hosted, linux]
Docker builds add the runner user to docker (dedicated host only)
One job per runner --ephemeral
Remove ./config.sh remove --token <token>

Related: GitLab Runner and VPS for developers.

  • GitHub Actions
  • CI/CD
  • Self-Hosting