CI/CD

GitHub Actions Deep Dive: Writing Reusable Workflows

Go beyond basic GitHub Actions: composite actions, reusable workflows, secret passing, dependency caching, matrix builds, and permission lockdown patterns you can apply immediately.

A
Abhishek Patel10 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

GitHub Actions Deep Dive: Writing Reusable Workflows
GitHub Actions Deep Dive: Writing Reusable Workflows

Beyond the Starter Template

Most GitHub Actions workflows start the same way: copy a YAML snippet from the docs, tweak the Node version, add npm test, and call it done. That works for a single repo. It falls apart the moment you have five repos running near-identical pipelines with slight variations, each drifting in its own direction.

GitHub Actions has built-in mechanisms for reuse — composite actions and reusable workflows — but the documentation buries the practical patterns under specification details. This guide covers the patterns that actually matter: how to write workflows that are DRY, fast, and locked down with minimal token scope.

Composite Actions vs Reusable Workflows

GitHub gives you two distinct reuse mechanisms, and picking the wrong one leads to awkward workarounds. Here's the real difference:

FeatureComposite ActionReusable Workflow
ScopeA single step inside a jobAn entire job (or set of jobs)
Defined inaction.yml in a repo or subdirectoryA .yml file under .github/workflows/
Can containMultiple run steps and action callsFull jobs with runners, services, matrices
Secrets accessInherits from calling jobMust be explicitly passed or use secrets: inherit
RunnerRuns on the caller's runnerSpecifies its own runner
OutputsStep-level outputsJob-level outputs
Best forShared setup steps, lint/format checksEntire CI/CD pipelines, deploy workflows

Definition: A composite action bundles multiple steps into a single reusable action that runs within the caller's job. A reusable workflow is a complete workflow file that runs as a separate job, triggered by workflow_call from another workflow.

When to Use Each

Use a composite action when you're extracting a handful of steps that multiple jobs share — setting up a language runtime, installing dependencies, running linters. The action runs inside the caller's job, on the caller's runner, with access to the same filesystem.

Use a reusable workflow when you're standardizing an entire pipeline across repos — a full CI job, a deployment pipeline, a release workflow. It runs as its own job with its own runner, so it's fully isolated.

Writing a Composite Action

A composite action lives in an action.yml file. Here's a practical one that sets up Node.js with pnpm and caches dependencies:

# .github/actions/setup-node-pnpm/action.yml
name: 'Setup Node + pnpm'
description: 'Install Node.js and pnpm with dependency caching'
inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'
runs:
  using: 'composite'
  steps:
    - uses: pnpm/action-setup@v4
      with:
        version: 9
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'pnpm'
    - run: pnpm install --frozen-lockfile
      shell: bash

Now every job that needs Node + pnpm uses a single step instead of three:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node-pnpm
        with:
          node-version: '22'
      - run: pnpm test

Pro tip: Put composite actions in the same repo under .github/actions/ for repo-specific actions, or in a dedicated shared repo (e.g., org/actions) and reference them with uses: org/actions/setup-node-pnpm@v1. Tag releases with semver so consumers can pin versions.

Writing a Reusable Workflow

A reusable workflow is a regular workflow file that uses workflow_call as its trigger. Here's a full CI workflow designed to be called from any repo in your org:

# .github/workflows/ci-reusable.yml (in your shared repo)
name: CI Pipeline
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'
      run-e2e:
        type: boolean
        default: false
    secrets:
      CODECOV_TOKEN:
        required: false

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm test -- --coverage
      - if: secrets.CODECOV_TOKEN != ''
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  e2e:
    if: inputs.run-e2e
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps
      - run: pnpm e2e

A caller workflow references it with uses:

# .github/workflows/ci.yml (in any repo)
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    uses: org/shared-workflows/.github/workflows/ci-reusable.yml@v1
    with:
      node-version: '22'
      run-e2e: true
    secrets:
      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

Passing Secrets Safely

Secrets handling is where reusable workflows get tricky. You have three options:

Option 1: Explicit Secret Passing

The caller lists each secret individually. Most verbose, most visible, most secure.

jobs:
  deploy:
    uses: org/workflows/.github/workflows/deploy.yml@v1
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Option 2: Inherit All Secrets

Pass every secret the caller has access to. Convenient but gives the reusable workflow access to secrets it might not need.

jobs:
  deploy:
    uses: org/workflows/.github/workflows/deploy.yml@v1
    secrets: inherit

Option 3: Environment Secrets

Use GitHub Environments to scope secrets. The reusable workflow references an environment, and only secrets configured in that environment are available. This is the cleanest approach for deployment workflows that need different credentials per environment (staging vs production).

Watch out: secrets: inherit passes all repository and organization secrets to the callee. If the reusable workflow is in a different repo and you don't fully trust it, always use explicit secret passing instead. Treat secrets like function arguments, not global variables.

Caching Dependencies the Right Way

Slow CI usually means slow dependency installation. The actions/cache action stores files between runs so you don't download the internet on every push.

Built-in Cache with setup-node

The simplest approach: actions/setup-node has a built-in cache parameter that handles the cache key and restore automatically.

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'  # also supports npm, yarn

Manual Cache for Custom Paths

For more control or when caching things beyond package managers (build artifacts, Playwright browsers, Docker layers):

- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/ms-playwright
      node_modules/.cache
    key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-playwright-

The key is an exact match — if the lockfile changes, the cache misses. The restore-keys provides a prefix fallback so you get a partial cache hit (old browsers) rather than a cold start. GitHub provides 10 GB of cache storage per repo, evicting the least recently used entries when you exceed the limit.

Matrix Builds: Test Across Versions Fast

Matrix builds run the same job across multiple configurations in parallel. Here's a matrix that tests across Node versions and operating systems:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
        exclude:
          - os: macos-latest
            node-version: 18  # skip old Node on macOS
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

Pro tip: Set fail-fast: false in matrix builds. The default (true) cancels all running jobs the moment one fails, which means you only see the first failure. With fail-fast: false, you get the full picture of what's broken across all configurations in a single run.

Locking Down Permissions

By default, the GITHUB_TOKEN in a workflow has broad read-write access to your repository. Most workflows don't need anywhere near that much scope.

Set Restrictive Defaults at the Workflow Level

permissions:
  contents: read  # default for all jobs in this workflow

jobs:
  test:
    runs-on: ubuntu-latest
    # inherits contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write     # only this job gets deployment scope
      id-token: write        # for OIDC-based cloud auth
    steps:
      - uses: actions/checkout@v4
      - run: deploy.sh

Permission Cheat Sheet

TaskMinimum Permission
Check out codecontents: read
Push commits / create tagscontents: write
Comment on PRspull-requests: write
Create deploymentsdeployments: write
Push to GHCR (container registry)packages: write
OIDC token for cloud providersid-token: write
Read org/repo secretsAutomatic (no explicit permission)

Watch out: If you set permissions at the workflow level, any permission you don't list is set to none. This is actually what you want for security, but it trips people up when a step suddenly fails with a 403 because it needs a permission you didn't declare.

CI/CD Platform Pricing Comparison

GitHub Actions is generous for open source, but the costs add up for private repos with heavy usage:

PlatformFree TierPaid (per minute)Standout Feature
GitHub Actions2,000 min/month (free repos unlimited)$0.008 (Linux), $0.016 (Windows)Native GitHub integration, largest marketplace
GitLab CI400 min/month$0.005 (Linux)Built-in container registry, Auto DevOps
CircleCI6,000 min/month$0.006 (Linux)Docker layer caching, resource classes
AWS CodeBuild100 min/month$0.005 (small Linux)Deep AWS integration, custom compute

Pro tip: GitHub Actions charges per minute, rounded up to the nearest minute. A job that takes 61 seconds costs 2 minutes. Optimizing cache hits and parallelization directly reduces your bill. Larger runners (4x, 8x CPU) cost more per minute but often finish faster, reducing total cost.

Frequently Asked Questions

What is the difference between a composite action and a reusable workflow?

A composite action bundles multiple steps into a single reusable step that runs inside the caller's job on the caller's runner. A reusable workflow is a complete workflow that runs as its own separate job with its own runner. Use composite actions for shared setup steps; use reusable workflows for standardizing entire pipelines across repos.

How do I pass secrets to a reusable workflow?

Three ways: explicitly list each secret in the caller's secrets: block (most secure), use secrets: inherit to pass all secrets (convenient but over-permissioned), or use GitHub Environments to scope secrets per deployment target. For cross-repo reusable workflows, always prefer explicit passing over inherit.

How much cache storage does GitHub Actions provide?

Each repository gets 10 GB of cache storage across all workflows. When the limit is exceeded, GitHub evicts the least recently used cache entries. Cache entries that haven't been accessed in over 7 days are also automatically cleaned up. You can view cache usage in the repository's Actions tab under "Caches."

How do I make GitHub Actions workflows faster?

The biggest wins: cache dependencies aggressively (use the built-in cache parameter in setup actions), run independent jobs in parallel instead of sequentially, use matrix builds to parallelize cross-version testing, and skip unnecessary steps with conditional expressions (if:). For large monorepos, use path filters to only run workflows when relevant files change.

What permissions does the GITHUB_TOKEN have by default?

For workflows triggered by events in the same repository, the default token has broad read-write access. For pull requests from forks, it's read-only. Setting explicit permissions: at the workflow or job level overrides the defaults — any permission you don't list becomes none. Always set permissions explicitly for security.

Can I use reusable workflows from a private repository?

Yes, but only within the same organization. The repository containing the reusable workflow must explicitly allow access from other repositories in the organization (under Settings > Actions > General). Public repositories can be referenced by any repository without additional configuration.

Conclusion

GitHub Actions is more than a YAML-based task runner. Composite actions let you DRY up shared steps across jobs. Reusable workflows let you standardize entire pipelines across repos. Matrix builds test across configurations in parallel. And explicit permissions ensure your workflows only have the access they actually need.

Start by extracting your setup steps into a composite action — it's the lowest-effort, highest-value refactor. Then look at your most duplicated workflow across repos and convert it to a reusable workflow with proper inputs and typed secrets. Most teams can cut their total workflow YAML by 60-70% with these two patterns alone.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.