
Depot CI Says It Rebuilt CI for Agents. Here's Tenki's Take.
GitHub Actions is quietly becoming one of the largest line items in engineering budgets. A mid-sized team running 50,000 minutes per month on standard Linux 2-core runners is paying $300/month in compute alone — before storage, cache overages, or any macOS builds enter the picture. Scale that to a 4-core or 8-core runner for faster feedback, and you're looking at $600 to $1,100/month.
The good news: most of that spend is waste. Redundant dependency installs, unoptimized Docker builds, oversized runners sitting idle during I/O-bound tasks. This playbook covers seven layers of optimization, ordered from quick wins to architectural changes, with real configs you can copy into your workflows today.
Before optimizing anything, you need to understand what you're actually paying for. GitHub's current pricing charges per-minute based on runner type and OS. Here's the breakdown that matters:
On top of per-minute compute, you're paying for artifact and cache storage. GitHub gives you 10 GB of cache per repository for free, but artifacts and anything beyond that is billed at $0.07/GB-month. If your org has 50 repos each pushing 500 MB of build artifacts daily, storage costs add up fast.
Free plan users get 2,000 minutes/month. GitHub Team gets 3,000. Enterprise Cloud gets 50,000. After that, every minute is billed at the rates above. And here's the part most people miss: larger runners never draw from your free quota. If you're on a 4-core or larger runner, every minute is paid from dollar one.
Let's put together a realistic monthly invoice for a 15-person engineering team running a Node.js monorepo with Docker builds:
That's before any nightly builds, scheduled workflows, or deployment pipelines. Teams with heavier workloads routinely hit $1,000–$3,000/month. Let's fix that.
The single highest-ROI change you can make. Most CI jobs spend 2–5 minutes downloading and installing dependencies from scratch on every run. With caching, that drops to seconds.
GitHub's built-in actions/cache action supports npm, yarn, pip, cargo, Go modules, and most other package managers. The setup is straightforward — here's a Node.js example:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-For Rust projects, caching the target/ directory and ~/.cargo/registry is even more impactful. Cargo builds are notoriously slow — a fresh Rust compile can take 10–15 minutes, but a cached incremental build often finishes in 2–3 minutes.
- name: Cache cargo registry and target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-A few things to watch for. Cache storage is capped at 10 GB per repo. If you're caching large artifacts across multiple branches, you'll hit that limit and caches start evicting. Use specific keys tied to your lockfile hash, and set restore-keys to fall back to partial matches so you don't lose all caching on a lockfile change.
Expected savings: 40–70% reduction in job time for dependency-heavy builds. On a $300/month bill, that's $120–$210 saved just from this one change.
If your pipeline builds Docker images, you're probably rebuilding every layer from scratch on each run. GitHub Actions doesn't persist the Docker build cache between jobs by default, so even unchanged base layers get rebuilt.
The fix is Docker BuildKit's cache backend, which integrates directly with GitHub Actions cache storage. Using docker/build-push-action with the GHA cache backend:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=maxThe mode=max flag caches all layers, not just the final image layers. This matters for multi-stage builds where intermediate stages (like compiling Go binaries or installing system packages) are the slow part.
One gotcha: the GHA cache backend uses your repo's 10 GB cache quota. If you're also caching npm or cargo dependencies, you might run into eviction issues. For larger images, consider using a registry-based cache (type=registry) instead, which pushes cache layers to your container registry and doesn't count against the Actions cache limit.
Expected savings: 50–70% reduction in Docker build time. For a team building 3–4 images per pipeline, that's several minutes shaved off every CI run.
Parallelization reduces wall-clock time, but it doesn't automatically reduce cost. Running 4 parallel jobs for 5 minutes each costs the same as one job for 20 minutes. The savings come from a subtler place: each parallel job spends less time on setup, checkout, and teardown relative to its total runtime.
Where parallelization actually saves money:
paths filters in your workflow triggers so docs-only PRs don't kick off the entire test matrix.Here's a matrix strategy that splits a Jest test suite across 4 shards:
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
- run: npx jest --shard=${{ matrix.shard }}/4When not to parallelize: if each shard spends more time on checkout and setup than actual testing, you're paying for overhead. Jobs under 2 minutes are usually better off running sequentially.
Most workflows default to ubuntu-latest, which maps to a 2-core x64 runner at $0.006/min. That's reasonable for most tasks. But teams frequently upgrade to 4-core or 8-core runners for speed without measuring whether the extra cores actually help.
The truth is, many CI tasks are I/O-bound or single-threaded. Linting, type checking, and most npm install operations won't run any faster on 8 cores. You're paying 3.7x more ($0.022 vs $0.006) for zero speedup.
The right approach is to profile your jobs and match runners to actual needs:
Also consider arm64 runners. GitHub's Linux arm64 2-core runner costs $0.005/min versus $0.006/min for x64 — a 17% discount. If your application targets arm64 anyway (which is increasingly common for container workloads on AWS Graviton or GCP Tau T2A), you get the cost savings and architecture-native testing in one move.
Layers 1–4 are workflow-level optimizations. They're worth doing regardless. But the biggest single cost reduction comes from moving off GitHub-hosted runners entirely.
Self-hosted runners on your own hardware have zero per-minute charges from GitHub. You pay for the infrastructure, but you control the cost curve. The tradeoff is operational overhead: you're now managing machines, handling security patching, scaling capacity, and dealing with runner registration.
Managed runner services split the difference — you get cheaper per-minute rates without the ops burden. Tenki is one option worth looking at here. Their runners plug into your existing GitHub Actions setup with a single runs-on change and come in at $0.003/min for a 2-core Linux runner — half the $0.006/min GitHub charges.
Let's run the numbers with the same 15-person team from earlier:
The migration is trivial — no workflow rewrites, no new YAML syntax. You change your runs-on label and everything else stays the same.
Beyond caching and runner selection, there's a category of savings that comes from just not running things you don't need to run.
Cancel redundant runs. When a developer pushes 3 commits in quick succession, you don't need 3 full CI runs. Use concurrency groups to auto-cancel in-progress runs when a new push arrives on the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueThis alone can cut your total minutes by 10–20% in active repos.
Timeout limits. GitHub's default job timeout is 6 hours. If a test hangs, you're paying for a runner doing nothing for up to 360 minutes. Set explicit timeouts:
jobs:
test:
timeout-minutes: 20
runs-on: ubuntu-latestShallow clones. If your repo has years of git history, every checkout downloads all of it. For CI, you almost never need full history:
- uses: actions/checkout@v4
with:
fetch-depth: 1On large monorepos, this can shave 30–60 seconds off every job. Across hundreds of daily runs, it adds up.
Artifact retention. By default, artifacts are retained for 90 days. Most teams never look at artifacts older than a week. Reduce retention to cut storage costs:
- uses: actions/upload-artifact@v4
with:
name: test-results
path: coverage/
retention-days: 7Before applying any of these optimizations, figure out where your money is actually going. GitHub provides a usage dashboard, but it's surprisingly easy to miss the biggest cost drivers.
Set up budget alerts too. GitHub lets you configure notifications when usage hits 90% and 100% of your included quota. There's no reason to be surprised by a bill.
Ranked by effort-to-savings ratio, starting with changes you can ship in under an hour:
Stack all of these together and the math gets dramatic. Caching cuts your minutes by 40–70%. Concurrency groups eliminate 10–20% of redundant runs. Right-sizing drops per-minute cost. And switching to a cheaper runner provider halves whatever's left. A team spending $1,000/month on GitHub Actions can realistically get below $100.
Start with the checklist. Pick the three easiest items for your codebase, ship them this week, and measure the impact in your next billing cycle. Most teams see results within days.
Tags
Recommended for you
What's next in your stack.
GET TENKI