CI/CD Pipelines with GitHub Actions — A Practical Guide
Most CI/CD Pipelines Are Duct Tape
They start as a single YAML file that runs npm test. Six months later, it’s 400 lines of shell scripts, hardcoded secrets, and steps that nobody dares to touch. Build times creep from 2 minutes to 20. Flaky tests get || true appended. Nobody reviews workflow changes.
This is the guide to building pipelines that stay fast, secure, and maintainable as your project grows.
Start With the Dependency Cache
The single biggest time waste in most pipelines: downloading the same dependencies on every run. GitHub Actions has built-in caching, and the setup actions for Node.js, Go, and Python support it natively.
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
For Go projects, cache both the module cache and the build cache:
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
This alone can cut build times by 30–60%. The GitHub Actions cache documentation covers the details, but the principle is simple: never download what you already have.
For Docker builds, layer caching via docker/build-push-action with cache-from and cache-to parameters prevents rebuilding unchanged layers:
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/my-org/my-app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The type=gha backend stores layer caches directly in GitHub’s cache infrastructure — no external registry needed.
Matrix Builds for Cross-Platform Confidence
Testing on a single OS with a single runtime version is a bet that nothing else matters. Matrix builds test combinations automatically:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: ['20', '22']
fail-fast: false
The fail-fast: false flag is important — without it, one failing combination cancels all others, hiding additional failures you need to know about.
For library authors, matrix builds across versions are non-negotiable. For application teams, testing against the next minor version of your runtime catches deprecations before they hit production. The GitHub Actions matrix strategy documentation covers advanced combinations and exclusions.
Reusable Workflows Kill Copy-Paste
When you have 10 repositories with near-identical CI pipelines, updating the linting step means 10 PRs. Reusable workflows solve this:
# .github/workflows/ci-shared.yml (in your org's .github repo)
on:
workflow_call:
inputs:
node-version:
type: string
default: '22'
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
Consuming repositories call it with one line:
jobs:
ci:
uses: my-org/.github/.github/workflows/ci-shared.yml@main
with:
node-version: '22'
One change, one PR, all repositories updated. Pin the reference to a tag or SHA for stability — @main is convenient but means any push to the shared repo immediately affects all consumers.
Security Hardening That Actually Matters
Pin Action Versions to SHA
# Bad: mutable tag
- uses: actions/checkout@v4
# Good: immutable SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Tags can be moved. SHA references cannot. The GitHub Security Hardening guide recommends this, and tools like pin-github-action automate the conversion.
Minimal Permissions
Default GITHUB_TOKEN permissions are too broad. Restrict them:
permissions:
contents: read
pull-requests: write
Set permissions: {} at the workflow level and grant only what each job needs. The permissions documentation lists every scope.
Don’t Trust User Input in Shell
Pull request titles, branch names, and commit messages are user-controlled. Interpolating them into shell commands enables injection:
# Dangerous: PR title could contain $(malicious-command)
- run: echo "PR: ${{ github.event.pull_request.title }}"
# Safe: use an environment variable
- run: echo "PR: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
Environment variables are passed as data, not interpreted as code. This is the #1 GitHub Actions security mistake and it’s trivially preventable.
Deployment Patterns
Environment Protection Rules
For production deployments, use GitHub Environments with required reviewers and wait timers:
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://my-app.example.com
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
The environment configuration in repository settings controls who can approve, how long to wait, and which branches can deploy. This is infrastructure-level gating — no custom approval bot needed.
Rollback Strategy
Every deploy workflow should have a rollback path. The simplest approach: re-run the previous successful deployment.
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref to deploy (tag, SHA, or branch)'
required: true
default: 'main'
workflow_dispatch with an explicit ref input lets you deploy any previous version manually. Combine this with tagged releases and you have one-click rollback without touching production servers.
Monitoring Pipeline Health
Slow pipelines erode trust. Track these metrics:
- P50 and P95 build time — if P95 is 3x your P50, you have flaky or resource-contention issues
- Failure rate — anything above 5% needs investigation; above 15% means developers are ignoring CI
- Time to green after failure — measures how fast the team responds to broken builds
GitHub’s workflow run API exposes timing data. A weekly Slack digest of these numbers keeps pipeline health visible without dashboards nobody checks.
The 80/20 of CI/CD
Most of the value comes from getting five things right:
- Cache dependencies aggressively — cut build times in half
- Use reusable workflows — stop maintaining 10 copies of the same pipeline
- Pin actions to SHAs — prevent supply chain attacks
- Set minimal permissions — limit blast radius
- Gate production deploys with environments — enforce approval without custom tooling
Everything else — matrix builds, concurrency groups, self-hosted runners, composite actions — is optimization on top of a solid foundation. Get the basics right first.