# How to cut GitHub Actions CI costs with RunsOn

> Pydantic cut Logfire's CI bill from $18,200 to $5,700 a month with RunsOn. How it works, setup steps, Spot rules and the gotchas to plan for.

By Boris Tane, Founder of Polylane · Published October 17, 2026 · 12 min read
Canonical: https://polylane.com/learn/deployment-safety/how-to-reduce-github-actions-ci-costs-with-runson/

RunsOn leaves GitHub Actions in charge of your workflows and runs each job on a fresh EC2 instance in your own AWS account, mostly on Spot, for a flat yearly licence from €300 / $350. Pydantic announced on 16 October 2026 that Logfire's CI moved to RunsOn on 1 September, and the monthly bill fell from $18,200 in August to $5,700 in September while runner minutes went up. To do the same, install the stack, connect the GitHub App, change each job's runs-on label, turn on the S3 cache, and keep merge-queue, release and deploy jobs on On-Demand.

RunsOn cuts GitHub Actions costs by moving where jobs run. It doesn't change how they are orchestrated. GitHub still schedules every workflow. RunsOn starts a new EC2 instance for each job in your AWS account, usually on Spot, and terminates it when the job ends. You pay AWS for those minutes at cost, plus a flat yearly licence. For most teams the migration is a different `runs-on:` label and one setup step per job.

The case study behind this guide is new. On 16 October 2026 Pydantic published "Pydantic runs on RunsOn", explaining how Logfire's CI got off a bill that was growing every month. The rest of this page covers what RunsOn is, what Pydantic changed, how to repeat it step by step, and the rules and limits that RunsOn's docs and Pydantic's write-up set out.

## What RunsOn is: GitHub Actions jobs on EC2 in your own account

RunsOn is software you install in your AWS account that gives GitHub Actions self-hosted runners, one fresh EC2 instance per job. GitHub-hosted runners bill by the minute once you pass your plan's included minutes, and hosted runner vendors usually charge per minute too. RunsOn works differently. According to its docs:

- **Each job gets a fresh EC2 instance in your account.** The instance is destroyed when the job ends, so nothing is shared or kept between jobs. Code, secrets, caches and logs stay in your AWS account.
- **The label describes the machine.** A label such as `runs-on=${{ github.run_id }}/family=m8+r8/cpu=4` asks for any 8th-generation general-purpose or memory-optimised instance with 4 vCPUs, whichever has Spot capacity at launch.
- **The images are ported from GitHub's own templates.** Actions, cache steps and secrets keep working.
- **The control plane runs in your account on Fargate.** It costs a few dollars a month. Only a licence check and a per-launch telemetry event go back to runs-on.com, and neither one carries repository contents or secrets.

There are two ways to run it. With **Flex**, GitHub sends each job to a webhook in your account and the job's label sets the instance shape. RunsOn reports a median wait of 17 to 24 seconds from queued to running. With **Fleet**, platform teams define runner fleets in Terraform and RunsOn pulls jobs from GitHub, so nothing listens for inbound traffic. The steps below use Flex, because that is where per-job labels apply.

```mermaid alt="How a GitHub Actions job runs on RunsOn Flex, from queued job to terminated EC2 instance"
graph TB
A[Job queued in GitHub Actions] --> B[Webhook reaches RunsOn in your AWS]
B --> C[Job label picks the instance shape]
C --> D{Spot capacity available?}
D -->|yes| E[Fresh Spot instance boots]
D -->|no| F[On-demand instance boots]
E --> G[Job runs, cache served from S3 in VPC]
F --> G
G --> H[Instance terminated after the job]
```

### What you still own after the switch

RunsOn runs the runner lifecycle. Pydantic says they still manage their AWS account, runner catalogue, capacity strategy, storage, cache policy, quotas and telemetry. Plan for someone on the team to own those. You need admin rights in an AWS account to create the stack, and someone who can install a GitHub App on the organisation, usually an owner.

### What it costs

RunsOn's homepage gives the licence price by the number of runners launched per month (one runner per job), never by the minute:

- €300 / $350 a year under 50k runners a month
- €900 / $1,050 under 200k
- €1,800 / $2,100 under 500k
- €3,600 / $4,200 from 500k

There is a 15-day trial, and non-commercial use is free. EC2, EBS and S3 go on your AWS bill at cost, so your existing AWS credits and commitments apply. Pydantic worked out that RunsOn pays for itself at 22 jobs per day.

## What Pydantic changed, and why the bill forced it

Logfire is built in the `pydantic/platform` repository. Until 1 September its CI ran on a mix of GitHub Actions, Depot and Ubicloud. As the team adopted AI coding tools, pull request volume jumped. By 30 August they had merged 2,032 pull requests that month. A team of the same size merged 365 over the same 30 days in 2025. The main CI workflow ran 4,194 times in May, 6,669 in June, 11,919 in July and 20,659 through 30 August.

The bill followed the volume: $3,990 in June, $10,778 in July and $18,200 in August. September was the first month on RunsOn. Total CI cost was $5,700, and that figure includes the RunsOn licence and all AWS costs: compute, EBS, S3 cache storage and data transfer. Runner minutes still rose over the same period, from 1,230,264 to 1,617,654.

**Figure 1. Logfire CI before and after the move to RunsOn**

| Metric | August 2026 (before RunsOn) | September 2026 (on RunsOn) |
| --- | ---: | ---: |
| Total CI cost (USD) | 18,200 | 5,700 |
| Total runner minutes (minutes) | 1,230,264 | 1,617,654 |

**Monthly CI cost and runner minutes from Pydantic's announcement**

| Month | Total CI cost (USD) | Total runner minutes |
| --- | --- | --- |
| April 2026 | 3,813 | not published |
| May 2026 | 3,665 | not published |
| June 2026 | 3,990 | not published |
| July 2026 | 10,778 | not published |
| August 2026 | 18,200 | 1,230,264 |
| September 2026 | 5,700 | 1,617,654 |

The lesson applies beyond Pydantic. If agents and bots open more pull requests, CI minutes grow with them. A per-minute price means the bill grows at the same rate. Pydantic chose to change the price of a minute and left the agents alone.

## Moving your first jobs to RunsOn, step by step

1. **Measure what you run now.** Count jobs per day and runner minutes per month for your main workflows, and note which jobs are slow or expensive. RunsOn's FAQ says most teams move those first.
2. **Deploy the stack.** One CloudFormation stack creates its own VPC, the cache bucket, IAM roles and the control plane in about ten minutes. To use an existing VPC, GitHub Enterprise Server or IAM permission boundaries, use the Terraform / OpenTofu module.
3. **Connect GitHub.** Install the private GitHub App from the stack's outputs.
4. **Change one job's label and add the setup action.** The label applies per job, so anything still on `ubuntu-latest` keeps running on GitHub.

Before:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: make test
```

After:

```yaml
jobs:
  test:
    runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-arm64/extras=s3-cache
    steps:
      - uses: runs-on/action@v2
      - uses: actions/checkout@v7
      - uses: actions/cache@v5
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: make test
```

`runs-on/action@v2` does nothing on a GitHub-hosted runner, so you can keep it in a workflow that runs on both.

5. **Pin jobs that must not be interrupted to On-Demand.** Add `spot=false` to deploy and release jobs before you move them:

```yaml
  deploy:
    runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/spot=false
```

6. **Test interruption handling before you rely on it.** The RunsOn CLI can interrupt a running Spot job:

```bash
AWS_PROFILE=runs-on-admin roc interrupt "$JOB_URL" --wait
```

7. **Move container builds last.** Pydantic found that ordinary jobs mostly needed only the new label and the setup action. Container builds needed extra cache and registry changes, which are covered below.

## Spot where retries are cheap, On-Demand where they aren't

RunsOn uses Spot by default with the `price-capacity-optimized` strategy. When there's no Spot capacity, Flex launches On-Demand, which adds 2 to 3 seconds. The docs say AWS doesn't bill a Spot instance interrupted within its first hour. That makes Spot a safe default for most CI work under an hour that has no critical side effects.

Pydantic didn't put every job on Spot. Their placement rules make a good starting policy:

```mermaid alt="Deciding whether a CI job runs on On-Demand, capacity-optimized Spot or price-capacity-optimized Spot"
graph TB
A[A CI job to place] --> B{Merge queue, release or deploy?}
B -->|yes| C[On-Demand with spot=false]
B -->|no| D{Expensive to restart?}
D -->|yes| C
D -->|no| E{Long and interruption sensitive?}
E -->|yes| F[Spot with spot=co]
E -->|no| G[Spot with spot=pco, the default]
style C fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

The strategies map to labels: `spot=co` is capacity-optimised, which picks pools with the most spare capacity to cut interruptions. `spot=pco` balances price against capacity. `spot=lp` chases the lowest price and accepts more interruptions.

Two more safety nets matter at scale:

- **The Spot circuit breaker.** After a set number of interruptions within a time window, it sends new launches to On-Demand for a recovery period. It is configured as `COUNT/WINDOW_MINUTES/RECOVERY_MINUTES`, and Fleet's default is `2/15/30`. Pydantic uses a circuit breaker and spreads its deployment across three availability zones.
- **Automatic reruns.** With the default `retry=when-interrupted`, RunsOn starts GitHub's rerun-failed-jobs flow at most twice, for three attempts in total. GitHub reruns every failed job and the jobs that depend on it.

### Keeping Spot interruptions out of the merge queue

An interrupted job fails, and a failed required check ejects the pull request from the merge queue. RunsOn's `runs-on/spot-retry-gate` reusable workflow fixes this. Require only `merge-gate / pass` in the branch ruleset, and don't make the individual Spot jobs required checks:

```yaml
on:
  pull_request:
  merge_group:

jobs:
  test:
    runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64
    steps:
      - uses: actions/checkout@v7
      - run: make test

  merge-gate:
    if: ${{ always() }}
    needs: [test]
    permissions:
      actions: read
      checks: read
    uses: runs-on/spot-retry-gate/.github/workflows/merge-queue.yml@v1
    with:
      job_results: ${{ toJSON(needs) }}
```

Set the merge queue's status-check timeout long enough to cover the retry. Don't cancel the workflow while the gate is waiting, because RunsOn waits for the failed attempt to finish before it asks for the rerun. The gate supports GitHub.com but not GitHub Enterprise Server.

## Right-sizing runners against the critical path

Pydantic's rule: the cheapest runner is the one that meets the job's latency target at the lowest total cost. That is often not the smallest machine. Their catalogue runs from one-core ARM runners to 16-core image builders. Most Python, TypeScript, agentic and control-plane jobs run on Graviton. They keep x64 for container builds and for tests that need it.

The label options that matter most:

- **Give Spot several families to choose from.** `family=c8+m8` lets AWS pick from more capacity pools. Pydantic puts several EC2 families in each preset for this reason. If you name a single instance type, the runner can fail to launch when AWS has no capacity for it.
- **Use vCPU ranges where exact counts don't matter.** `cpu=2+8` means at least 2 vCPUs and up to 8, which widens the Spot pool.
- **Put idle-heavy jobs on burstable instances.** For deploy polling, notifications and approval gates, the docs suggest `family=t8i` on x64 or `family=t4g` on arm64. A `t8i.micro` costs about a tenth of an `m8a.large`.
- **Match the image to the architecture.** The default image is `ubuntu24-full-x64`. If you ask for an arm64 family without an arm64 runner preset, also set `image=ubuntu24-full-arm64`.

To reuse shapes across repositories, define them once in `.github/runs-on.yml` and reference them with `runner=`:

```yaml
runners:
  cheap:
    ram: [2, 4, 8]
    family: ["t8i", "t4g"]
    image: ubuntu24-full-x64
  fast:
    cpu: 32
    family: ["c8a", "m8a"]
    spot: false
    image: ubuntu24-full-x64
```

## Keeping the cache next to the compute

Pydantic's third rule: keep cache close to compute, and keep it only while it is likely to be reused. RunsOn gives you three tools for this.

- **Magic Cache.** Add `extras=s3-cache` and `runs-on/action@v2`. Your existing `actions/cache` and language setup cache steps then read and write an S3 bucket in your VPC, with no other changes. RunsOn sets no size cap. Entries expire after a fixed retention window, 10 days by default, and cache hits don't extend it. GitHub's own docs give a default cache limit of 10 GB per repository.
- **Sticky EBS disks.** `sticky=build-cache:30gb` attaches a persistent cache disk that is restored from its latest snapshot before the job. Pydantic uses sticky disks to keep expensive BuildKit state.
- **Docker layers in ECR.** Turn on the ephemeral registry in the stack, add `extras=ecr-cache`, and point Buildx at `type=registry`. Layers stay in your account.

Each job's `Complete runner` step prints a cost estimate and a Magic Cache summary: lookups, hit rate, downloads and saves. Check the hit rate after the first week. A low rate usually means your keys change too often for the retention window.

## What bites teams after the switch

- **Jobs steal each other's runners.** With the array form of `runs-on:`, a job asking for a subset of another job's labels can take its runner. Fix: use the single-string form joined with `/`, which RunsOn recommends.
- **Matrix entries swap runners.** `github.run_id` is the same for every matrix entry. Fix: add the matrix value to the prefix, for example `runs-on=${{ github.run_id }}-${{ matrix.function }}/cpu=12`.
- **Spot quota runs out.** When a Spot quota is exhausted, Flex launches the affected families On-Demand for the next 5 minutes and sends an email alert. Fix: request an EC2 Spot quota increase before your peak week.
- **Container jobs lose the cache.** Magic Cache works in `container:` jobs only if the job can reach the instance. Fix: don't set `ACTIONS_*` variables in the job's `env:`, and don't run the container with `--network none`.
- **The cache is shared across repositories by default.** Cache isolation is opt-in (`EnableCacheIsolation`). Direct S3 clients such as sccache and BuildKit `type=s3` keep stack-wide access either way. Fix: run a separate stack for public or untrusted repositories.
- **Config changes don't apply on a branch.** On public repositories, RunsOn reads `.github/runs-on.yml` only from the default branch. Fix: merge config changes before you test them.
- **macOS jobs have nowhere to go.** RunsOn's FAQ says macOS jobs stay where they are. The two run side by side in the same workflow.

## Checking the bill after the first month

Compare like with like. Pydantic's $5,700 covers everything: licence, compute, EBS, S3 cache storage and data transfer. Your comparison against last month's GitHub or vendor invoice should include the same lines. Watch runner minutes too. If minutes keep climbing while cost falls, as they did for Logfire, the move is doing its job. If cost is flat, look at how many jobs are falling back to On-Demand and at your cache hit rate first.

Leaving is the label change in reverse. Then delete the stack, empty and delete its two S3 buckets (cache and access logs), and remove the GitHub App.

Running on AWS? See [how Polylane monitors AWS in production](https://polylane.com/for/aws/).

## Common questions

**How much did Pydantic save by moving to RunsOn?**

Logfire's total CI cost fell from $18,200 in August 2026 to $5,700 in September 2026, the first month on RunsOn. The September figure includes the RunsOn licence and all AWS costs: compute, EBS, S3 cache storage and data transfer. Runner minutes rose from 1,230,264 to 1,617,654 over the same months.

**How much does a RunsOn licence cost?**

The licence is priced by the number of runners launched per month, one per job: €300 / $350 a year under 50k, €900 / $1,050 under 200k, €1,800 / $2,100 under 500k and €3,600 / $4,200 from 500k. There is a 15-day trial and free non-commercial use. EC2, EBS and S3 are billed by AWS at cost.

**At what volume does RunsOn pay for itself?**

Pydantic puts the break-even at 22 jobs per day, and says they were well past it. Count your daily jobs across all workflows before you decide. Below that volume, the licence and the work of running the stack may cost more than you save.

**Do I need to rewrite my GitHub Actions workflows?**

Usually not. Ordinary jobs need a new runs-on label and a runs-on/action@v2 step, because the runner images are ported from GitHub's templates. Pydantic found that container builds needed extra cache and registry changes, and they use sticky EBS disks to keep BuildKit state.

**What happens when a Spot instance is interrupted mid-job?**

The job fails, and with the default retry=when-interrupted RunsOn starts GitHub's rerun-failed-jobs flow up to twice, for three attempts in total. Recovery capacity is launched On-Demand on a best-effort basis. To stop interruptions from ejecting pull requests from a merge queue, use the runs-on/spot-retry-gate workflow and require only merge-gate / pass.

**Which jobs should stay on On-Demand instances?**

Pydantic keeps merge-queue validation, release publishing, deployments and jobs with expensive restarts on On-Demand. RunsOn's docs add migrations, release signing and long jobs with poor checkpointing. In Flex, add spot=false to the job's label.

**How long does RunsOn take to install?**

RunsOn says one CloudFormation stack takes about ten minutes and creates its own VPC, cache bucket, IAM roles and Fargate control plane. You then install a private GitHub App from the stack outputs, which usually needs an organisation owner. Use the Terraform or OpenTofu module for an existing VPC or GitHub Enterprise Server.

**Does RunsOn support macOS runners?**

RunsOn's homepage FAQ says macOS jobs stay where they are, because RunsOn runs Linux and Windows on EC2. You can keep macOS jobs on GitHub-hosted runners in the same workflow. The label applies per job, so the two run side by side.

## Sources

- [Pydantic runs on RunsOn (Pydantic, 16 October 2026)](https://pydantic.dev/articles/ai-oh-my-ci)
- [RunsOn: self-hosted GitHub Actions runners on AWS](https://runs-on.com/)
- [RunsOn job labels](https://runs-on.com/docs/runners/labels/)
- [RunsOn Spot instances and interruptions](https://runs-on.com/docs/costs/spot-pricing/)
- [RunsOn Magic Cache for GitHub Actions](https://runs-on.com/docs/performance/caching/actions/)
- [GitHub Docs: Dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

## About the author

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

## Related

- [What is change intelligence? A production safety guide](https://polylane.com/learn/deployment-safety/what-is-change-intelligence/): What is change intelligence? Learn how deploys, config, flags, infrastructure and dependency changes connect to incidents, using tools you already have.
- [How to correlate incidents with deploys using evidence](https://polylane.com/learn/deployment-safety/how-to-correlate-incidents-with-deploys/): Learn how to correlate incidents with deploys using release markers, metrics, logs and verified GitHub, Vercel, Cloudflare and Kubernetes change events.
- [How to keep AI coding agents from breaking production](https://polylane.com/learn/ai-in-production/how-to-keep-ai-coding-agents-from-breaking-production/): Stop AI coding agents breaking production: scoped credentials, branch protection they can't bypass, required checks that block, and production-aware review.
- [How to deploy Vite apps to Cloudflare with Void](https://polylane.com/learn/deployment-safety/how-to-deploy-vite-apps-to-cloudflare-with-void/): Void, announced by Cloudflare on 15 October 2026, deploys Vite apps to Workers in one command. Setup steps, secrets, migrations, CI and the gotchas.
- [How to attach Vercel Sandbox to a Secure Compute network](https://polylane.com/learn/deployment-safety/vercel-sandbox-secure-compute-network-attachment/): Vercel Sandbox now supports Secure Compute. Attach a sandbox to your network for static egress IPs and VPC peering, with steps, costs and gotchas.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
