How can I reduce GitHub Actions costs without rewriting my workflows?

Reduce GitHub Actions costs with Depot runners and remote caching, then cut obsolete runs and unnecessary storage. Measure the jobs driving your bill and keep your existing workflow YAML, actions, and test commands.

Last updated 2026-09-30

You can reduce GitHub Actions costs without rewriting your workflows by running your existing jobs on Depot-managed runners, reusing dependencies through Depot Cache, and cancelling runs that newer commits have already replaced. Start with the jobs driving your bill, then compare the cost of completing the same work before and after each change.

You keep the workflow triggers, jobs, steps, Marketplace actions, secrets, permissions, and pull request checks you already use. Connect your GitHub organization to Depot, then change one runner label:

 jobs:
   test:
-    runs-on: ubuntu-24.04
+    runs-on: depot-ubuntu-24.04

The runners are up to 3x faster than standard GitHub-hosted runners. They also include an in-memory disk accelerator and cache transfers up to 1000 MiB/s. Usage is tracked by the second with no one-minute minimum.

That matters because CI cost isn't just the advertised per-minute rate:

job cost = billable runtime x runner price
monthly compute cost = sum of every job cost

A cheap runner that takes twice as long can cost more. A faster runner at the same rate can cost less. Replacing the machine underneath the job lets you improve both sides of that equation without rebuilding the workflow.

Depot's managed runners still use GitHub Actions to schedule jobs. Depot CI runs compatible workflow YAML on Depot's own orchestrator. You don't need to move CI engines to make the runner and cache changes in this guide.

Find the jobs driving your bill

Start with your organization's GitHub Actions usage and the same billing period in Depot if you're already using our runners. Separate runner compute, cache storage, and artifact storage. Moving compute doesn't remove artifacts you've kept in GitHub.

For each expensive job, record how often it runs, its runner size, billed duration, retries, and cache restore time. Include cancelled and failed attempts. A short job that runs thousands of times can cost more than a slow nightly build.

What you findChange to tryWhat to compare
Many short jobs rounded up to whole minutesDepot runners with per-second usage trackingBilled time for the same number of jobs
Old PR commits still running after a new pushCancel superseded PR runsRuntime spent on obsolete commits
Dependency restores or installs dominate runtimeDepot Cache and working cache keysRestore, install, and save time together
A large runner spends most of its time waitingA smaller runner, or fixing the external bottleneckCompleted-job cost and feedback time
Retries and test matrices multiply the workFix flaky tests and remove redundant matrix entriesTotal attempted jobs per successful change
Cache or artifact storage keeps growingRetention and size limitsStored usage and any extra rebuild time

Compare equivalent workloads. Use the same test coverage, runner architecture, and mix of cache hits and misses. Reducing coverage or comparing a warm cache with a cold one can make an apparent saving disappear on the next run.

Runtime usually matters more than the headline rate

As of September 30, 2026, a standard two-core Linux runner from GitHub and Depot both list a rate of $0.006 per minute. The billing behavior is different. GitHub rounds each job up to the next whole minute. Depot tracks usage by the second and calculates the total at the end of the billing period.

Consider 100 Linux jobs that each finish in 20 seconds:

GitHub-hosted runnerDepot GitHub Actions runner
Actual execution time2,000 seconds2,000 seconds
Billable time100 minutes33.33 minutes
Cost at $0.006/minute$0.60$0.20

That's a 67% reduction in this compute-only example, before allowances, plan fees, and storage. The effect is largest for test matrices and other workflows with many short jobs.

Long jobs follow the same equation. If a 12-minute job finishes in four minutes on a runner with the same per-minute rate, its compute cost falls from $0.072 to $0.024. That example illustrates the math, not a promise that every job will run 3x faster.

Depot removes time from the existing job

Changing the runs-on label gives the workflow three ways to finish sooner.

  1. Faster compute. Depot Linux x64 runners use current-generation AMD EPYC CPUs, while Arm runners use AWS Graviton4. Linux and Windows sizes range from 2 to 64 vCPUs, so CPU and memory constrained jobs don't have to stay on one standard machine size.
  2. Faster disk. Linux and macOS runners reserve memory for a RAM-backed disk accelerator. Jobs that compile large codebases, install many dependencies, or create lots of small files spend less time waiting on disk I/O.
  3. Faster cache transfers. Depot automatically accelerates the cache used by actions/cache and actions/setup-*. You keep the same cache steps and keys. Uploads and downloads can reach 1000 MiB/s, up to 10x faster than the standard GitHub Actions cache.

The cache improvement is especially useful because a slow cache can erase its own benefit. If restoring an archive takes almost as long as rebuilding it, the workflow still pays for the transfer on every job. Faster restore and save times turn more of the existing cache configuration into actual runtime savings.

Keep cache savings larger than cache costs

On Depot GitHub Actions runners, actions using the GitHub Actions cache API automatically use Depot Cache. Existing actions/cache steps keep their keys and paths. Setup actions use it when their caching option is enabled. A runner change won't add caching to a workflow that doesn't use it yet.

For an npm project with package-lock.json at the repository root, this job enables caching on a Depot runner:

jobs:
  test:
    runs-on: depot-ubuntu-24.04
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npm test

Keep your project's Node version and test command. setup-node uses the lockfile to build its cache key and caches npm's downloaded packages, not node_modules. You still need npm ci on every run. If you're already caching the same npm directory with actions/cache, use one approach so you don't restore and save it twice.

Check the logs for actual hits. A key containing a unique run ID creates a new entry on every run and prevents exact hits across runs. Dependency caches usually need keys based on the operating system and lockfile. Keep inputs that affect compatibility in the key, and measure restore plus save time against the work the cache avoids.

Depot Cache's GitHub Actions integration requires Depot runners; it doesn't redirect cache traffic from standard GitHub-hosted runners. Cache entries are repository-scoped, with a shared namespace across branches. Review keys and trust boundaries when running contributions from different branches.

Depot Cache includes storage according to your plan, with usage charges above that allowance. In organization settings, choose retention of 7, 14, or 30 days and a size limit appropriate to the working set. The default is 14 days with no size limit. Shorter retention can lower storage usage but cause more misses. Measure the resulting compute cost too. These settings don't govern Docker layer cache, which has separate project policies.

Cancel obsolete PR runs

If a developer pushes three fixes while the first test run is still running, you can end up paying to validate three versions nobody will merge. GitHub's concurrency setting can cancel the earlier run when the next one arrives.

For a test workflow that runs on pull_request, add this at the top level alongside on and jobs:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

The group separates workflows and PRs. Non-PR runs get their own run ID and aren't cancelled by this setting. Keep this on test workflows where superseded runs are disposable; don't copy it into a deployment that must finish. Cancellation prevents further execution, but it doesn't refund time already consumed.

Also inspect workflows that run the same tests on both every branch push and every pull request. Remove redundant triggers only after checking branch protections and the events your team needs. Keep required checks reporting on every PR. Adding path filters without accounting for required checks can leave a PR waiting for a run that never starts.

Reducing a matrix's max-parallel only limits how many jobs run at once. It doesn't remove matrix entries or necessarily reduce their total compute. Keep the combinations that provide distinct coverage and measure their combined runtime.

Stop jobs that are stuck

A test process that hangs keeps consuming runner time. Add timeout-minutes at the job level, alongside runs-on. For the test job above, the change is:

 jobs:
   test:
     runs-on: depot-ubuntu-24.04
+    timeout-minutes: 20
     steps:

Twenty minutes is an example, not a target for every job. Choose a limit above your slowest healthy runs, including cold installs and the full test suite. Leave enough room for normal variation. If healthy runs start timing out, investigate and adjust the limit; repeatedly killing and restarting them can increase the bill.

Reduce reruns without dropping test coverage

Before rerunning a failed workflow, check which job failed and why. Fix deterministic failures first. For a transient failure, use GitHub's Re-run failed jobs option instead of repeating the whole workflow. This keeps a failed test job from unnecessarily repeating unrelated jobs that already passed.

For flaky tests, preserve the first failure's logs and reproduce it with the same inputs. If your test framework supports retrying individual failed tests, use a small, explicit retry limit and track how often it gets used. Compare total runtime across all attempts. A green result after three attempts still paid for three attempts.

Keep the required suite and checks in place. Don't use continue-on-error, remove failing tests, or skip coverage just to make the bill look smaller. Give recurring failures an owner and fix the cause. Our guide to debugging flaky CI tests covers the investigation in more detail.

Use the cheapest runner for the completed job

The smallest runner doesn't always produce the lowest bill.

An eight-core runner costs more per minute than a two-core runner. It's cheaper overall only when the extra compute reduces runtime enough to cover that price difference. Use this break-even test:

new runtime / old runtime < old runner price / new runner price

For example, moving from a two-core Depot Linux runner at $0.006 per minute to an eight-core runner at $0.024 per minute raises the rate by 4x. The job must run more than 4x faster to reduce compute cost. If it runs 3x faster, you paid more for a faster result. That may still be worth it for feedback time, but it isn't a cost reduction.

Depot's GitHub Actions analytics show CPU, memory, and step-level timing data. Use those metrics to find jobs that are constrained by the runner before increasing its size. Use step timing and logs to investigate disk or network bottlenecks. More CPU won't fix a job that spends most of its time waiting on an external API.

Include the cost of operating self-hosted runners

Running your own virtual machines can make the provider invoice look smaller. It also moves costs into engineering.

A production runner fleet needs capacity planning, autoscaling, base image updates, security patches, job isolation, cache storage, metrics, failed-machine cleanup, and incident response. Idle capacity costs money. So does the time a platform engineer spends keeping the fleet alive.

Use the full equation when comparing managed and self-hosted runners:

total runner cost = compute + storage + network + idle capacity + engineering time

Depot starts a fresh, single-tenant virtual machine for every job and destroys it when the job completes. There's no idle fleet for your team to size or patch. If policy requires the runners to execute inside your AWS account, Depot can also run its managed runner infrastructure there.

Keep artifact storage under control

Dependency caches help future jobs reuse work. Test reports, logs, and release bundles are artifacts with their own retention needs. GitHub bills storage separately from compute, and moving jobs to Depot doesn't move artifacts uploaded to GitHub.

Inspect the largest uploads before changing retention. Keep release assets and required audit records for as long as your team needs them; use shorter retention for disposable PR reports. The retention-days input on actions/upload-artifact sets retention for that upload within your repository's allowed range. Set it on future uploads and review existing artifacts separately.

If your test command writes diagnostic reports to test-results/, add this step after the tests to keep failed-run reports for seven days:

- name: Upload failure reports
  if: ${{ failure() }}
  uses: actions/upload-artifact@v4
  with:
    name: test-failure-reports
    path: test-results/
    retention-days: 7
    if-no-files-found: warn

Replace the path with your test tool's output directory. The action uploads existing files; it doesn't generate a report. In a matrix, include the matrix values in the artifact name so each job uploads a distinct artifact. Keep successful-run reports too if your team needs them for comparisons or audits. A cancelled or timed-out job may not reach the upload step, so don't rely on it as your only record of a failure.

Cases where the invoice may not fall

Depot isn't automatically cheaper for every repository or every job.

  • Standard GitHub-hosted runners are free for public repositories. Moving a public project for cost savings alone doesn't make sense.
  • If your organization stays inside its included GitHub Actions allowance, faster jobs may improve feedback time without reducing the current invoice.
  • A job bottlenecked on an external service may not get faster from better compute, disk, or caching.
  • Per-minute prices vary by operating system and runner size. Compare the current rates and the completed-job cost, not a generic percentage claim.
  • Depot GitHub Actions runners require a repository owned by a GitHub organization. Repositories owned by personal accounts aren't supported.
  • Depot Windows runners don't support Hyper-V. Windows jobs that require Hyper-V or Docker on Windows need a different execution environment.

Roll out the change one job at a time

Start with the jobs consuming the most billable time, not the workflow with the most YAML.

  1. Record the current p50 and p95 runtime, billable minutes, queue time, and cache restore time for the job.
  2. Change its runs-on label to the equivalent Depot GitHub Actions runner type.
  3. Run enough normal pull requests to include warm caches, cold caches, and a representative test load.
  4. Compare completed-job cost using billed runtime multiplied by the runner rate. Include retries, cache usage, artifact storage, plan fees, and included allowances when comparing the invoices.
  5. Keep the new label where it wins. Try another runner size when the metrics show a CPU or memory constraint.

You can migrate one job, one workflow, or one repository at a time. The rest of GitHub Actions keeps working exactly as it did before.

Start building with Depot
in minutes