VPS Tutorials
Set Up a GitHub Actions Self-Hosted Runner on a VPS (2026): Installation, Docker Builds, Caching, Auto-Updates and Security Isolation
A complete tutorial for deploying a GitHub Actions self-hosted runner on a VPS, covering configuration choices, running it as a Linux service, labels, Docker builds, caching and disk, security isolation, auto-updates and common troubleshooting.

Set Up a GitHub Actions Self-Hosted Runner on a VPS (2026): Installation, Docker Builds, Caching, Auto-Updates and Security Isolation
When GitHub Actions jobs keep queuing, hosted runner minutes run short, or builds must reach an internal database, many people's first instinct is to buy a VPS and run a self-hosted runner.
This path can indeed save money and make the build environment fully controllable. But it is not "free compute you set up and forget": code in your workflows executes directly on your VPS. What the runner can read, what it can connect to, and whether it can operate Docker determine how big the impact will be when something goes wrong.
This article covers one thing only: using an Ubuntu/Debian VPS to install, service-ize and manage a GitHub Actions self-hosted runner reliably.
If the project is public, jobs are few, and there is no need for internal network access, I would use GitHub-hosted runners first. They provide a relatively clean environment each time, and you don't have to worry about machine maintenance.
Self-hosted runners are better suited to the following situations:
- Private repository builds take a long time and you want to reduce hosted minute consumption;
- Docker images are large and you want to reuse local layer caches;
- Builds need access to a VPN, internal services, or a fixed egress IP;
- You need a special CPU architecture, system dependencies, or hardware;
- You already have an idle VPS and are willing to bear the security and operations costs.
If your real need is "build on GitHub-hosted runners, then deploy the image to a VPS", see Migrating from Manual Compilation on a VPS to GitHub Actions + Docker instead. This article covers the reverse: making the VPS itself the runner that executes build jobs.
The runner program itself is lightweight; what really consumes resources is your compilation, testing and Docker builds.
| Use case | Suggested starting specs | Disk recommendation |
|---|---|---|
| Shell, Go, small Node tests | 2 cores, 2GB | 30GB SSD |
| Node/Java builds, ordinary Docker images | 2–4 cores, 4GB | 60GB SSD |
| Multi-stage images, large frontends or parallel tests | 4 cores, 8GB+ | 100GB+ SSD |
Disk usually fills up before memory. _work, Docker layers, tool caches and logs all accumulate over time. Before choosing a machine, see the VPS configuration checklist for development and testing; don't just look at the monthly price.
Below uses Ubuntu/Debian as an example:
sudo adduser --disabled-password --gecos "" github-runner
sudo apt update
sudo apt install -y curl ca-certificates git jq tar
sudo install -d -o github-runner -g github-runner /home/github-runner/actions-runner
The runner generally only needs to make outbound HTTPS connections to GitHub on port 443; you don't need to open public inbound ports specifically for it. If the VPS has a strict outbound firewall or proxy, allow traffic according to GitHub's list of communication domains.
Don't copy runner version numbers and checksums from old blog posts. Go to the repository:
Settings → Actions → Runners → New self-hosted runner
After selecting Linux and the actual architecture, GitHub generates the download, verification and configuration commands. Switch to the dedicated user and run the current commands shown on the page in the install directory:
sudo -iu github-runner
cd ~/actions-runner
#在这里执行 GitHub 页面当前显示的 curl、sha256sum 和 tar 命令
./config.sh \
--url https://github.com/OWNER/REPOSITORY \
--token REGISTRATION_TOKEN \
--name vps-runner-01 \
--labels docker \
--work _work
REGISTRATION_TOKEN is a registration token temporarily generated by GitHub; the official documentation currently states it is valid for 1 hour. It is not a long-term secret, so don't put it in scripts, screenshots or articles. If it expires, go back to the settings page and regenerate it.
After successful configuration, the runner by default carries the self-hosted, operating system and architecture labels; here docker was added additionally. Labels are only routing conditions; they do not automatically install Docker, nor do they verify that the machine actually has the corresponding capability.
First exit the dedicated user, then install the service in the runner directory:
exit
cd /home/github-runner/actions-runner
sudo ./svc.sh install github-runner
sudo ./svc.sh start
sudo ./svc.sh status
svc.sh creates actions.runner.*.service. To view live logs:
sudo journalctl -u 'actions.runner.*' -f
Seeing Idle on the GitHub Runners page means it is connected and waiting for jobs; Active means it is running a job; Offline usually means the service isn't started, the machine is offline, or it can't connect to GitHub.
Create a new .github/workflows/self-hosted-test.yml:
name: self-hosted-runner-test
on:
workflow_dispatch:
jobs:
test:
runs-on: [self-hosted, linux, x64, docker]
steps:
- uses: actions/checkout@v4
- run: |
uname -a
docker version
Every label in the array must match the same online runner at the same time. When a job stays at Queued, first check case, architecture and custom labels, then check whether this runner is allowed to be used by the current repository.
If the VPS is ARM64, change x64 to the ARM64 shown on the runner page; don't fake labels just to get jobs running.
If workflows need to build images, first install Docker Engine the official Docker way, then add the runner user to the docker group:
sudo usermod -aG docker github-runner
sudo ./svc.sh stop
sudo ./svc.sh start
Note: being able to access /var/run/docker.sock usually means being able to gain root-level control of the host. Workflows can mount host directories, read other containers' environment variables, and even start privileged containers.
So I would not mix a production database, business containers and a runner on the same VPS. At minimum:
- Serve only trusted private repositories;
- Use a separate VPS or separate VM for the runner;
- Set
GITHUB_TOKENpermissions to read-only by default, opening them minimally per job; - Pin third-party Actions to trusted versions, and to commit SHAs in critical scenarios;
- Don't let unreviewed fork PRs land on a persistent runner;
- Don't let the runner's network directly access the production internal network.
GitHub also explicitly warns: forks of public repositories can submit malicious workflows via pull requests, and self-hosted runners should almost never be used directly for public repositories.
These three concepts are easy to confuse:
actions/cacheis used to reuse dependencies or build caches; even with a self-hosted runner, caches are still stored in GitHub-managed cloud storage;- Artifacts are used to save the results of a workflow run, such as test reports, archives and binaries;
- The runner's local
_work, Docker layers, and npm or Maven caches stay on the VPS disk; they are fast but persist and may be polluted by later jobs.
For reproducible builds, treat local caches as an accelerator, not as the only copy. For Docker production healthchecks, logs and rollback ideas, see the Docker Compose production configuration guide.
Check at least once a week:
df -h
du -sh /home/github-runner/actions-runner/_work
docker system df
journalctl -u actions.runner.* --disk-usage
Don't just run docker system prune -af the moment you see the disk is full. It deletes unused containers, networks, images and build caches; if you add --volumes, it may also delete unused volumes.
A safer order is: confirm no jobs are currently running → delete old work directories you are sure you don't need → use docker builder prune to clean rebuildable build caches → then handle old images according to the checklist. For important data, first verify recovery using the backup and restore drill guide.
The runner auto-updates by default: it updates when it finds a new version while running a job, and if idle for a long time it will also try to update within a week of a release.
For persistent VPS deployments, keep the default auto-update. If you used --disableupdate during registration, GitHub requires upgrading within 30 days of a new version release; for critical security updates, older versions may stop receiving jobs even earlier.
What you should really back up is not the runner binary, but the following rebuildable information:
- The repository or organization the runner belongs to;
- The user, directory, labels and work directory conventions;
- Docker and build dependency installation steps;
- Firewall and network policies;
- Monitoring, disk cleanup and rebuild scripts.
Being able to reinstall within half an hour after breaking the VPS is more practical than making a full-disk backup of _work.
cd /home/github-runner/actions-runner
sudo ./svc.sh status
sudo journalctl -u 'actions.runner.*' -n 200 --no-pager
sudo -u github-runner ./config.sh --check \
--url https://github.com/OWNER/REPOSITORY \
--pat YOUR_FINE_GRAINED_PAT
The detailed results of --check are written to _diag in the install directory. Don't disable TLS verification long-term just to save effort.
Check whether the runner is Idle, whether all runs-on labels match, and whether the repository has permission to use the runner. If there is only one runner process on the same machine, it can usually handle only one job at a time; while the previous one hasn't finished, later jobs can only queue.
First go to the Actions page to confirm whether the job is really still executing, then check _diag/Runner_*.log and _diag/Worker_*.log. If it's a runaway Docker build, keep the logs and process information first, confirm the impact before stopping the service, and don't just reboot the whole production machine.
sudo -u github-runner docker version
id github-runner
ls -l /var/run/docker.sock
After confirming the user group, restart the runner service so the new group permissions take effect. If it still fails, check rootless Docker or the socket path, rather than changing the service to run as root.
On the runner details page in GitHub, click Remove; the page generates a time-limited removal token. Then run the page's commands in the install directory:
sudo ./svc.sh stop
sudo ./svc.sh uninstall
sudo -u github-runner ./config.sh remove --token REMOVE_TOKEN
If the machine is already broken, you can force-remove it on the GitHub page. A normal persistent runner that hasn't connected for 14 consecutive days is also removed automatically, but don't treat this mechanism as a cleanup process.
Compute jobs run on your own machine, but caches, artifacts, networking and other GitHub services may still be affected by plan and billing rules. What you save is hosted compute resources; it doesn't mean all Actions costs drop to zero.
Yes, each runner uses its own directory, name and service. But they will compete for CPU, memory, disk and the Docker daemon. On a small machine it's better to install one first, then decide whether to scale based on job concurrency and queueing data.
Technically yes, but be cautious about security. A more stable approach is for the build runner to only produce signed images or artifacts, and for the deployment stage to go through environment approvals and a release identity with fewer permissions.
Before running it long-term, go through the VPS security hardening checklist and VPS monitoring and alerting configuration once more. The most valuable thing about a self-hosted runner isn't "free compute" but controllability; if permissions, disk and the rebuild process are also controllable, then it really does save time.