# GitHub Actions Free Minutes Running Low? Dual Self-hosted Runners on One VPS: Setup and Isolation

> Canonical: https://vxi.me/en/posts/github-actions-vps-dual-runner/
> Locale: en-us
> Published: 2026-10-19
> Updated: 2026-10-19
> Markdown: https://vxi.me/en/posts/github-actions-vps-dual-runner.md

<picture class="runner-hero"><source media="(max-width: 640px)" srcset="/images/posts/github-actions-vps-dual-runner/dual-runner-trust-boundaries.mobile.en-us.svg" width="420" height="626" type="image/svg+xml"><img src="/images/posts/github-actions-vps-dual-runner/dual-runner-trust-boundaries.en-us.svg" alt="GitHub routes untrusted PRs to isolated runners; reviewed deployments and tooling CI use separate runners on one VPS, with CI cgroup limits and a resource preflight." width="1200" height="590" loading="eager" fetchpriority="high" decoding="async" style="display:block;width:100%;height:auto"></picture>

*Diagram based on the existing dual-runner system, archived successful jobs and inspected systemd settings; labels are examples. The animation first demonstrates task routing and resource boundaries, then keeps only low-frequency connection glow; when the system "reduce motion" setting is on, a static architecture diagram is shown.*

When CI runs heavily on a private repository, the free minutes in your GitHub Actions plan can quickly become a number you have to watch. If you already have a VPS that stays on around the clock, can you let GitHub keep managing the workflows while handing the actual builds and tests to your own machine?

Yes. Two workflows I maintain already hand part of their deterministic tasks to the same VPS: one runner handles the blog's builds and deployments, the other runs checks for the engineering tooling. GitHub takes care of triggering and logging; the computing happens on my own server. **GitHub currently does not charge compute-minutes for self-hosted runner jobs**; but that doesn't mean the VPS is free — nor does it mean you should throw every PR onto your production box.

Whether to do this, I'd start with three questions:

- **Are the free minutes actually running out?** If there's plenty to spare, the convenience of GitHub-hosted might be worth more than self-hosting.
- **Does the VPS need to stay online anyway?** Reusing existing capacity is a different budget story from renting a new machine just for CI.
- **How trustworthy is the code that will run?** Fixed tasks on a protected main branch are candidates for self-hosting; untrusted PRs should go to an isolated execution environment first.

The first two questions decide whether the effort is worth it; the third decides whether you end up sacrificing your production environment to save a few minutes.

## Self-hosted is free in compute minutes, not in the whole bill

[The GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions) currently says: self-hosted runner jobs are free; standard GitHub-hosted runners on private repositories first consume the minutes included in your plan, and overages are billed. Per current official quotas, GitHub Free includes 2,000 minutes per month, and Pro and Team get 3,000 minutes — the exact number still depends on the repository owner's account. Taking a standard Linux x64, 2 vCPU runner as an example, the [official list price](https://docs.github.com/en/billing/reference/actions-runner-pricing) is **$0.006 per minute**, and hosted jobs round partial minutes up.

This breaks down into three line items:

| Cost | What happens after moving to a VPS |
| --- | --- |
| GitHub-hosted compute minutes | The self-hosted jobs you move away stop consuming these hosted minutes |
| VPS compute and maintenance | You pay for CPU, memory, disk, bandwidth, and ops yourself |
| Other GitHub SKUs | Over-quota Artifact/Cache storage and other hosted jobs are still billed separately |

There's also a date that's easy to get wrong from old news. [GitHub originally announced](https://github.blog/changelog/2025-12-16-coming-soon-simpler-pricing-and-a-better-experience-for-github-actions/) a $0.002-per-minute platform fee for self-hosted runners, then updated the announcement to **delay it**. "Billing starts March 2026" in an old post is not the current rule; then again, nothing guarantees it will stay free forever.

My three historical jobs make a good *hypothetical*: if they ran on standard 2-vCPU Linux hosted runners for exactly the same durations, they'd round up to 9, 13, and 2 minutes, totaling 24 minutes. **Only if all of these minutes were billed overages would the list-price reference be $0.144.** That's arithmetic against list prices, not actual savings; the bill and plan-included usage for the same cycle must be checked in your account's Usage.

## One VPS, two runners: not done by just changing runs-on

A self-hosted runner is a process running on your own machine that connects to GitHub to pick up jobs — not GitHub logging into your VPS over SSH. [The official runner documentation](https://docs.github.com/en/actions/concepts/runners/self-hosted-runners) explains the full mechanism.

I pulled three successful runs from my existing GitHub Actions job history:

| Actual task | Runner role | Result | First-to-last log span |
| --- | --- | --- | ---: |
| Blog build and production deploy | Production runner | Success | **8 min 32 sec** |
| Blog PR content validation (3rd attempt) | Same production runner | Success | **12 min 12 sec** |
| Engineering tooling security and contract checks | Dedicated CI runner | Success | **1 min 27 sec** |

The two runners have distinct identities on GitHub, and the job logs point to the same VPS. At minimum this shows two repositories genuinely consuming self-owned compute, not just a well-written YAML file. The durations in the table are the log spans of historical jobs — good for cross-checking execution records; capacity questions still need resource limits and operational discipline.

On a modestly-sized VPS, the most common failure mode is resource contention: a big CI build starts, and the always-on production service suddenly goes sluggish. So I handle runner identities, host-level limits, and workflow admission separately.

## Runner labels alone don't isolate: systemd does the actual resource limiting

Each of the two repositories can create its own repo-level runner under **Settings → Actions → Runners**, grabbing the download URL, checksum, and short-lived registration token from the current GitHub page. Production and CI run under different non-root Linux accounts with separate working directories and least privilege, each with its own labels, for example:

```yaml
# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]

# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]
```

**Labels only choose who runs the job; they don't isolate.** If both runners can read production secrets, or both hold unrestricted `sudo` or Docker socket access, different names protect nothing on the VPS.

The install script I wrote for the low-priority CI runner actually contains this systemd service drop-in. This is the starting limit set I use on a shared **2-vCPU, ~2.4 GiB memory** VPS:

```ini
[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10
```

There's an easy-to-misunderstand detail here. Per [systemd's resource control documentation](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html), `CPUQuota=75%` means **75% of one CPU core's CPU time**, not 75% of the whole dual-core VPS. `MemoryHigh=700M` is a soft threshold where memory reclaim pressure starts rising; `MemoryMax=1050M` is a hard boundary — exceeding it can trigger OOM and fail the CI job. `Nice=10` and `OOMScoreAdjust=400` make this CI more inclined to yield to critical services, but they can't promise production will never be affected.

The prerequisite is a host on **cgroup v2** with memory controllers and a matching systemd version. Run `test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version` first to confirm the unified hierarchy, the memory controller, and the systemd version; don't copy this into an old environment and assume the limits took effect.

The runner's `./config.sh` **only registers**; it doesn't create a systemd service. Following [GitHub's official Linux service steps](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application?learnProduct=actions&platform=linux), go into each registered production and CI runner install directory and run `install <dedicated user>` on each `svc.sh` with admin privileges, then `start`. For example, run this **inside the CI runner's own install directory** (registered, `svc.sh` present):

```bash
sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status
```

`ci-runner` is an example account — don't reuse the production account; if a service is already running, **don't install twice**. Afterwards find the real **CI unit** and set limits in a maintenance window:

```bash
systemctl list-unit-files 'actions.runner.*.service'

# 替换为上一行显示的真实 CI unit，切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax
```

`restart` will interrupt whatever job this runner is executing — first make sure it's idle, and don't accidentally restart the production runner. After restarting, confirm it's back Online on the repository's Runners page, and verify the effective values via `systemctl show`. When rolling back, only remove the CI drop-in you added this time — don't wipe existing service customizations. Registration codes, hostnames, service accounts, and production directories never need to go into workflows, and shouldn't appear in a public article.

## Check resources once more before the workflow starts

systemd can bound a service's process group, but it can't conjure free disk space. My engineering CI also has a resource admission step: read the host's available memory and the free space on the working directory's filesystem. Below is a trimmed Node.js recipe for a **trusted main branch**:

```yaml
name: Trusted main validation
on:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: trusted-main-validation
  cancel-in-progress: false

jobs:
  test:
    runs-on: [self-hosted, linux, x64, ci-lab]
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - name: Check available resources
        shell: bash
        run: |
          set -euo pipefail
          mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
          disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
          echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
          if [ "$mem_kib" -lt 600000 ]; then
            echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
            exit 1
          fi
          if [ "$disk_kib" -lt 5242880 ]; then
            echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
            exit 1
          fi
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npm test
```

This example uses `actions/checkout@v7` and `actions/setup-node@v7`; they use Node.js 24 as the action runtime and require **Actions Runner version at least v2.327.1**. Check the runner version before migrating existing machines — don't just bump the YAML. [checkout v7](https://github.com/actions/checkout/tree/v7) and [setup-node v7](https://github.com/actions/setup-node/tree/v7) have the relevant notes.

About 586 MiB of available memory and 5 GiB of available disk are this small VPS's admission thresholds. The script prints the remaining amounts and the failure reason, so it's easy to tell memory shortage from disk shortage.

**This step can only check, not reserve.** The `concurrency` groups of two repositories are not a cross-repo global lock. My choice is to keep the engineering CI under the CPU/memory caps of its own systemd cgroup — failing when resources are short rather than growing without bounds; production keeps its own permissions and resource boundaries. For big builds, or cases where the two repositories must strictly never run at the same time, the most reliable option is **switching to a single controlled task queue or a separate VPS**, with a single scheduler dispatching work serially; don't mistake same-named cross-repo `concurrency` for a global mutex. If you want true host-level serialization, you can design a separately-reviewed `flock` wrapper executor — but it must lock **the entry of every resource-intensive task**; locking before just one job does nothing; this approach isn't deployed on my box. The cost is that tasks queue up; you can't keep both repositories' concurrent throughput.

If you really need to serialize the two repositories' heavy steps on the **same host**, you can pick a separately-reviewed shared file-lock recipe. First, have an admin create a lock-only system group, add both trusted runner users to it, and set up the shared lock file (coordination only; don't give the group production-directory permissions):

```bash
sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock
```

The account names here are placeholders; after adding new groups, restart the relevant runner services in a maintenance window so they pick up the new supplementary groups — running jobs must not be interrupted casually. Then **both repositories must** put their resource-heavy commands inside the same lock's scope:

```bash
flock -w 900 /var/lock/ci-queue/build.lock \
  bash -e -c 'npm ci && npm test'
```

That way the second participant waits at most 900 seconds and then fails, instead of hanging forever. It only protects the commands wrapped by the same lock — it won't automatically serialize all steps of a GitHub job, and it can't stop an untrusted workflow from deliberately skipping the lock. Also for that reason, **my existing dual runners don't use this lock scheme**; it's a replicable recipe needing independent evaluation, not a measured result from a running setup.

Don't keep raising `MemoryMax` without limit just to turn a CI run green; tasks that chronically exceed the headroom belong on a separate machine.

## Sharing one runner between PRs and production is a different risk calculation

Looking at the blog's existing workflows, I found content PR validation and production deployment still use the **same production runner labels**. In a private repository with only trusted maintainers that may be tolerable for now — but it must not be taught as a security tutorial for open collaboration.

GitHub's [security guide](https://docs.github.com/en/actions/reference/security/secure-use) warns: persistent self-hosted runners running untrusted code can carry persistent contamination across jobs, or let scripts touch host files and privileges. If external collaborators, fork PRs, or other untrusted code ever enter the validation pipeline, they should be kept off the production box:

```text
不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署
```

This means some PR validation may keep spending GitHub-hosted minutes. But letting unfamiliar scripts near deployment credentials to save a few minutes is a bad trade. Even for a private repository, you have to look at actual collaborator permissions, branch protection, and who can modify workflows.

## Recovering deployment when the runner drops or the VPS fails

The cost of self-hosted runners isn't just the monthly rent — it's also downtime, updates, and recovery time. One very practical difference: **when a self-hosted runner goes offline, jobs matching its labels queue up; they don't fall back to `ubuntu-latest` automatically**. [GitHub's documentation](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) says a job with no available runner waits up to about 24 hours before failing.

The recovery order can be simple: first check whether the runner shows Online on the repository's Runners page, then look at the CI's `./svc.sh status`, `journalctl -u <real-unit> -n 100 --no-pager`, and free disk; on a cgroup OOM, shrink the workload or wait for idle instead of just lifting the limit. The runner application usually auto-updates, but if you've disabled auto-update you need to track versions yourself; per the official rules, a runner past its update window — or one missing a critical security update — may stop receiving jobs.

Production deployment should have a clearly-defined separate recovery channel: for example, a **separately-approved GitHub-hosted build plus a controlled deploy step** behind a protected release entry point, or retry after restoring the VPS through your existing ops channel. GitHub-hosted runners by default **can't reach the VPS's local directories or hold the original production deploy credentials**, so merely switching `runs-on` to `ubuntu-latest` isn't a working fallback. Until you've drilled it, don't describe this backup as verified automatic disaster recovery.

## Whether it actually saves money is decided in Usage

Green Actions runs only prove jobs finished; they don't do your monthly accounting. In GitHub's **Billing & licensing → Usage**, review Actions usage per billing cycle; for line items, export a report per the [official method](https://docs.github.com/en/billing/how-tos/products/view-productlicense-use). Account over-quota hosted minutes, Artifact/Cache storage, extra VPS rent, and maintenance cost separately.

For these migrations, my order is always: **first check whether the plan minutes are really tight, then check whether the VPS is already always-on, and finally check the trust boundary of the code being run.** Stable, frequent, trusted builds on an existing VPS are good self-hosting candidates; projects with little CI and plenty of unused free minutes are probably better off on GitHub-hosted.

The cost math plugs straight into your own numbers: **migratable over-quota hosted minutes per month × the matching SKU unit price − new VPS/ops cost**. Say 1,000 minutes of standard Linux 2-core overage: list price $6; if they were inside the free quota anyway, the marginal bill may be $0. Only trusted, maintainable, recoverable tasks earn their way onto the box.

The earlier [ChatGPT Web + GitHub + Self-hosted Runner walkthrough](/posts/chatgpt-web-github-self-hosted-runner/) was about the development entry point and the deployment exit; this one answers a different question: **how to save GitHub-hosted minutes, and how to keep multiple runners on one VPS from dragging each other down.**

*Note on timing: billing and official announcements verified as of October 19, 2026; job durations come from existing run records, systemd values from existing install scripts — not from new host stress tests.*
