Angular Builds on GitHub Actions

The Angular CLI switches off its disk cache when CI is set. Turn it back on, restore .angular/cache beside the npm store, and size warp- runners per phase.

Last verified:

To make ng build fast on GitHub Actions, set the Angular CLI cache environment to all so the CLI writes its disk cache while CI is set, then restore .angular/cache and the package manager store at the start of the job. On WarpBuild runners both halves are workflow edits: runs-on picks a warp- label from 2 to 32 vCPU, and WarpBuilds/cache is a drop-in replacement for actions/cache@v4.

This page covers the Angular cache setting that most workflows miss, the two cache entries an Angular repository needs, a workflow with the test run split from the production build, sizing for each phase, and the list-price arithmetic on a stated volume.

Overview

The Angular CLI keeps a persistent build cache on disk at .angular/cache, holding the compiler and bundler output that lets a rebuild skip work whose inputs did not move. The cli.cache block in angular.json carries three options: enabled, path, and environment.

environment is the one that decides whether GitHub Actions ever sees a warm cache. It takes local, ci, or all, and it defaults to local, which means the CLI turns the disk cache off whenever the CI environment variable is present. GitHub Actions sets CI to true on every runner, listed in the variables reference. So an Angular repository that restores .angular/cache without changing that option restores a directory the CLI then declines to read. Run ng cache info in the job to print the resolved setting and the on-disk size, documented under the ng cache command.

Two directories are worth carrying between runs, and they change on different schedules. The package manager store moves when the lockfile moves. .angular/cache moves whenever source files move, which is every pull request.

An Angular pipeline then has three phases with different resource shapes:

Install rebuilds node_modules from the store. Bounded by disk and network, not by cores.

Compile and type check runs the Angular compiler over the whole program. This pass is largely single threaded, so cores do little for it and heap size does.

Bundle and minify splits across a worker pool sized from the machine CPU count, and it is the phase that repays a larger runner.

Angular work belongs on the Linux x64 sizes, both because they run to 32 vCPU and because WarpBuild Cache is enabled by default on Linux runners.

Configuration

First, the angular.json change. Without it the workflow below restores a cache the CLI ignores:

{
  "cli": {
    "cache": {
      "enabled": true,
      "environment": "all",
      "path": ".angular/cache"
    }
  }
}

Then the workflow. The test job runs on a 4 vCPU runner and the production build on an 8 vCPU runner, so the two phases stop sharing one machine size. The package manager store is handled by WarpBuilds/setup-node, one of the cache-enabled setup actions that route dependency caching through WarpBuild Cache.

name: angular
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json

      - run: npm ci

      - name: Restore Angular cache
        uses: WarpBuilds/cache@v1
        with:
          path: .angular/cache
          key: angular-test-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }}
          restore-keys: |
            angular-test-${{ runner.os }}-

      - run: npx ng test --watch=false --browsers=ChromeHeadless --progress=false

  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json

      - run: npm ci

      - name: Restore Angular cache
        uses: WarpBuilds/cache@v1
        with:
          path: .angular/cache
          key: angular-build-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }}-${{ github.sha }}
          restore-keys: |
            angular-build-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }}-
            angular-build-${{ runner.os }}-

      - run: npx ng build --configuration production

Three details in that key design earn their place.

The hash covers the lockfile plus angular.json and the tsconfig files. The lockfile carries the compiler and builder versions, and the two configuration files change build output without moving a dependency, so both belong in the key.

The two jobs use different key prefixes. ng test and ng build --configuration production produce different cache contents, and pointing both jobs at one key makes each run overwrite the other job's entry.

The build key ends in github.sha so every run saves a fresh entry, and the ladder underneath falls back to the newest entry on the same dependency and configuration set, then to the newest entry for the runner. A partial hit is safe here: the CLI keys each unit of work internally and redoes only what changed.

Cache entries are scoped to key, version, and branch, and they expire after 7 days of last use, so seed the entry by running the workflow on main and let branch builds reach it through restore-keys. On hosted runners the cache is metered at $0.20 per GB-month of storage and $0.0001 per write or restore, and it is included at no charge on BYOC runners.

Sizing

WarpBuild Linux x64 runners carry 4GB of memory per core across the range documented under cloud runners:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032

The production build takes 8 vCPU. The compile pass holds one core whatever the label says, and the bundling and minification pass spreads across the rest. On 4 vCPU that second pass becomes the visible part of the job for an application past a few hundred components. Above 8 vCPU the returns depend on how much of the build is bundling, so read the phase timings from the build log before moving up.

The Karma job takes 4 vCPU. ng test drives one headless browser by default, so extra cores sit idle while memory does the work: Chrome plus the compiled test bundle is what pushes a small runner into swapping. The Linux x64 images carry the same tooling as GitHub-hosted runners, so Chrome is already installed, as listed in the Ubuntu 24.04 image manifest. Splitting a large suite across parallel jobs is the lever that a bigger single runner cannot pull, and the guide to sizing runners for Node test suites covers where that trade lands.

Worked cost model

Take the pipeline above at 600 pull request runs a month, with a 5 minute build job and a 4 minute test job:

Line itemRateMonthly minutesMonthly cost
Build job, warp-ubuntu-latest-x64-8x$0.016/min3,000$48.00
Test job, warp-ubuntu-latest-x64-4x$0.008/min2,400$19.20
Cache storage and operations (1.5GB, 3,600 ops)$0.20/GB-month plus $0.0001/opn/a$0.66
Total5,400$67.86

Against GitHub list prices for the same shape: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price (GitHub Actions billing reference, checked on 2026-08-13). On the 3,000 build minutes above, that is $48.00 against $66.00.

Every size and platform rate sits on the pricing page.

Bottlenecks

The CLI cache disabled by its own default. This is the first thing to check, because the symptom looks like a cache action problem. The restore step reports a hit, the build still does full work, and the cause is the environment value described in the overview. ng cache info in the job prints the answer in one line.

A key that never repeats. Hashing a directory glob that covers generated files, or every file under src, mints a fresh key on every push and turns the ladder into a cold start. Hash the lockfile and the configuration files instead, and let the CLI decide what inside the directory is still valid.

Install from the registry. When npm ci runs without a warm store, the wall time tracks the dependency count rather than the change. The store setup shown above fixes it, and the Node.js on GitHub Actions page covers the npm, pnpm, and yarn variants. The same store and framework cache split applies to a Vue build.

When the responsible phase is unclear, WarpBuild CI observability correlates runner system metrics with GitHub Actions job logs, which separates a compile pass pinning one core from a bundling pass using all of them. The Action Debugger pauses the workflow and opens an SSH session on the runner, so you can read .angular/cache on the machine itself. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger are part of the same product surface.

Proof

Every cost number here is list-price arithmetic against GitHub's published rates, with the source linked and the date stated, so you can re-run it against your own minute counts.

Public repositories running warp- labels are citable evidence, and reading a runs-on line takes a few seconds. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, which you can read in triggerdotdev/trigger.dev's e2e.yml (checked on 2026-08-13).

FAQ

Why is the Angular cache empty on every GitHub Actions run?

The Angular CLI cache option environment defaults to local, which disables the disk cache whenever the CI environment variable is set, and GitHub Actions sets CI to true on every runner. Set environment to all in the cli.cache block of angular.json, then restore and save .angular/cache with a cache action so the directory survives the runner being destroyed.

What should the .angular/cache key hash?

The lockfile plus angular.json and the tsconfig files. The lockfile covers the compiler and builder versions, and the two configuration files cover the build options that change the output without touching a dependency. Add a restore-keys ladder so a dependency bump restores the previous directory instead of starting cold.

What runner size does an Angular build need?

warp-ubuntu-latest-x64-8x for the production build, where bundling and minification spread across workers, and warp-ubuntu-latest-x64-4x for a Karma job that drives one headless browser. Move the build to 16x only after the log shows the bundling phase holding all 8 cores.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.