GitHub Actions reusable workflows — DRY your CI pipeline
← Back
April 4, 2026DevOps7 min read

GitHub Actions reusable workflows — DRY your CI pipeline

Published April 4, 20267 min read

Every repo in your org has the same deploy job. Same lint job. Same Docker build. You copy-paste the workflow YAML, it drifts between repos, someone fixes a security issue in one and forgets the other twelve. Reusable workflows solve this: define the job once in a central repo, call it from every other repo. When you update the central workflow, all repos pick up the change automatically.

Creating a reusable workflow

A reusable workflow uses workflow_call as its trigger:

yaml
# .github/workflows/deploy.yml in org/workflows repo
name: Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
      region:
        required: false
        type: string
        default: 'us-east-1'
    secrets:
      AWS_ACCESS_KEY_ID:
        required: true
      AWS_SECRET_ACCESS_KEY:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ inputs.region }}

      - name: Deploy to ECS
        run: |
          aws ecs update-service             --cluster ${{ inputs.environment }}-cluster             --service myapp             --force-new-deployment

Calling a reusable workflow

yaml
# .github/workflows/ci-cd.yml in any application repo
name: CI/CD

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - id: tag
        run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
      - run: docker build -t myapp:${{ steps.tag.outputs.tag }} .

  deploy-staging:
    needs: build
    uses: org/workflows/.github/workflows/deploy.yml@main  # reference the shared workflow
    with:
      environment: staging
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

  deploy-production:
    needs: deploy-staging
    uses: org/workflows/.github/workflows/deploy.yml@main
    with:
      environment: production
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets: inherit  # pass all secrets from the calling workflow

Secrets inheritance

secrets: inherit passes all secrets from the calling workflow to the reusable workflow. Use it when you want transparent secret passing without listing them explicitly. Use explicit secret passing when you want to be clear about what is shared.

Sharing workflows within the same repo

Reusable workflows do not have to be in a separate repo. Reference them with a relative path:

yaml
# Call a workflow in the same repo
uses: ./.github/workflows/deploy.yml
with:
  environment: staging

Version-pinning reusable workflows

yaml
# Pin to a specific tag for stability
uses: org/workflows/.github/workflows/deploy.yml@v2.1.0

# Pin to a commit SHA for maximum security
uses: org/workflows/.github/workflows/deploy.yml@a3f9d21

# Use main for always-latest (convenient but can break)
uses: org/workflows/.github/workflows/deploy.yml@main

For production deployments, pin to a tag or SHA. For internal tooling workflows, @main is usually fine since you control the source.

Composite actions vs reusable workflows

Two ways to share logic — different tradeoffs:

yaml
# Composite action: reuse steps within a job
# action.yml in org/actions/setup-node/
name: 'Setup Node with cache'
description: 'Setup Node.js with npm cache'
inputs:
  node-version:
    required: true
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - run: npm ci
      shell: bash

# Reusable workflow: reuse entire jobs
# Better when you need separate runners, environments, or concurrency control

Use composite actions when you want to reuse a sequence of steps within a single job. Use reusable workflows when you want to reuse entire jobs (with their own runner, environment, concurrency settings).

Outputs from reusable workflows

yaml
# In the reusable workflow
on:
  workflow_call:
    outputs:
      deployment-url:
        description: "The URL of the deployed service"
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    outputs:
      url: ${{ steps.deploy.outputs.service-url }}
    steps:
      - id: deploy
        run: |
          URL=$(deploy_and_get_url)
          echo "service-url=$URL" >> $GITHUB_OUTPUT

---
# In the calling workflow
jobs:
  deploy:
    uses: org/workflows/.github/workflows/deploy.yml@main

  notify:
    needs: deploy
    steps:
      - run: |
          echo "Deployed to ${{ needs.deploy.outputs.deployment-url }}"

The governance model

The pattern I use in organisations with multiple application repos:

  1. Central org/workflows repo owned by the platform team
  2. Reusable workflows for: build, test, deploy, security scan, release
  3. Application repos call the central workflows and only define app-specific logic locally
  4. Security fixes and new features in org/workflows propagate to all apps on next PR

The initial setup takes a day. The ongoing maintenance is near-zero compared to keeping N repos in sync manually.

Share this
← All Posts7 min read