GitHub Actions is a tool I frequently use to manage my CI/CD workflows. Especially for many of my projects, automating build and test steps has been a huge convenience. However, I eventually realized that GitHub’s hosted runners could be costly, especially for projects with intense workloads and long-running builds. This led me to wonder if I could reduce these costs by taking control of my own runner.
With this thought in mind, I embarked on the journey of moving my GitHub Actions runner to my own virtual private server (VPS). This process not only helped me optimize costs but also gave me more control over my build environment. Having my own runner has proven to be invaluable for handling specific software dependencies, custom network configurations, or high-performance requirements.
Why Did I Need My Own GitHub Actions Runner?
GitHub’s hosted runners are an excellent solution for many scenarios. However, as a developer with specific needs, I encountered some limitations. The primary reasons I decided to set up my own runner were:
Cost Optimization
For some of my projects, build times were becoming excessively long, especially when dealing with large dependencies. This led to high costs with GitHub’s free tiers. By hosting my runner on my VPS, I could utilize the existing resources more efficiently and reduce costs. For instance, one of my side projects had a CI/CD pipeline that consumed over 2000 minutes of usage per month, which translated to a significant cost with GitHub.
Custom Environment and Dependencies
Sometimes, I needed software dependencies or custom network configurations that weren’t available in GitHub’s default runner images. By hosting my own runner, I could easily install and configure the required software and dependencies. For example, I had an old C++ project that required a specific GCC version, or a custom netcat variant for some network tests. On one client project, for security reasons, builds had to egress only from a specific IP range, which made setting up my own runner mandatory.
Performance Requirements
For large monorepos or complex build processes, projects might require more CPU, RAM, or faster disk I/O. While GitHub’s standard runners offer a certain level of performance, I could shorten build times by renting a more powerful VPS or by using my existing server’s idle resources. Disk I/O can be critical, especially when building Docker images or downloading large numbers of dependencies. By using NVMe disks on my own server, I sped up this process significantly.
Preparation Phase: VPS and Prerequisites
Before setting up my runner, I needed to choose a suitable VPS and ensure the necessary prerequisites were in place. These steps were critical for the installation process to proceed smoothly.
VPS Selection and Characteristics
I typically prefer Ubuntu or Debian-based Linux distributions due to their stability, extensive package repository, and seamless systemd integration. For a runner, the minimum requirements depend on the project’s size, but a general starting point would be:
- CPU: 2 vCPU
- RAM: 4 GB
- Disk: 60 GB SSD (adequate space for build caches and dependencies)
My choice was a VPS with 4 vCPU, 8 GB RAM, and a 160 GB NVMe disk, which also hosts the backends of the other side products I use, allowing me to make more efficient use of my existing resources.
Installation of Required Packages
After SSHing into my VPS, I installed the basic tools required for the runner’s operation.
sudo apt update
sudo apt upgrade -y
sudo apt install -y curl git jq
curl: For downloading the GitHub Actions runner package.git: For cloning repositories and performing operations.jq: For processing JSON output (often used in runner scripts).
GitHub Actions Runner Installation: Step-by-Step
Now, let’s move on to the actual topic: the steps for downloading, installing, and configuring the runner software. This process is also explained in detail in GitHub’s own documentation, but I’ll share some tips along with my own experiences.
1. Obtain a Runner Token
First, I needed to obtain a runner token from GitHub. This token enables secure communication between the runner and GitHub. I retrieved the token by navigating to my repository’s or organization’s settings:
- Repository Level:
Your repository->Settings->Actions->Runners->New self-hosted runner - Organization Level:
Your organization->Settings->Actions->Runners->New self-hosted runner
When you select Linux as the operating system here, it will give you the steps and the token. Copy the token, because we’ll need it during installation.
2. Create the Runner Directory and Set Permissions
It’s essential to create a dedicated directory for the runner and set the correct permissions. For security reasons, I prefer to run the runner with an unprivileged user instead of the root user. This prevents a potential security vulnerability from spreading across the entire system.
sudo mkdir /actions-runner
sudo chown mustafa:mustafa /actions-runner # Replace with your username
cd /actions-runner
I used my own username, mustafa. You should replace this with your actual username.
3. Download and Extract the Runner Software
Using the GitHub-provided commands, I downloaded and extracted the runner package. These commands are also included in the steps shown to you above when obtaining the token.
# Example commands (check the GitHub page for the current version)
curl -o actions-runner-linux-x64-2.309.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.309.0/actions-runner-linux-x64-2.309.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.309.0.tar.gz
With these commands, the runner software files will be extracted into the actions-runner directory.
4. Configure the Runner
After extracting the package, we need to run the config.sh script to connect the runner to our GitHub account.
./config.sh --url https://github.com/MustafaErbay/my-repo --token <YOUR_TOKEN> --name my-vps-runner --labels linux,x64,self-hosted --unattended
--url: The URL of your repository or organization. For the backend project of one of my side products, I used a URL likehttps://github.com/MustafaErbay/my-repo.--token: The token you just obtained.--name: The name you’ll give the runner. It will appear with this name in the GitHub Actions interface.--labels: The labels you’ll assign to the runner. You can target specific runners in your workflows using these labels. Using general labels likelinux,x64, andself-hostedis a good start.--unattended: This parameter lets the configuration complete without asking additional questions.
After completing this step, you should see your new runner in an “Idle” state under the “Runners” tab in the GitHub Actions interface.
Management and Security with systemd
Instead of running the runner manually, configuring it as a systemd service ensures it starts automatically when the server reboots and runs continuously in the background. It also offers great advantages in terms of resource management and log tracking.
1. Create the systemd Unit File
I created a file named /etc/systemd/system/actions.runner.service and added the following content to it:
[Unit]
Description=GitHub Actions Runner
After=network.target
[Service]
ExecStart=/actions-runner/run.sh
WorkingDirectory=/actions-runner
User=mustafa
Group=mustafa
Restart=always
RestartSec=10
LimitNOFILE=1024
LimitNPROC=1024
TimeoutStopSec=5min
# Memory limits (soft limit, sends a warning)
MemoryHigh=4G
[Install]
WantedBy=multi-user.target
UserandGroup: Specify which user and group the runner will run under. It should absolutely not be therootuser. I used my own username.WorkingDirectory: The directory where the runner will operate.ExecStart: The script that starts the runner.Restart=always: Ensures the service is automatically restarted if it fails or stops. This is critical for keeping the runner continuously available.MemoryHigh=4G: This is a soft memory limit enforced bycgroup. If the memory the runner uses exceeds 4 GB, the system sends a warning and tries to free memory, but doesn’t terminate the process directly. This way, I can intervene before falling into anOOM-killedsituation.
2. Enable and Start the systemd Service
After saving the unit file, we need to reload systemd, enable the service, and start it:
sudo systemctl daemon-reload
sudo systemctl enable actions.runner.service
sudo systemctl start actions.runner.service
To check the status of the service:
sudo systemctl status actions.runner.service
You should see the green “active (running)” message.
3. Log Tracking and Debugging
Thanks to systemd, I can track the runner’s logs with journald. This is an invaluable resource for debugging in case of any problem.
journalctl -u actions.runner.service -f
This command shows the logs of the runner service in real time. Once, I noticed that the runner kept shutting down and restarting. When I examined the journalctl output, I saw that it was constantly being OOM-killed because of a dependency. This made me realize that I needed to adjust the MemoryHigh limit and optimize memory usage in my build steps.
CI/CD Pipeline Integration and Tests
Now that my runner is up and running, I can start using it in my GitHub Actions workflows.
Updating the Workflow File
In the YAML files in your .github/workflows directory, you should change the runs-on keyword to your own runner’s labels.
For example, in my main.yml file:
name: CI/CD Pipeline
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build_and_test:
runs-on: [self-hosted, linux, x64] # My own runner labels
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build project
run: npm run build
This way, GitHub will look for a suitable runner with the self-hosted, linux, and x64 labels to run this workflow.
A Simple Test Workflow
After the first integration, I always run a simple test workflow. This lets me make sure the runner is working correctly and can communicate with GitHub.
name: Self-Hosted Runner Test
on:
workflow_dispatch:
jobs:
test_runner:
runs-on: [self-hosted, linux, x64]
steps:
- name: Check hostname
run: hostname
- name: Check disk usage
run: df -h
- name: List current user
run: whoami
- name: Display environment variables
run: env
By running this workflow manually (workflow_dispatch), I can check my runner’s hostname, disk usage, and which user it runs under. This is a quick way to verify that the environment works as I expect.
Performance Comparison
After I started using my own runner, I noticed a visible improvement in build times. Especially in steps where large dependencies were downloaded or Docker images were built, my VPS with its own NVMe disks was faster than GitHub’s hosted runners. For example, a build that took 15 minutes on a production ERP dropped to an average of 5-7 minutes on my own runner. This was a significant gain in terms of both time and cost.
Maintenance and Updates
Setting up a self-hosted runner is just the beginning. Keeping it up to date and healthy is also important.
Keeping the Runner Software Up to Date
GitHub updates the runner software regularly. These updates may include new features, performance improvements, and security patches. To keep my runner up to date, I usually follow these steps:
- I check whether there’s a new version on the GitHub Actions “Runners” page.
- If there’s a new version, I stop the runner service:
sudo systemctl stop actions.runner.service - I go to the
/actions-runnerdirectory and back up or delete the existing files. - I download and extract the new version’s
tar.gzfile. - I run the
./bin/installdependencies.shscript to update the dependencies (sometimes necessary). - I reconfigure the runner (
./config.sh, orunconfig.shthenconfig.sh). - I restart the service:
sudo systemctl start actions.runner.service
Disk Space Management
Dependencies downloaded during CI/CD processes, build outputs, and temporary files can fill up disk space over time. Especially if you work with Docker images, Docker’s own cache can take up a lot of space too. For this reason, I regularly check disk usage and clean up unnecessary files.
df -h # Check disk usage
sudo du -sh /actions-runner # Check the size of the runner directory
sudo docker system prune -a # Clean the Docker cache (if you use Docker)
Once, during a Docker build, the disk filled up to 100% and the runner crashed. This situation made me realize I needed to add the docker system prune command to the end of my CI/CD pipeline.
Security Updates
Keeping my VPS’s operating system and all installed packages regularly up to date is critical for security.
sudo apt update
sudo apt upgrade -y
sudo reboot # A restart may be needed after kernel updates
I also take additional security measures, such as blacklisting kernel modules. For example, I had blacklisted the algif_aead module because of a CVE (such as CVE-2026-31431).
Potential Problems and Their Solutions
Some common problems I encountered while using a self-hosted runner, and the solutions I came up with, were:
The Runner Going Offline
Sometimes the runner can drop offline for unexpected reasons. This is usually due to network outages, server resource shortages (OOM-killed), or a bug in the runner software.
- Solution: I check the service’s status with
sudo systemctl status actions.runner.service. If it has stopped, I examine the logs withjournalctl -u actions.runner.service -f. It usually restarts automatically thanks to theRestart=alwayspolicy, but if it keeps shutting down, you need to find the root cause.
OOM-Killed Processes
Insufficient memory is a frequently encountered situation, especially in large projects or memory-intensive operations. I once ran into this error by writing sleep 360 and using a long sleep command instead of a polling-wait mechanism. The runner stayed idle for a long time, and when memory consumption increased, it got OOM-killed.
- Solution: I review the
MemoryHighorMemoryMaxlimits in thesystemdunit file. I analyze and optimize the memory usage in the workflow steps. For example, when installing Node.js dependencies, I can usenpm ci --no-optionalto skip unnecessary dependencies.
Network Problems
Firewall settings, proxy configurations, or DNS issues can prevent the runner from communicating with GitHub.
- Solution: I test the basic network connection with the
curl -v https://github.comcommand. If I’m using a proxy, I make sure I’ve added theHTTP_PROXYandHTTPS_PROXYsettings to the runner’s environment variables.MTU/MSSmismatches can also sometimes cause hidden problems, especially within VPN topologies. I test for these situations with commands likeping -M do -s 1472 google.com.
Docker Build Issues and Disk Fires
In projects that use Docker, disk space can fill up quickly due to the build cache or layers. OOM errors can also occur during docker build.
- Solution: I do regular cleanup with the
docker system prune -acommand I mentioned above. I also optimize myDockerfileto reduce the number of layers and exclude unnecessary files from the build context (.dockerignore). I can apply memory and CPU limits to Docker containers as well usingcgroup.
File Integrity Monitoring
To make sure the files in the runner directory aren’t modified by unauthorized people, I monitor file integrity with tools like auditd. This is especially important in projects with high security sensitivity.
sudo auditctl -w /actions-runner -p rwxa -k github-runner-integrity
This rule monitors all write, read, execute, and attribute changes in the /actions-runner directory and logs them with the github-runner-integrity key.
Conclusion
Moving my GitHub Actions runner to my own VPS was an important learning process for me. In addition to reaching my initial cost-optimization goal, I gained more control and flexibility over my CI/CD environment. By making use of my own server’s idle resources, I both protected my budget and was able to respond more quickly to my projects’ specific needs.
In this process, I also reinforced my Linux system administration skills, such as service management with systemd, resource limits with cgroup, and log tracking with journald. We must not forget that managing a self-hosted runner requires more responsibility than GitHub’s hosted runners; however, with correct configuration and regular maintenance, it’s possible to handle these responsibilities.
As a next step, I’m planning to set up an auto-scaling structure that can automatically scale multiple runners. This way, I’ll be able to dynamically start and stop new runners according to the workload, and optimize my resource usage even further.