GitHub Actions CI/CD That Actually Scales
Build a reusable GitHub Actions workflow system with matrix builds, caching strategies, environment approvals, and security scanning — without copy-pasting YAML into every repo.
Every team starts with copy-pasted GitHub Actions workflows. Then they accumulate. Then they diverge. Then a security fix needs to be applied to 30 different repos. This post is about avoiding that pain by building a reusable workflow system from the start.
The Architecture: Reusable Workflows
GitHub's reusable workflows (workflow_call) let you define a workflow once and reference it from any repo in your organisation. Combined with composite actions, you can build a proper CI/CD framework.
.github/
├── workflows/
│ ├── _reusable-build.yml # Called by application repos
│ ├── _reusable-deploy.yml # Called by application repos
│ └── _reusable-security.yml # Called by application repos
└── actions/
├── setup-node/action.yml # Composite: install + cache
└── docker-build/action.yml # Composite: build + push + sign
The Reusable Build Workflow
# .github/workflows/_reusable-build.yml
# In a central "platform" repo
name: Reusable Build
on:
workflow_call:
inputs:
image-name:
required: true
type: string
node-version:
required: false
type: string
default: '20'
run-tests:
required: false
type: boolean
default: true
outputs:
image-digest:
description: Docker image digest
value: ${{ jobs.build.outputs.digest }}
secrets:
registry-token:
required: true
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Lint
run: npm run lint
- name: Test
if: inputs.run-tests
run: npm test -- --coverage
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.registry-token }}
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}/${{ inputs.image-name }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # SLSA provenanceApplication repos then call it:
# In your application repo
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
uses: myorg/platform/.github/workflows/_reusable-build.yml@main
with:
image-name: myapp
node-version: '20'
secrets:
registry-token: ${{ secrets.GITHUB_TOKEN }}Pin reusable workflow versions
Instead of referencing @main, pin to a tag: @v2.1.0. This prevents upstream workflow changes from breaking your builds unexpectedly. Treat your platform workflows like external dependencies.
Caching That Actually Helps
The default cache: npm in setup-node caches ~/.npm, but doesn't cache node_modules. For a 1000-package project, npm ci with a warm cache still takes 30–90 seconds. Here's a faster pattern:
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ciThis skips npm ci entirely on cache hit — reducing install time from 60s to 2s.
Environment-Gated Deployments
Production deployments should require manual approval. GitHub Environments give you this:
# .github/workflows/_reusable-deploy.yml
jobs:
deploy-staging:
environment: staging # No approval required
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
run: ./scripts/deploy.sh staging
deploy-production:
needs: deploy-staging
environment: production # Requires approval from "platform-team"
runs-on: ubuntu-latest
steps:
- name: Deploy to production
run: ./scripts/deploy.sh productionIn your repo settings: Environments → production → Required reviewers → platform-team.
Now deploy-production waits for a human to click approve before running. The approval request shows the commit, diff, and changelog automatically.
Security Scanning
Every build should include dependency scanning and secret detection:
security:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
# Scan for secrets committed to code
- name: Secret scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Scan dependencies for known CVEs
- name: Dependency review
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
# SAST via CodeQL
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- name: Run CodeQL
uses: github/codeql-action/analyze@v3These run in parallel with your build, so they don't add to your critical path.
Workflow Concurrency
Prevent multiple deployments from running simultaneously:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # Don't cancel in-flight deployments!cancel-in-progress: false is important for deployments. You don't want a new commit to cancel a deployment that's halfway through migrating a database.
Putting It All Together
The complete caller workflow:
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
build:
uses: myorg/platform/.github/workflows/_reusable-build.yml@v2
with:
image-name: myapp
secrets:
registry-token: ${{ secrets.GITHUB_TOKEN }}
security:
uses: myorg/platform/.github/workflows/_reusable-security.yml@v2
deploy:
if: github.ref == 'refs/heads/main'
needs: [build, security]
uses: myorg/platform/.github/workflows/_reusable-deploy.yml@v2
with:
image-digest: ${{ needs.build.outputs.image-digest }}
secrets: inheritThis pattern scales to hundreds of repos with a single source of truth for your CI/CD logic. When you need to update the Docker build action, you change it once and every repo picks it up on the next build.
Written by
Chetan Yamger
Cloud Engineer · AI Automation Architect · Blogger
Cloud Engineer and AI Automation Architect with deep expertise in Azure, Intune, PowerShell, and AI-driven workflows. I use ChatGPT, Gemini, and prompt engineering to build intelligent automation that improves productivity and decision-making in real IT environments.
Stay in the loop.
New articles, straight to you.
Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.
Discussion
Share your thoughts — your email stays private
Leave a comment
