Tools: Free CI Minutes Are Gone: Setting Up a GitHub Actions Self-Hosted Runner on Linux

Tools: Free CI Minutes Are Gone: Setting Up a GitHub Actions Self-Hosted Runner on Linux

Why Not Just Pay?

The Setup

1. Create a Dedicated User

2. Download and Verify the Runner

3. Get a Registration Token

4. Register the Runner

5. Install as a systemd Service

Security Hardening

Updating Your Workflow Files

The Moment It Worked

Trade-offs at a Glance

Conclusion GitHub gives you 3,000 free Actions minutes per month on private repositories. That sounds like a lot until you're running multi-step CI pipelines on every PR, every push, every little tweak. Then it's not a lot. Then you're watching the counter drop and quietly calculating how many commits you can still make this month. The obvious fix is paying more. The other fix is already having a server sitting there. I had a server sitting there. I set it up as a self-hosted runner. Here's what that looked like. I don't have a strong principled reason. The server was running anyway, jobs were queuing, and the quota was at zero. The path of least resistance was pointing GitHub at the machine I already had. Self-hosted runners also run on your hardware, in your network, with your dependencies already installed — which means no time spent on "install Python 3.12, install dependencies, wait for cache" every single run. The first run is slower (Poetry installs everything fresh); subsequent runs are faster because the virtualenv is already there. Don't run the runner as root. Create a system user for it: -r makes it a system account (UID < 1000), -m creates the home directory, -d sets it to /opt/github-runner. No sudo access, no shell login by default. Exactly what you want. Find the latest version at github.com/actions/runner/releases, then: SHA256 verification matters here. You're downloading an executable that will have significant access to your server. Go to your repository → Settings → Actions → Runners → New self-hosted runner. GitHub will show you a registration token. Copy it — it expires in about an hour. Run this as the github-runner user, not root. The --labels let you target this specific runner in your workflow YAML. --unattended skips the interactive prompts. When it works, you'll see: You want active (running). Check the logs: The line you're looking for: Listening for Jobs. Once you see that, the runner is up and waiting. The runner registration flow — straightforward once you have the token. A few things worth doing before you call it done: File permissions. The runner directory should be owned by the runner user only: Limit what the runner user can do. Don't add it to sudoers unless your jobs actually need it. If they do, scope the permissions tightly with a specific sudoers rule rather than giving full sudo access. Consider ephemeral runners for sensitive repos. For public repos especially, ephemeral runners run each job in a fresh environment and auto-deregister. For a private repo on your own hardware, persistent runners are usually fine — just be aware of the trade-offs. Change runs-on in every workflow YAML: The labels in runs-on must match what you set with --labels during registration. If you have multiple runners with different labels (e.g., prod, staging), you can target them precisely. I pushed a PR, watched the workflow page, and saw: Not on a GitHub-hosted VM spinning up somewhere in Azure. On the machine I was sitting in front of. The first run took a while — Poetry was installing everything fresh. After that, runs were noticeably faster because the virtualenv persisted between jobs. Neither is universally better. If you're under the free tier, GitHub-hosted is the right default. Once you've burned through the quota, self-hosted makes sense if you have spare server capacity. The setup takes about 20 minutes end-to-end: create the user, download the runner, register it, install the service, update the workflow files. The tricky part is the registration token — it expires quickly, so have the config.sh command ready before you generate it. Once it's running, you stop thinking about it. Jobs queue, the runner picks them up, logs stream in. The CI pipeline works exactly the same as before — just on your hardware instead of GitHub's. The counter is back to zero. I mean, my quota is back to 3,000. The counter is on my server now. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

useradd -r -m -d /opt/github-runner -s /bin/bash \ -c "GitHub Actions Runner" github-runner useradd -r -m -d /opt/github-runner -s /bin/bash \ -c "GitHub Actions Runner" github-runner useradd -r -m -d /opt/github-runner -s /bin/bash \ -c "GitHub Actions Runner" github-runner cd /opt/github-runner curl -o actions-runner-linux-x64-2.334.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.334.0/actions-runner-linux-x64-2.334.0.tar.gz # Verify SHA256 (hash is in the release body) echo "048024cd2c848eb6f14d5646d56c13a4def2ae7ee3ad12122bee960c56f3d271 actions-runner-linux-x64-2.334.0.tar.gz" | sha256sum -c tar xzf actions-runner-linux-x64-2.334.0.tar.gz cd /opt/github-runner curl -o actions-runner-linux-x64-2.334.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.334.0/actions-runner-linux-x64-2.334.0.tar.gz # Verify SHA256 (hash is in the release body) echo "048024cd2c848eb6f14d5646d56c13a4def2ae7ee3ad12122bee960c56f3d271 actions-runner-linux-x64-2.334.0.tar.gz" | sha256sum -c tar xzf actions-runner-linux-x64-2.334.0.tar.gz cd /opt/github-runner curl -o actions-runner-linux-x64-2.334.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.334.0/actions-runner-linux-x64-2.334.0.tar.gz # Verify SHA256 (hash is in the release body) echo "048024cd2c848eb6f14d5646d56c13a4def2ae7ee3ad12122bee960c56f3d271 actions-runner-linux-x64-2.334.0.tar.gz" | sha256sum -c tar xzf actions-runner-linux-x64-2.334.0.tar.gz sudo -u github-runner ./config.sh \ --url https://github.com/your-username/your-repo \ --token YOUR_REGISTRATION_TOKEN \ --name prod-server-01 \ --labels "self-hosted,linux,prod" \ --unattended sudo -u github-runner ./config.sh \ --url https://github.com/your-username/your-repo \ --token YOUR_REGISTRATION_TOKEN \ --name prod-server-01 \ --labels "self-hosted,linux,prod" \ --unattended sudo -u github-runner ./config.sh \ --url https://github.com/your-username/your-repo \ --token YOUR_REGISTRATION_TOKEN \ --name prod-server-01 \ --labels "self-hosted,linux,prod" \ --unattended √ Connected to GitHub √ Runner successfully added √ Settings Saved √ Connected to GitHub √ Runner successfully added √ Settings Saved √ Connected to GitHub √ Runner successfully added √ Settings Saved cd /opt/github-runner sudo ./svc.sh install github-runner sudo ./svc.sh start cd /opt/github-runner sudo ./svc.sh install github-runner sudo ./svc.sh start cd /opt/github-runner sudo ./svc.sh install github-runner sudo ./svc.sh start sudo systemctl status actions.runner.*.service sudo systemctl status actions.runner.*.service sudo systemctl status actions.runner.*.service sudo journalctl -u actions.runner.*.service -n 50 --no-pager sudo journalctl -u actions.runner.*.service -n 50 --no-pager sudo journalctl -u actions.runner.*.service -n 50 --no-pager chown -R github-runner:github-runner /opt/github-runner chmod 700 /opt/github-runner chown -R github-runner:github-runner /opt/github-runner chmod 700 /opt/github-runner chown -R github-runner:github-runner /opt/github-runner chmod 700 /opt/github-runner # Before jobs: test: runs-on: ubuntu-latest # After jobs: test: runs-on: [self-hosted, linux, prod] # Before jobs: test: runs-on: ubuntu-latest # After jobs: test: runs-on: [self-hosted, linux, prod] # Before jobs: test: runs-on: ubuntu-latest # After jobs: test: runs-on: [self-hosted, linux, prod] Running job: Backend Lint (ruff) Running job: Backend Lint (ruff) Running job: Backend Lint (ruff)