How to Set Up Self-Hosted GitHub Actions Runners on a VPS
Move GitHub Actions CI onto your own Linux server, step by step, with the cost math, secure setup, several runners, an offline alert and common fixes.
By Paul Peery
· 10 min read

GitHub charges for hosted runner time on private repositories, but jobs on your own server don't use your included compute. This guide takes an empty Linux VPS to several runners that work through your CI, restart themselves and alert you if they go offline. Plan on about an hour. You need to be comfortable pasting commands into an SSH session, not to be a Linux expert.
Check That It Saves You Money#
You're swapping a per-minute bill for a fixed server bill plus some upkeep. At the time of writing, GitHub's billing docs and runner price list say:
| Item | Cost |
|---|---|
| Self-hosted runner usage | Free; it doesn't reduce your included compute |
| Standard hosted runners on public repos | Free |
| Standard hosted Linux 2-core, private repo | $0.006 a minute |
| Included compute each month | $12 on Free, $18 on Pro or Team |
To compare, add up the job-minutes in a typical run, since parallel jobs each count. Then:
hosted bill = (runs a month × job-minutes per run × $0.006) − your included computeAs an example, 60 runs of 300 job-minutes on Pro is 18,000 minutes, or $108, minus $18, for a $90 monthly bill. A $30 VPS would save $60 a month. Those are sample figures, so use your own numbers and a real server quote, and count your time too.
The price could change. In December 2025 GitHub announced a $0.002-a-minute charge for self-hosted runners from March 2026, then postponed it to re-evaluate. Some articles still describe that fee as active, so check the billing page before you commit. Artifact and cache storage also keep their own limits, wherever your jobs run.
Use This With Private Repositories Only#
GitHub warns that on a public repository, a fork's pull request could run dangerous code on your machine. Public repos already get standard hosted runners free, so there's no reason to take that risk. On a private repo, everyone with write access can still run anything on the server, so limit that access to people you trust.
Keep the server dedicated to CI. Runners keep their state between jobs, so don't put your website, a database or production secrets on it.
Step 1: Choose a Server#
Pick Ubuntu 24.04 LTS on x64 for these commands. GitHub also supports other Linux systems, including Debian 10 or later. One runner handles one job at a time, so the runner count sets how many jobs run at once.
| Server | Runners | Notes |
|---|---|---|
| 2 cores, 8 GB | 1 | A starting guess for light jobs |
| 4 cores, 16 GB | 2–3 | A starting guess; watch the load during a full run |
| 8 cores, 32 GB | 6 | Measured: about 300 job-minutes finished in about 55 minutes, against 1h 45m with three runners |
Start low, because adding a runner takes minutes. Too many hurts more than too few: on an overloaded server, jobs ran about ten times slower and hit their time limits. Choose plenty of disk as well.
Step 2: Secure the Server#
SSH in as root and run the commands in steps 2 to 6 there. First, update everything and turn on automatic security patches:
apt update && apt -y upgrade
apt -y install unattended-upgrades ufw sudo git curl jq unzip zip ca-certificates build-essential
dpkg-reconfigure -f noninteractive unattended-upgradesRunners only make outgoing connections, so SSH is the only port you need to open. If your SSH doesn't use port 22, run ufw allow YOURPORT/tcp first so you don't lock yourself out.
ufw allow OpenSSH
ufw --force enableIf you log in with an SSH key, turn off password logins too. Keep this session open and test a new login in a second window before closing it.
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/10-no-passwords.conf
sshd -t && systemctl reload sshStep 3: Create a Runner User#
Jobs run whatever your workflow says, so they shouldn't run as root:
adduser --disabled-password --gecos "" runnerDon't give this user sudo. Avoid the docker group too unless your jobs need it, because Docker access is effectively root access.
Step 4: Install the Tools Your Jobs Need#
GitHub's hosted machines come with many tools installed, while yours has only what you add. Actions like actions/setup-node still fetch their own versions. If your project uses Node.js, install it, changing 24 to your version:
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt -y install nodejs
corepack enableIf you use Playwright, install Chromium's system libraries now, because the runner user can't later:
npx --yes playwright install-deps chromiumDon't try to guess everything. If a job later fails with command not found, install that tool as root and re-run the job.
Step 5: Get a Registration Token#
In your repo, go to Settings > Actions > Runners > New self-hosted runner and choose Linux and x64. Copy the value after --token from the Configure section. It expires after one hour, so treat it like a password.
Step 6: Install the Runners#
Change the first three lines, then paste the whole block. It gives each runner its own folder and sets it up as a service that starts on boot. The prompt keeps the token out of your shell history.
REPO=your-name/your-repo
LABEL=my-vps
COUNT=3
read -rsp "Paste the token from GitHub: " TOKEN; echo
V=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | tr -d v)
curl -fsSLo /tmp/runner.tgz "https://github.com/actions/runner/releases/download/v$V/actions-runner-linux-x64-$V.tar.gz"
for n in $(seq 1 "$COUNT"); do
d=/home/runner/actions-runner-$n
mkdir -p "$d"
tar xzf /tmp/runner.tgz -C "$d"
chown -R runner:runner "$d"
[ "$n" = 1 ] && "$d/bin/installdependencies.sh"
sudo -u runner bash -c "cd '$d' && ./config.sh --unattended --url 'https://github.com/$REPO' --token '$TOKEN' --name '$LABEL-$n' --labels '$LABEL' --work _work"
(cd "$d" && ./svc.sh install runner && ./svc.sh start)
done
rm -f /tmp/runner.tgz
unset TOKENCheck that each service is running, then reload the Runners page on GitHub, where each should show as Idle:
systemctl list-units --plain --no-legend 'actions.runner.*' | awk '{print $1, $4}'To add a runner later, run the same block with a fresh token and a loop over just the new numbers, such as for n in 4; do.
Step 7: Run a Test Job#
Add this as .github/workflows/runner-test.yml on your default branch, since manual runs are only offered for workflows there. Use your own label:
name: Runner test
on: workflow_dispatch
permissions:
contents: read
jobs:
hello:
runs-on: my-vps
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: |
echo "Running on $RUNNER_NAME as $(whoami)"
nproc
free -h
df -h / /tmpFrom the Actions tab, choose Runner test > Run workflow. The log should name your runner and show the user runner.
Step 8: Add a Switch to Your CI#
Don't hard-code your server's label into every job. GitHub keeps a job queued for up to 24 hours when no matching runner is online, then fails it. Read the runner from a repository variable, with GitHub's machines as the fallback:
jobs:
test:
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}After you commit that, one setting moves all your CI. Using the GitHub CLI on your own computer:
gh variable set CI_RUNNER --body my-vps # use your server
gh variable delete CI_RUNNER # back to GitHub's machinesJobs already queued keep waiting after a switch, so cancel and re-run them.
Step 9: Fix What Assumes a Fresh Machine#
A hosted job gets a new machine to itself. Yours is shared and lasts for months, so run a full CI pass and fix whichever of these appears.
Jobs can't use sudo. Skip steps that need it on your server, since you installed those libraries in step 4:
- run: npx playwright install $([ "$RUNNER_ENVIRONMENT" = "self-hosted" ] || echo --with-deps) chromium
- name: Install system packages
if: runner.environment != 'self-hosted'
run: sudo apt-get install -y some-packageFixed ports collide. Two jobs can't both listen on port 3000. Pick a random port, or use port 0 if your tool supports it:
- run: echo "PORT=$((20000 + RANDOM % 20000))" >> "$GITHUB_ENV"Shared caches race. All the runners share one home folder, so one job can read a file another is still writing. WordPress Playground did this with its cached zip, failing with Could not unzip file. Error code: 19. Giving the tool a home folder per runner fixes it:
- run: |
if [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then export HOME="$HOME/.tool-homes/$RUNNER_NAME"; mkdir -p "$HOME"; fi
your-tool-command-hereStuck jobs hold a runner. Set timeout-minutes on every job.
Step 10: Automate Cleanup#
Nothing resets your disk between jobs, and cancelled jobs leave files behind. These daily tasks remove temporary files older than three hours and job folders older than two days:
cat > /etc/cron.d/runner-clean <<'EOF'
30 4 * * * root find /tmp -mindepth 1 -maxdepth 1 -user runner -mmin +180 -exec rm -rf {} +
0 5 * * * root find /home/runner/actions-runner-*/_work -mindepth 1 -maxdepth 1 -mtime +2 -exec rm -rf {} +
EOF
chmod 644 /etc/cron.d/runner-cleanIf df -h /tmp shows tmpfs, then /tmp is held in memory and is small. Point jobs that make lots of temporary files at the runner's own folder, which is emptied after every job:
- run: echo "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV"Step 11: Add an Offline Alert#
A dead server doesn't produce a failed job. Jobs just sit in the queue. This workflow runs on GitHub's machines and fails when jobs have waited over 20 minutes with nothing running, which triggers GitHub's usual failure email:
name: Runner watch
on:
schedule:
- cron: "41 * * * *"
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
watch:
if: ${{ vars.CI_RUNNER != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
env:
GH_TOKEN: ${{ github.token }}
LABEL: ${{ vars.CI_RUNNER }}
steps:
- name: Look for stuck jobs
run: |
cutoff=$(date -u -d '-20 minutes' +%Y-%m-%dT%H:%M:%SZ)
stuck=0
running=0
for status in queued in_progress; do
for run in $(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?status=${status}&per_page=30" --jq '.workflow_runs[].id'); do
jobs=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run}/jobs?per_page=100" --jq '.jobs')
r=$(echo "$jobs" | jq --arg l "$LABEL" '[.[] | select(.status=="in_progress" and (.labels | index($l)))] | length')
s=$(echo "$jobs" | jq --arg l "$LABEL" --arg c "$cutoff" '[.[] | select(.status=="queued" and (.labels | index($l)) and .created_at < $c)] | length')
running=$((running + r))
stuck=$((stuck + s))
done
done
echo "running: ${running}, waiting over 20 minutes: ${stuck}"
if [ "$stuck" -gt 0 ] && [ "$running" -eq 0 ]; then
echo "::error::${stuck} job(s) have waited over 20 minutes for '${LABEL}' and none is running."
exit 1
fiThis uses hosted minutes on purpose, since it has to work when your server doesn't. Hourly is about 720 runs a month. At one minute each that's about $4.32 of your allowance, so run it less often if you'd like to spend less.
Looking After Your Runners#
Leave the runner's automatic updates on. GitHub stops sending jobs to a runner that goes more than 30 days without an available update. Reboot when /var/run/reboot-required exists, ideally while nothing is running; the services come back on their own.
During a full CI run, check uptime. Load staying well above your core count means you have too many runners for the machine. GitHub also removes a runner that hasn't connected for more than 14 days, so a server left off that long needs registering again.
When Something Breaks#
If a release is blocked, move CI first and investigate later: run gh variable delete CI_RUNNER, then cancel and restart the waiting runs.
- Jobs stay queued. The runners are offline, or
runs-ondoesn't match your label exactly. Check the Runners page and the service list from step 6. - Two-minute jobs take twenty, or every runner says busy while nothing runs. The machine is overloaded, or a cancelled job left processes behind. Look with
ps -u runner -o pid,etime,%cpu,%mem,cmd --sort=-%cpu. Rebooting is the simplest cure. Then cancel and restart any run that was going at the time. ENOSPCorUnknown system error -122. The disk or/tmpis full. Checkdf -h / /tmpand clear old files with the commands from step 10.EADDRINUSE. Two jobs want one port. Use the random port from step 9.- Missing shared libraries after a Playwright upgrade. Run the
install-depscommand from step 4 again. - Runners stopped, and
systemctl restartwith a wildcard finds nothing. Wildcards only match loaded units. Start each by its full name fromsystemctl list-unit-files 'actions.runner.*', or reboot.
Never clean up with pkill -f node. The runner service is itself a Node process, so this stops every runner on the machine. Kill leftovers by a specific name instead, such as chrome or playwright.
Repairing a Broken Runner Update#
Sometimes a runner's self-update leaves an incomplete folder. Jobs on those runners fail at once with Cannot find module '../lib/cli.js' from an externals.<version> path, while other runners pass, and re-running doesn't help. Set the version and runner numbers from the error, then swap in a clean copy:
v=2.338.2
broken="1 2 3"
curl -fsSLo /tmp/runner.tgz "https://github.com/actions/runner/releases/download/v$v/actions-runner-linux-x64-$v.tar.gz"
rm -rf /tmp/r && mkdir /tmp/r && tar xzf /tmp/runner.tgz -C /tmp/r
for n in $broken; do
unit=$(systemctl list-units --all --plain --no-legend "actions.runner.*-$n.service" | awk '{print $1}')
systemctl stop "$unit"
rm -rf "/home/runner/actions-runner-$n/externals.$v"
cp -a /tmp/r/externals "/home/runner/actions-runner-$n/externals.$v"
chown -R runner:runner "/home/runner/actions-runner-$n/externals.$v"
systemctl start "$unit"
done
rm -rf /tmp/r /tmp/runner.tgzThen re-run the failed jobs.
Once CI is settled, the rest of your release path is the next thing to look at. The deploying guide covers putting a template online, and the launch checklist covers what to check before you announce it.

About the author
Paul Peery
Founder of Freeframe
Paul Peery runs Freeframe, which publishes complete multi-page website templates in HTML, Next.js, Astro and WordPress, and writes about getting small websites built, launched and kept up to date.


