Guides / Reduce setup & install overhead

Too much work per run

Reduce CI setup and install overhead

By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026

A workflow has four jobs: lint, test, build, deploy. Each one checks out the repo, sets up Node 20, and runs npm ci. That's the same 90 seconds of dependency installation repeated four times per run. Across 40 runs a day, you're spending 100 minutes just on setup, all before a single test executes. This is one of the most common sources of invisible CI waste, and the fixes are built into GitHub Actions.

Symptoms

How to tell if setup overhead is inflating your CI bill

Expand a few job logs in your Actions tab and look at step timings. If these patterns look familiar, setup is eating a meaningful share of your minutes:

  • Setup + install dominates job runtime. Open any job log and add up the time for actions/checkout, actions/setup-node, and npm ci. If those three steps account for more than 40% of the job, your actual work (tests, lint, build) is the minority of what you're paying for.

  • Identical setup steps across every job. Your workflow YAML has npm ci (or pip install, bundle install, go mod download) repeated in 3–5 jobs. Each job re-downloads the same dependencies from the registry because GitHub-hosted runners are ephemeral, meaning every job starts with a clean filesystem.

  • No cache step in the workflow. Search your workflow files for actions/cache or for the cache: parameter on setup-node / setup-python / setup-ruby. If neither appears, every run downloads dependencies from scratch. GitHub provides 10 GB of cache storage per repository by default, and using none of it is leaving free performance on the table. See our dependency cache not working guide for more on diagnosing cache misses.

Metrics

Quantify the waste

Setup overhead compounds with job count. A workflow with 4 jobs that each spend 90 seconds on setup wastes 6 minutes per run. Here's a realistic scenario for a mid-size team on Linux runners:

Before optimization

Runs/day 40
Setup min/run (4 jobs × 1.5 min) 6
Monthly setup minutes 5,280
Monthly setup cost $32/mo

At $0.006/min (Linux 2-core) · 22 working days

After optimization (70% less setup)

Runs/day 40
Setup min/run (cached + consolidated) 1.8
Monthly setup minutes 1,584
Monthly setup cost $10/mo

Save $22/mo · $264/year · per workflow

That's one workflow on Linux. On macOS runners at $0.062/min, the same 5,280 uncached setup minutes cost $327/mo. With caching and consolidation: $98/mo. That's $229/mo saved, purely from setup configuration alone.


Fix 1

Use built-in dependency caching on setup actions

The official setup-node, setup-python, setup-ruby, setup-java, and setup-go actions all support a cache parameter. When set, the action automatically caches and restores the package manager's global cache directory. On cache hit, npm ci skips network downloads and installs from the local cache, which cuts install time from 60–90 seconds to 10–15 seconds.

This is a single-line change per job. The setup action handles cache key generation, restore, and save automatically, keyed on the lockfile hash.

No caching - downloads every run
- uses: actions/setup-node@v4
  with:
    node-version: 20
- run: npm ci
# npm ci: ~60-90s every run
Cached - 10-15s on hit
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
- run: npm ci
# npm ci: ~10-15s on cache hit

The same pattern works across ecosystems. Here are the setup actions and their cache parameter values:

Ecosystem Setup action Cache value Lockfile
Node.js setup-node@v4 npm package-lock.json
Node.js (Yarn) setup-node@v4 yarn yarn.lock
Node.js (pnpm) setup-node@v4 pnpm pnpm-lock.yaml
Python setup-python@v5 pip requirements.txt
Ruby setup-ruby@v1 bundler Gemfile.lock
Java setup-java@v4 gradle *.gradle*
Go setup-go@v5 (auto) go.sum

One caveat: the built-in cache parameter caches the package manager's global cache directory (e.g., ~/.npm), not node_modules itself. This means npm ci still runs and links packages from the cache into node_modules, but it skips the network download. For most projects, this is the right tradeoff because it avoids stale module issues while still cutting install time by 70–85%.

Fix 2

Consolidate jobs that share the same setup

Every job in GitHub Actions runs on a fresh runner. If you have separate jobs for lint, typecheck, and unit tests that all need the same Node version and the same npm ci, you're paying for three full setups. Merging these into a single job runs setup once and executes each task as a step within the same runner.

The tradeoff is clear: separate jobs give you parallel execution and independent status checks, but they multiply setup cost. For fast tasks like lint, typecheck, and unit tests (each under 2 minutes), consolidation almost always wins. For more on optimizing lint and typecheck specifically, see optimize lint and typecheck ROI.

3 jobs × 90s setup = 4.5 min wasted
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx tsc --noEmit

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm test
1 job × 90s setup = 1.5 min
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
      - run: npm test

One caveat: consolidation means all tasks share one status check. If you need separate required checks for lint and test (e.g., different teams own each), keep them as separate jobs but add caching to each. If you just need to know “did CI pass,” a single consolidated job is simpler and cheaper.

Fix 3

Add explicit dependency caching with actions/cache

The built-in cache parameter on setup actions covers the common case. But when you need to cache additional directories like build outputs, native extension compilations, or multiple package managers in the same job, use actions/cache@v4 directly. This gives you full control over cache keys, paths, and restore strategies.

.github/workflows/ci.yml
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20

  # Cache npm global store + node_modules
  - uses: actions/cache@v4
    id: deps
    with:
      path: |
        ~/.npm
        node_modules
      key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      restore-keys: |
        deps-${{ runner.os }}-

  # Skip install on exact cache hit
  - run: npm ci
    if: steps.deps.outputs.cache-hit != 'true'

The restore-keys fallback is important. When your lockfile changes (new dependency added), the exact key won't match, but the partial key deps-${{ runner.os }}- will restore the most recent cache. npm ci then only downloads the diff. Without restore keys, every lockfile change triggers a full cold install.

GitHub provides 10 GB of cache storage per repository by default. Caches not accessed for 7 days are evicted. When the repository exceeds the limit, the oldest caches are removed first. For most projects, dependency caches are well under 1 GB.

One caveat: caching node_modules directly (as shown above) is faster but can cause issues when switching Node versions or when packages have post-install scripts that compile native extensions. If your project uses native modules (e.g., bcrypt, sharp), cache only ~/.npm and always run npm ci.

Fix 4

Use prebuilt container images to skip setup entirely

Caching speeds up dependency installation, but you're still paying for the setup action to run, the cache to restore, and the install command to verify. For workflows that need identical environments on every run, a Docker container image with dependencies pre-baked eliminates all of that. The runner pulls the image once (and caches the pull), then every job starts with everything already installed.

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/your-org/ci-node:20
    steps:
      - uses: actions/checkout@v4
      # No setup-node, no npm ci - already installed
      - run: npm test

Build the image separately (nightly or on lockfile change) with your Node version, dependencies, and any system libraries pre-installed. Push it to GitHub Container Registry or Docker Hub. Your CI workflow pulls the image and runs tests directly, with zero setup time. For tips on making the Docker build itself faster, see Docker builds never cached.

Dockerfile.ci
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Image now has node_modules ready

One caveat: the container image must be rebuilt whenever package-lock.json changes. A stale image means npm ci runs inside the container anyway, negating the benefit. Trigger image rebuilds on lockfile changes to the default branch, and tag images by lockfile hash for cache-key alignment.

Team / Enterprise Cloud GitHub also offers custom images for larger runners (currently in preview), which let you snapshot a runner's state after setup. This requires GitHub Team or Enterprise Cloud.


Reference

Complete optimized workflow

Here's a workflow that applies all the fixes: built-in dependency caching, consolidated lightweight jobs, and explicit caching for build outputs. Copy and adjust for your project.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  # Consolidated: lint + typecheck + unit tests share one setup
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm            # Fix 1: built-in caching
      - run: npm ci
      - run: npm run lint           # Fix 2: all in one job
      - run: npx tsc --noEmit
      - run: npm test

  # Build kept separate (longer running, different concerns)
  build:
    needs: [check]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build

Reference

GitHub Actions cache limits and behavior

Understanding how GitHub's cache system works helps you avoid common pitfalls:

Property Value
Storage per repo (default) 10 GB
Max storage per repo (org-configurable) 10 TB
Eviction after last access 7 days
Cache key max length 512 characters
Branch scope Current branch + default branch
PR scope Base branch + default branch + PR caches

Branch scoping matters: a cache created on a feature branch is only available to that branch and the default branch. PR workflows can also access caches from the base branch. This means caches created on main are available to all branches, which makes main the ideal place for the canonical cache.

Related guides

Guides / Reduce setup & install overhead

See which jobs waste the most time on setup

CostOps breaks down your CI spend by step category. See how much setup and install costs you per month, and track savings as you optimize.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.