log in
consulting hosting industries the daily tools about contact

Self-hosted GitHub Actions runners on a $10 VPS: worth it, with caveats

Running GitHub Actions on your own VPS cuts CI costs to near zero. Here's the setup I use and the gotchas that will bite you if you skip the boring parts.

GitHub's hosted runners are convenient right up until you're paying $50/month for a Laravel app with a modest test suite. I moved several client projects to self-hosted runners on a $10 DigitalOcean Droplet and cut that bill to essentially zero. The setup takes about an hour. The gotchas take longer to find.

Why bother

GitHub gives you 2,000 free minutes per month on the free plan and 3,000 on Pro. Sounds generous. It isn't, once you have a few projects running tests, building Docker images, and deploying on every push. Minutes drain fast, and the per-minute rate after that ($0.008 for Linux) adds up quietly until you notice it on your bill.

Beyond cost: hosted runners are ephemeral 2-core machines with 7GB RAM. For a biotech client I work with, the test suite spins up a Postgres database, seeds several hundred thousand rows of synthetic lab data, and runs integration tests against it. On a hosted runner that took 14 minutes. On a $24 Droplet with 4 cores and 8GB RAM that I had sitting around for other work, it runs in under 4 minutes. You also get persistent disk, so Composer and npm caches actually survive between runs.

The basic setup

I run Debian 12 on my VPS. Create a dedicated user — don't run the runner as root.

useradd -m -s /bin/bash ghrunner
su - ghrunner
mkdir actions-runner && cd actions-runner

Grab the latest runner tarball from your repo's Settings > Actions > Runners > New self-hosted runner. GitHub generates the exact download URL and token for you. It looks like this:

curl -o actions-runner-linux-x64-2.317.0.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.317.0/actions-runner-linux-x64-2.317.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.317.0.tar.gz
./config.sh --url https://github.com/your-org/your-repo --token YOUR_TOKEN_HERE

Then install it as a systemd service (back as root):

./svc.sh install ghrunner
./svc.sh start

That's the 10-minute version. Now your workflow files just need runs-on: self-hosted and you're routing to your machine.

A workflow I actually use

Here's a stripped-down version of the deploy workflow I run for a Laravel e-commerce project. Tests, then deploy if on main.

name: Test and Deploy

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Install PHP dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Copy env
        run: cp .env.ci .env

      - name: Generate app key
        run: php artisan key:generate

      - name: Run migrations
        run: php artisan migrate --force

      - name: Run tests
        run: php artisan test --parallel

  deploy:
    runs-on: self-hosted
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via Deployer
        run: php vendor/bin/dep deploy production

Notice I'm using .env.ci — a committed, safe-for-CI environment file pointing at a local test database. The runner machine has Postgres and Redis installed directly. No service containers, no Docker-in-Docker, no waiting for images to pull. It's just there.

The gotchas. There are several.

The runner token expires. The registration token GitHub gives you during setup is valid for one hour. If you're copy-pasting it into a script you'll run later, it'll fail. Not a big deal once you know it, but confusing the first time.

Concurrent jobs will stomp on each other. This one bit me properly. By default, one runner handles one job at a time. That's fine. But if you configure the runner to handle multiple jobs concurrently — or register the same machine as multiple runners — and your workflow writes to shared paths (say, a .env file in the repo checkout directory), jobs will clobber each other mid-run. The fix is to always use $GITHUB_WORKSPACE, which is unique per job, and never write outside it during tests. Sounds obvious. It's not when you're adapting a workflow from a hosted runner where you never thought about it.

Your PATH isn't the login shell PATH. The runner executes as a non-login, non-interactive shell. That means ~/.bashrc and ~/.bash_profile don't load. If you installed PHP via phpenv, Node via nvm, or anything else that hooks into the shell profile, none of it will be in PATH. I lost an embarrassing amount of time on this with a client project where PHP 8.2 was installed via a custom path. The fix: use absolute paths, or set PATH explicitly in the workflow env block.

env:
  PATH: /usr/local/php82/bin:/usr/bin:/bin

Or just install PHP system-wide via apt and stop fighting it.

Security: this is the big one. Self-hosted runners on public repos are a known attack vector. A malicious pull request can run arbitrary code on your machine. GitHub's own docs say not to use self-hosted runners with public repos. I only use this setup for private repos. If you have a public repo and you're thinking about self-hosted runners, stop and use hosted runners or harden your setup extensively with ephemeral runners (each job gets a fresh VM, not a persistent one).

For private repos it's still worth thinking about what the runner user can access. My ghrunner user has no sudo, no SSH keys to production, and no access to anything outside its home directory and the project checkout path. Deployer runs as a separate user with its own locked-down SSH key. Don't let the runner user be your deploy user.

Disk fills up silently. Composer caches, Docker layer caches, old workspace directories from failed runs — they accumulate. I had a runner go dark at 2am because the disk hit 100%. Now I have a cron job:

# /etc/cron.daily/clean-runner
#!/bin/bash
find /home/ghrunner/actions-runner/_work -maxdepth 2 -name "vendor" \
  -mtime +7 -exec rm -rf {} + 2>/dev/null
docker system prune -f 2>/dev/null

Adjust for your setup. The point is: add monitoring and a cleanup job before you deploy this, not after.

Runner goes offline and nobody notices. The systemd service restarts on crash, but if the Droplet reboots and systemd fails to start the service for some reason, your CI silently stops working. Jobs queue up with a "Waiting for a runner" message that's easy to miss. I added a simple UptimeRobot monitor that hits the GitHub API and checks runner status, but honestly, just make sure your systemd service is set to restart on boot and test it.

systemctl enable actions.runner.your-org-repo.ghrunner.service

That line is in the svc.sh install output but it's easy to gloss over.

When I'd reach for this

I use self-hosted runners for:

  • Private repos where CI minutes are the cost driver
  • Projects with long-running test suites that benefit from persistent caching and more RAM
  • Projects where the runner machine doubles as a staging environment (the runner user can see the database directly)
  • Clients where I'm already running a managed VPS and have spare capacity

I wouldn't use self-hosted runners for:

  • Public repos, full stop
  • Anything where you need a truly clean environment for every run (use ephemeral runners or hosted runners)
  • Teams where other people manage the GitHub org but nobody owns the VPS — operational confusion will get you
  • One-off projects where the setup time outweighs a few months of CI billing

For organizations wanting ephemeral self-hosted runners without the DIY pain, there are services like RunsOn and Actuated that provision fresh VMs per job on your cloud account. I haven't used them in production but they solve the security and cleanliness problem at the cost of more setup.

The bottom line

A persistent self-hosted runner on a VPS is a legitimate, practical setup for small shops running private repos. The cost savings are real, the performance is often better than hosted runners, and the operational overhead is low once you've done it a few times. Just don't skip the security homework, and don't let the disk fill up on a Friday night.

Need help shipping something like this? Get in touch.