Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
© 2026
AutomationIntermediate

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.

4 min read
Share

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

yaml
# .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 provenance

Application repos then call it:

yaml
# 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:

yaml
- 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 ci

This 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:

yaml
# .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 production

In 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:

yaml
  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@v3

These run in parallel with your build, so they don't add to your critical path.

Workflow Concurrency

Prevent multiple deployments from running simultaneously:

yaml
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:

yaml
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: inherit

This 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.

CChetan Yamger

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.

AI AutomationAzure & IntunePowerShell & PythonNode.js / Next.jsApplication PackagingPower BIGeminiVDI / WVDGitHub ActionsM365Graph APIPrompt Engineering
Newsletter

Stay in the loop.
New articles, straight to you.

Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.

New article notifications
No spam, ever
Free forever

Discussion

Share your thoughts — your email stays private

Leave a comment

0/2000

Your email is used to prevent spam and will never be displayed.