How We Slashed CI/CD Build Times by 60% with Custom GitHub Actions.

Rishav Sinha

Rishav Sinha

Published on · 5 min read

How We Slashed CI/CD Build Times by 60% with Custom GitHub Actions.

The Bottleneck: Waiting 25 Minutes for a Typo Fix

We hit a breaking point. Our engineering team had grown to 20+ developers working in a TypeScript monorepo containing three Next.js frontends, two Node.js services, and a shared UI library. The feedback loop was excruciating.

Deploying a single line of CSS change triggered a 25-minute CI pipeline.

Developers were context-switching while waiting for checks to pass, leading to broken flow states and a massive backlog of PRs on Fridays. We were also burning through our GitHub Actions minutes budget at an alarming rate. We needed to treat our CI pipeline like a product, not an afterthought.

The "Naive" Approach: Run All The Things

In the early days, like many startups, we opted for simplicity. Our .github/workflows/ci.yml was a brute-force instrument. If a developer pushed code, we ran npm install, npm test, npm run lint, and npm run build on everything.

This works fine when you have 50 files. It is disastrous at scale.

Why this fails:

  1. Redundant Compute: Touching the marketing-site shouldn't trigger tests for the backend-api.
  2. No Layer Caching: We were downloading the same 800MB of node_modules on every single run.
  3. Linear Execution: Jobs were running sequentially rather than leveraging the inherent parallelism of the graph.

The Architecture: Smart Monorepos and Composite Actions

To cut build times by 60% (from ~25m to ~8m), we moved to an architecture centered around Impact Analysis and Remote Caching.

We stuck with Turborepo for the orchestration because of its hashing algorithm, but the real magic happened in how we integrated it with GitHub Actions.

The Strategy:

  1. Composite Actions: We abstracted our setup logic (Node install, pnpm setup, cache restoration) into a local composite action to ensure consistency across jobs.
  2. Remote Caching: We connected Turbo to a remote cache artifact store (Vercel or self-hosted). If a hash matches, the build is skipped entirely, and artifacts are downloaded in seconds.
  3. Affected Graph: We configured the CI to only run tasks against packages that actually changed (compared to main).

Needle in haystack

The Implementation (Deep Work)

Let's look at the configuration. The biggest win came from standardizing our environment setup to maximize cache hit rates.

First, we created a Composite Action. This reduces code duplication in your workflows and allows you to centralize the logic for caching pnpm stores.

# .github/actions/setup-node-pnpm/action.yml
name: "Setup Node & PNPM"
description: "Sets up Node.js, pnpm, and handles strict caching strategies"

inputs:
  node-version:
    description: "Node version to use"
    required: false
    default: "20"

runs:
  using: "composite"
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - name: Install pnpm
      uses: pnpm/action-setup@v2
      with:
        version: 9.0.0
        run_install: false

    - name: Get pnpm store directory
      id: pnpm-cache
      shell: bash
      run: |
        echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

    # SENIOR TIP: Hash the lockfile strictly. If lockfile hasn't changed,
    # restoring modules takes seconds instead of minutes.
    - name: Setup pnpm cache
      uses: actions/cache@v4
      with:
        path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
        key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-pnpm-store-

    - name: Install dependencies
      shell: bash
      run: pnpm install --frozen-lockfile

Next, the Turbo Configuration. You must define your pipeline strictly so Turbo knows that build depends on ^build.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      // "outputs" is CRITICAL for caching.
      // If these files exist, Turbo skips the task.
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    },
    "lint": {}
  }
}

Finally, the Main Workflow. We use the --filter flag here. Note the use of concurrency to cancel outdated builds, this alone saves significant cost.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: ["main"]
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    name: Quality Checks
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Environment
        uses: ./.github/actions/setup-node-pnpm

      - name: Lint & Test Affected
        run: pnpm turbo run lint test --filter="[origin/main...HEAD]"

  build:
    name: Build Affected
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Environment
        uses: ./.github/actions/setup-node-pnpm

      # If the artifacts are in the remote cache, this step takes ~10 seconds
      - name: Build
        run: pnpm turbo run build --filter="[origin/main...HEAD]"
        env:
          # Ensure you have your remote cache credentials set in GitHub Secrets
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

The "Gotchas":

Optimizing CI is never as clean as the tutorials make it look. We ran into two specific issues that caused headaches:

  1. The "Fetch Depth" Trap: Initially, turbo --filter was returning empty lists or failing entirely. It turns out actions/checkout defaults to fetch-depth: 1 ie shallow clone. Turborepo needs the git history to compare the current HEAD against origin/main. Always set fetch-depth: 0 if you are doing diff-based execution.

  2. Next.js Cache Bloat: We tried to cache the .next/cache folder to speed up subsequent builds. However, the cache grew to over 3GB, and the time spent downloading and unzipping the cache from GitHub Actions storage took longer than just rebuilding the app. We optimized this by excluding bulky static assets and only caching the specific Webpack build traces.

Needle in haystack

Conclusion

By moving to a smart monorepo structure and leveraging composite actions, we reduced our average build time from 25 minutes to roughly 8 minutes. On a good day, with high cache hit rates, it's under 3 minutes.

The takeaway when CI gets out of the way, engineers ship faster and break things less often. Stop letting your pipeline be the bottleneck.