GitHub Actions: Reusable Workflows and Composite Actions

GitHub Actions: Reusable Workflows and Composite Actions
When you have 20 repositories that all need to build, test, and deploy in a consistent way, maintaining 20 copies of the same workflow YAML is a maintenance nightmare. One security fix, one version bump, one new requirement means editing every copy. Reusable workflows solve this: write the workflow once in a central location, call it from any repository with a single line.
This guide covers reusable workflows with workflow_call, composite actions for step-level reuse, passing inputs and secrets, setting up a centralised DevOps repository, and controlling access to private reusable workflows.
Reusable Workflows vs Composite Actions
Before writing any code, understand which tool fits your scenario:
| Reusable Workflow | Composite Action | |
|---|---|---|
| Unit of reuse | Entire jobs | Individual steps within a job |
| Runs in | A separate runner | The caller's runner |
| Can use | Any action, service containers | Actions only (no service containers) |
| Secrets | Inherit or explicit mapping | Must use inputs or env |
| Outputs | Job outputs → workflow outputs | Step outputs |
| Best for | Full deployment pipelines, CI test suites | Setup steps, repeated command sequences |
Use a reusable workflow when you want to standardise an entire job or series of jobs across repos.
Use a composite action when you want to package a few repeated steps that run inside someone else's job.
Creating a Reusable Workflow
A reusable workflow is triggered by workflow_call instead of push or pull_request.
# .github/workflows/deploy.yml (in your central devops repo: my-org/.github)
name: Deploy Application
on:
workflow_call:
inputs:
environment:
description: 'Target environment (production, staging)'
type: string
required: true
image_tag:
description: 'Docker image tag to deploy'
type: string
required: true
slack_channel:
description: 'Slack channel for deployment notifications'
type: string
required: false
default: '#deployments'
secrets:
AWS_ROLE_ARN:
description: 'AWS IAM role ARN to assume'
required: true
SLACK_WEBHOOK_URL:
required: false
outputs:
deployment_url:
description: 'URL of the deployed environment'
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # Links to GitHub Environment for approval rules
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy to ECS
id: deploy
run: |
aws ecs update-service \
--cluster ${{ inputs.environment }}-cluster \
--service app-service \
--force-new-deployment
echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"
- name: Notify Slack
if: always() && secrets.SLACK_WEBHOOK_URL != ''
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "Deployment to ${{ inputs.environment }}: ${{ job.status }}",
"channel": "${{ inputs.slack_channel }}"
}Calling a Reusable Workflow
From any repository in your organisation, call the reusable workflow:
# .github/workflows/cd.yml (in my-org/my-app repository)
name: Continuous Deployment
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
id: meta
run: |
IMAGE_TAG="${{ github.sha }}"
docker build -t my-org/my-app:${IMAGE_TAG} .
docker push my-org/my-app:${IMAGE_TAG}
echo "version=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
deploy-staging:
needs: build
uses: my-org/.github/.github/workflows/deploy.yml@main
with:
environment: staging
image_tag: ${{ needs.build.outputs.image_tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE_ARN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
deploy-production:
needs: [build, deploy-staging]
uses: my-org/.github/.github/workflows/deploy.yml@main
with:
environment: production
image_tag: ${{ needs.build.outputs.image_tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Key points:
uses:specifies{owner}/{repo}/.github/workflows/{file}.yml@{ref}- The
@mainref pins to the main branch — you can also pin to a tag like@v1.2.0 with:passes inputs,secrets:passes secrets explicitly- You cannot mix
secrets: inheritand explicit secret mapping in the same call
Secrets: Inherit vs Explicit Mapping
secrets: inherit
The simplest approach — the called workflow automatically receives all secrets the calling workflow has access to:
deploy-production:
uses: my-org/.github/.github/workflows/deploy.yml@main
with:
environment: production
image_tag: ${{ needs.build.outputs.image_tag }}
secrets: inherit # All secrets are passed automaticallyTrade-off: Simple but less explicit — you don't see which secrets are being passed. Use for internal workflows where you trust the template.
Explicit Secret Mapping
More secure and auditable — name every secret explicitly:
deploy-production:
uses: my-org/.github/.github/workflows/deploy.yml@main
with:
environment: production
secrets:
AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }} # Maps caller's secret name to called workflow's expected name
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}Use explicit mapping when the called workflow is in a public or shared repository where you want clarity about which secrets are exposed.
Composite Actions
A composite action packages multiple steps into a single reusable step. It lives in a repository as an action.yml file.
Example: Node.js Setup Composite Action
# actions/setup-node/action.yml (in my-org/.github repository)
name: 'Setup Node.js with Cache'
description: 'Install Node.js, restore npm cache, and install dependencies'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
working-directory:
description: 'Directory containing package.json'
required: false
default: '.'
outputs:
cache-hit:
description: 'Whether the npm cache was restored'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore npm cache
id: cache
uses: actions/cache@v4
with:
path: ${{ inputs.working-directory }}/node_modules
key: node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: npm ci
working-directory: ${{ inputs.working-directory }}Using a Composite Action
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Use the composite action — all three steps collapse to one
- uses: my-org/.github/actions/setup-node@main
with:
node-version: '20'
- run: npm testVersioning Reusable Workflows
Pinning reusable workflows to @main means all callers immediately get changes when you update the template. This is convenient but can cause unexpected breakage.
For stable, production-grade templates, version them with tags:
# v1 of the deploy workflow
uses: my-org/.github/.github/workflows/deploy.yml@v1
# Upgrade to v2 explicitly when ready
uses: my-org/.github/.github/workflows/deploy.yml@v2Create semantic version tags in the .github repo when you make breaking changes:
git tag -a v2.0.0 -m "Breaking: require image_tag input, remove auto-detection"
git push origin v2.0.0Teams can upgrade at their own pace by changing the @v1 reference to @v2.
The Centralised DevOps Repository Pattern
Create a dedicated repository for shared workflows. GitHub has a special convention: a repository named .github in your organisation is accessible without specifying the full path in workflow references.
Repository structure
my-org/.github/
├── .github/
│ └── workflows/
│ ├── ci.yml # Standard CI: lint, test, build
│ ├── deploy-ecs.yml # Deploy to AWS ECS
│ ├── deploy-k8s.yml # Deploy to Kubernetes
│ ├── security-scan.yml # SAST and dependency scanning
│ ├── release.yml # Semantic release automation
│ └── pr-checks.yml # PR validation: size, labels, branch naming
├── actions/
│ ├── setup-node/
│ │ └── action.yml
│ ├── setup-python/
│ │ └── action.yml
│ └── docker-build-push/
│ └── action.yml
└── README.md # Documents available workflows and how to use themAccess control for private reusable workflows
Reusable workflows in a private repository are not accessible to other organisations by default. Configure access in the repository settings:
- Go to the
.githubrepo → Settings → Actions → General - Under "Access", select Accessible from repositories in the 'my-org' organization
- Optionally restrict to specific repositories
Practical: CI Template for All Node.js Services
# my-org/.github/.github/workflows/nodejs-ci.yml
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
type: string
required: false
default: '20'
run-integration-tests:
type: boolean
required: false
default: false
secrets:
DATABASE_URL:
required: false
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: my-org/.github/actions/setup-node@main
with:
node-version: ${{ inputs.node-version }}
- run: npm run lint
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: my-org/.github/actions/setup-node@main
- name: Run unit tests
run: npm test
- name: Run integration tests
if: inputs.run-integration-tests
run: npm run test:integration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL || 'postgresql://postgres:test@postgres:5432/testdb' }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: my-org/.github/actions/setup-node@main
- name: Audit npm dependencies
run: npm audit --audit-level=high
- uses: github/codeql-action/init@v3
with:
languages: javascript
- uses: github/codeql-action/analyze@v3Calling this from any Node.js service:
# .github/workflows/ci.yml (in any Node.js service repo)
name: CI
on: [push, pull_request]
jobs:
ci:
uses: my-org/.github/.github/workflows/nodejs-ci.yml@main
with:
run-integration-tests: true
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}Ten lines replaces 80 lines of per-repo CI configuration. When the security team adds a new scan, one update to the template propagates to all services.
Frequently Asked Questions
Q: Can a reusable workflow call another reusable workflow?
Yes, up to a nesting depth of 4 levels. A called workflow can itself call another reusable workflow. This is useful for composing complex pipelines (CI calls a test workflow which calls a code-coverage workflow).
Q: Can I use reusable workflows from public repositories in private repos?
Yes. Public reusable workflows are accessible from any repository. For private reusable workflows, the calling repository must be granted access through the source repository's settings (Settings → Actions → General → Access).
Q: What happens when I update a reusable workflow pinned to @main?
All workflows currently referencing @main will use the new version on their next run. There is no approval step. This is why production-critical workflows should be pinned to a version tag, not @main.
Q: Is there a limit to how many inputs a reusable workflow can have?
GitHub supports up to 10 inputs and 10 secrets per workflow_call definition. If you need more, consider grouping related values into a JSON string input and parsing it within the workflow.
Key Takeaway
Reusable workflows are the foundation of a platform engineering approach to CI/CD. By centralising your build, test, and deployment workflows in a single repository, you can enforce security standards, apply fixes to all services simultaneously, and give development teams a simple, reliable interface for shipping software. Use reusable workflows for full job templates and composite actions for step-level reuse. Version your templates with semantic tags so teams can upgrade deliberately rather than having updates forced on them.
Read next: GitHub Actions: Matrix Builds and Parallelism →
Part of the GitHub Mastery Course — engineering the reuse.
