From 45 Minutes to Under 5: CI/CD Build Acceleration
A 45-minute build doesn't feel like an emergency, which is why it survives so long. Multiply those 45 minutes by every PR, by every push to fix a red CI run and by a team of a dozen engineers. Depending on how often people push, that can easily add up to more than ten engineer-hours a week spent waiting. It also caps your deployment frequency, since nobody ships more often than their pipeline allows.
I've listed the changes below in the order I apply them. If you fix the wrong stage first, you end up re-measuring everything later. To keep the numbers concrete, I'll follow one illustrative monolith pipeline from 45 minutes to under 5. The timings are a representative example and not a benchmark. Your stages and ratios will differ, but the same levers apply.
Where the time went
No single cache accounts for most of the time saved. Most of the gain comes from restructuring the pipeline so that independent work runs concurrently and nothing is done twice.
Notice that the "Before" pipeline builds the application twice, once in the standalone Build step and again inside the Docker build, whose Dockerfile runs its own npm ci and npm run build. This is very common. The "After" pipeline drops the standalone Build step, and the build happens once, inside the image's builder stage (section 2).
1. Dependency caching, keyed correctly
The most common caching mistake: keying the cache on the branch name instead of the lockfile hash. That means the cache almost never hits on a fresh branch, right when you need it most.
The second most common one is caching node_modules. npm ci deletes node_modules before installing, so restoring it wastes time, and a partial restore-keys match can hand an npm install-based job a stale dependency tree. Cache npm's download cache (~/.npm) instead and let npm ci build node_modules from it:
# .github/workflows/ci.yml
- name: Cache npm download cache
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: npm ci --prefer-offlinerestore-keys matters as much as key. On a near-miss (a slightly different lockfile), it still restores most of the cached tarballs, so the install doesn't start from zero. That's safe for ~/.npm because it's a content-addressed store, and npm ci still installs exactly what the lockfile says and fetches only what's missing. If you use actions/setup-node, cache: npm sets this up for you, and the test job below uses it.
2. Docker layer caching via BuildKit registry cache
Rebuilding the full image on every push is the second-largest time sink after dependency installs. BuildKit's registry-backed cache keeps layers between separate CI runs. It needs a Buildx builder, because the default docker driver on the runner can't export cache (you get cache export is not supported for the docker driver). Set up Buildx and log in to the registry first:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to the registry
uses: docker/login-action@v4
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push with layer cache
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: registry.example.com/app:${{ github.sha }}
cache-from: type=registry,ref=registry.example.com/app:buildcache
cache-to: type=registry,ref=registry.example.com/app:buildcache,mode=maxFor multi-stage builds you need mode=max. It also caches the layers of intermediate stages (the builder below), where the default only caches the final image's layers.
Pair this with a multi-stage Dockerfile ordered so rarely-changing layers (dependency installs) come before frequently-changing ones (application source). The builder stage installs everything, including the devDependencies your build needs (TypeScript, bundlers). The runtime stage installs production dependencies only and copies in the build output:
# syntax=docker/dockerfile:1
FROM node:24.21.0-slim AS build
WORKDIR /app
# Dependencies change far less often than source, install them first
# so this layer stays cached across most commits.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Production dependencies only, also cached until the lockfile changes.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Adjust dist/server.js to whatever your build emits, and add node_modules to .dockerignore so COPY . . doesn't drag a local install into the build context. With the lockfile unchanged, both npm ci layers come straight from the cache, and what's left is npm run build on the new source. In this example that's the 3-minute build that used to run twice. It now runs once and makes up most of the image job's 4 minutes.
3. Splitting the test suite across parallel shards
A single-process test run doesn't get faster because your CI runner has more cores. Either the test runner has to be told to use them, or the job has to be split across multiple runners. Each shard is a separate runner with its own checkout, Node.js setup and dependency install, so the cache from section 1 now saves install time on six runners instead of one:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Run test shard ${{ matrix.shard }}
run: npx jest --ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}--shard needs Jest 28 or later, and strategy.job-total keeps the denominator in sync if you change the number of shards. Six shards turn this example's 20-minute serial test run into roughly 3 to 3.5 minutes per shard. That's a bit more than 20/6, because there's always a slowest shard and each runner has its own startup time. Add the ~45-second cached install and the test branch finishes in a bit over 4 minutes, running at the same time as the image build.
4. Runner sizing: bigger isn't always the fix
Before reaching for self-hosted or larger runners, get what you can out of caching and parallelization. Those are close to one-time investments, while larger runners make your bill grow linearly with usage. I usually reach for bigger runners only on the Docker build step, where more CPU cores noticeably speed up the application build inside the builder stage.
The GitLab CI equivalent
The same three levers apply almost 1:1 on GitLab. Use needs to replace the default stage-by-stage ordering with a dependency graph. GitLab can only cache paths inside the project directory, so point npm's cache there:
test:
image: node:24.21.0-slim
parallel: 6
cache:
key:
files:
- package-lock.json
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
- npx jest --ci --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
deploy:
needs: ["test", "docker-build"] # runs as soon as both finish, not after every prior stage
script:
- ./deploy.shStage-by-stage result
These numbers are for the illustrative pipeline above. The "After" column isn't a sum. Tests and the image build run in parallel, so the wall-clock total is the slower of the two branches plus the deploy.
| Stage | Before (serial) | After | |---|---|---| | Install dependencies | 8m 00s | 0m 45s per shard (warm npm cache) | | Build | 3m 00s | no separate step (runs once inside the Docker build) | | Unit tests | 20m 00s | up to 3m 30s (slowest of 6 shards, parallel) | | Docker build | 11m 00s | 4m 00s incl. push (cached dependency layers, parallel with tests) | | Push + deploy | 3m 00s | 0m 30s (deploy only; push happens in the image job) | | Total (wall clock) | 45m 00s | ~4m 45s (0m 45s + 3m 30s test branch, then 0m 30s deploy) |
I run this same audit-and-fix sequence for clients in a CI/CD Acceleration engagement, starting with a timing breakdown of your own pipeline.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out