GitHub Actions reusable workflows — DRY your CI pipeline
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:
# .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
# .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:
# Call a workflow in the same repo
uses: ./.github/workflows/deploy.yml
with:
environment: staging
Version-pinning reusable workflows
# 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:
# 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
# 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:
- Central
org/workflowsrepo owned by the platform team - Reusable workflows for: build, test, deploy, security scan, release
- Application repos call the central workflows and only define app-specific logic locally
- Security fixes and new features in
org/workflowspropagate 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.