CI/CD Pipeline Optimization: Speed Up GitHub Actions & Jenkins
CI/CD pipeline optimization guide: caching dependencies, parallel jobs, docker layer caching, test splitting, artifact reuse, GitHub Actions matrix, and reducing build times by 50-80%.
Slow CI/CD pipelines destroy developer productivity. A 20-minute build means developers switch context, lose focus, and often push more commits before the first one is verified β creating a feedback loop of queued failures. Optimizing your pipeline from 20 minutes to 4 minutes is not a luxury; it is a productivity multiplier that compounds across every engineer on your team every single day.
This guide covers the concrete techniques that consistently yield 50β80% build time reductions in real projects.
1. Cache Dependencies Aggressively
Dependency installation is usually the single largest time sink in a pipeline. Cache it.
# GitHub Actions β Node.js with npm cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm" # Built-in cache for npm/yarn/pnpm
# Or manually for more control
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
# Python with pip cache
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# Go modules cache
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
The cache key must include a hash of your lockfile. When dependencies change, the lockfile changes, invalidating the cache and triggering a fresh install. On cache hits, dependency installation drops from 2β3 minutes to under 5 seconds.
2. Run Jobs in Parallel
Most pipelines run lint, test, and build sequentially when they could run concurrently. Structure your workflow to fan out where possible and fan in for deployment:
jobs:
# These three jobs run in parallel
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run type-check
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:unit
# This job waits for all three to complete
integration-tests:
needs: [lint, type-check, unit-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
# Deploy only after everything passes
deploy:
needs: [integration-tests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./scripts/deploy.sh
If lint takes 2 minutes and unit tests take 4 minutes running sequentially that is 6 minutes. Running them in parallel collapses to 4 minutes β the slower of the two.
3. Use a Build Matrix for Cross-Platform Testing
Instead of writing separate jobs for each platform or version, use matrix strategy:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["18", "20", "22"]
exclude:
# Skip Windows + Node 18 to save runner minutes
- os: windows-latest
node: "18"
fail-fast: false # Don't cancel all jobs if one fails
runs-on: ${{ matrix.os }}
name: Test on ${{ matrix.os }} / Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "npm"
- run: npm ci
- run: npm test
fail-fast: false is important β if you want full test coverage across all combinations, you need every matrix job to run to completion, not stop at the first failure.
4. Docker Layer Caching
Docker builds are often the slowest part of a pipeline. Optimizing your Dockerfile layer order and using GitHub Actions cache or registry cache can cut image build times by 70%+.
Order layers from least to most frequently changing:
FROM node:20-alpine
WORKDIR /app
# 1. System dependencies (changes rarely)
RUN apk add --no-cache curl
# 2. Package files (changes when dependencies change)
COPY package*.json ./
RUN npm ci --only=production
# 3. Application code (changes on every commit)
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Use GitHub Actions cache for Docker layers:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.IMAGE_TAG }}
cache-from: type=gha # Read cache from GitHub Actions cache
cache-to: type=gha,mode=max # Write all layers to cache
The type=gha cache backend stores Docker layer cache in GitHubβs cache storage (up to 10GB free). For large images, use type=registry to cache layers in your container registry instead.
5. Split Tests by Duration
Running all tests in a single process is wasteful when you have fast runners that could be processing tests in parallel. Use test splitting tools to distribute your test suite:
# Jest with --shard flag (Jest 29+)
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4] # Split into 4 parallel shards
steps:
- run: |
npx jest \
--shard=${{ matrix.shard }}/4 \
--coverage \
--coverageDirectory=coverage/${{ matrix.shard }}
coverage-merge:
needs: test
steps:
- name: Download all coverage reports
uses: actions/download-artifact@v4
- run: npx nyc merge coverage/ merged-coverage.json
# pytest with pytest-split
- run: |
pytest --splits 4 --group ${{ matrix.group }} \
--store-durations # Track test durations for smarter splitting
For a 500-test suite that runs in 8 minutes sequentially, splitting across 4 runners reduces it to approximately 2 minutes β a 4x speedup for the cost of 3 additional runner minutes.
6. Reuse Build Artifacts Between Jobs
When multiple jobs need the same built output, build once and share via artifacts:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
test-e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: npm run test:e2e # Uses pre-built dist, no rebuild needed
deploy-staging:
needs: [build, test-e2e]
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh staging
Without artifact reuse, every downstream job would rebuild the application. With artifact reuse, the build happens exactly once.
7. Jenkins Pipeline Optimization
For Jenkins users, the equivalent optimizations:
pipeline {
agent none
options {
buildDiscarder(logRotator(numToKeepStr: '10'))
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
}
stages {
stage('Parallel Checks') {
parallel {
stage('Lint') {
agent { label 'node' }
steps {
sh 'npm ci'
sh 'npm run lint'
}
}
stage('Unit Tests') {
agent { label 'node' }
steps {
// Use stash to cache node_modules across stages
sh 'npm ci'
sh 'npm test -- --shard=1/2'
stash includes: 'coverage/**', name: 'coverage-1'
}
}
}
}
stage('Build Docker Image') {
agent { label 'docker' }
steps {
script {
docker.build(
"${IMAGE_NAME}:${GIT_COMMIT}",
"--cache-from ${IMAGE_NAME}:latest ."
)
}
}
}
}
post {
always {
cleanWs()
}
}
}
The --cache-from flag pulls the previous imageβs layers as a cache source. Combined with properly ordered Dockerfile layers, this makes subsequent builds significantly faster.
8. Targeted Pipeline Triggers
Not every commit needs to run the full pipeline. Use path filters to run only relevant jobs:
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
if: |
github.event_name == 'push' ||
contains(github.event.pull_request.changed_files, 'backend/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
- if: steps.changes.outputs.backend == 'true'
run: cd backend && npm test
# Simpler: use paths in the trigger itself
on:
push:
paths:
- "backend/**"
- "shared/**"
A change to a README file or a frontend asset should not trigger your full backend test suite.
Measuring Your Improvements
Before optimizing, establish your baseline. GitHub Actions provides timing data per step β check the pipeline run summary. Look for:
- Steps that run sequentially but could be parallel
- Dependency installation steps without cache hits
- Docker builds without layer caching
- Test suites running on a single runner
A well-optimized pipeline typically has 80%+ cache hit rates, nearly all parallelizable work running in parallel, and Docker builds completing in under 60 seconds. Track your average pipeline duration week-over-week and set a target β most teams can reach under 5 minutes for their primary pipeline with these techniques.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.