Reusable workflows let you factor out common CI/CD logic. Instead of copying the same jobs into ten repositories, you build one central workflow that all the others can call.
What you will learn
- Create a reusable workflow with the
workflow_calltrigger - Define typed inputs, secrets and outputs
- Call a reusable workflow from another repository or locally
- Pass secrets explicitly or through
secrets: inherit - Chain and version your CI/CD templates
- Know the limitations: nesting depth, dynamic secrets
This guide is for people maintaining several pipelines. If you are starting out, read Conditions and if first.
Why reuse workflows?
The problem: you have 50 microservices with nearly identical pipelines. Every change (a new Node version, an added security scan) means editing 50 files.
The answer: a reusable workflow in a central repository:
org/ci-templates/└── .github/workflows/ ├── node-ci.yml # Standard Node.js CI ├── python-ci.yml # Standard Python CI └── docker-build.yml # Image build and pushEach microservice calls those templates:
# In every repository: 5 lines instead of 100jobs: ci: uses: org/ci-templates/.github/workflows/node-ci.yml@v1.0.0 with: node-version: '20' secrets: inheritCreating a reusable workflow
A reusable workflow is a workflow file like any other, with one difference: its trigger makes it callable by other workflows.
The basic structure
A reusable workflow uses the workflow_call trigger:
name: Reusable CI
on: workflow_call: # This trigger makes the workflow callable inputs: node-version: description: 'Node.js version' required: false type: string default: '20' secrets: npm-token: description: 'NPM token used to publish' required: false
jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }}
- run: npm ci - run: npm testDefining the inputs
Inputs make the workflow configurable:
on: workflow_call: inputs: # A required input environment: description: 'Deployment environment' required: true type: string
# An optional input with a default value node-version: description: 'Node.js version' required: false type: string default: '20'
# A boolean input skip-tests: description: 'Skip the tests' required: false type: boolean default: false
# A numeric input timeout: description: 'Timeout in minutes' required: false type: number default: 30The available input types are string, boolean and number.
Defining the secrets
Secrets are declared separately from inputs:
on: workflow_call: secrets: # A required secret deploy-key: description: 'SSH deployment key' required: true
# An optional secret slack-webhook: description: 'Slack webhook for notifications' required: falseDefining the outputs
Outputs return values to the calling workflow:
on: workflow_call: outputs: version: description: 'The version that was built' value: ${{ jobs.build.outputs.version }} image-digest: description: "The Docker image digest" value: ${{ jobs.build.outputs.digest }}
jobs: build: runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.value }} digest: ${{ steps.push.outputs.digest }} steps: - id: version run: echo "value=$(cat VERSION)" >> $GITHUB_OUTPUT
- id: push run: echo "digest=sha256:abc123" >> $GITHUB_OUTPUTCalling a reusable workflow
On the calling side, a reusable workflow is invoked in place of a job's
steps:, with the uses: keyword.
The call syntax
The calling job holds no steps:, it delegates the work entirely to the
reusable workflow.
name: CI
on: [push, pull_request]
jobs: call-workflow: uses: owner/repo/.github/workflows/reusable.yml@v1.0.0 with: node-version: '20' environment: 'production' secrets: deploy-key: ${{ secrets.DEPLOY_KEY }}Referencing the workflow
Several formats are possible:
# Same repository (relative path)uses: ./.github/workflows/reusable.yml
# ❌ External repository with a branch: a mobile reference, avoid in productionuses: org/repo/.github/workflows/reusable.yml@main
# External repository with a version tag (acceptable, see the good practices)uses: org/repo/.github/workflows/reusable.yml@v1.0.0
# ✅ External repository with a commit SHA, version in a comment (safest)uses: org/repo/.github/workflows/reusable.yml@<sha40> # v1.0.0Passing the secrets
There are three ways to pass secrets to a reusable workflow.
1. Explicit secrets: you pass by name exactly what the template needs, and nothing else.
jobs: call: uses: org/repo/.github/workflows/reusable.yml@v1.0.0 secrets: npm-token: ${{ secrets.NPM_TOKEN }} deploy-key: ${{ secrets.DEPLOY_KEY }}2. Inherit every secret: secrets: inherit passes the whole secret set of
the parent repository. Convenient, and the broadest option there is.
jobs: call: uses: org/repo/.github/workflows/reusable.yml@v1.0.0 secrets: inherit3. No secret at all: if the reusable workflow needs none, pass none.
jobs: call: uses: org/repo/.github/workflows/reusable.yml@v1.0.0Using the outputs
The calling workflow reads the values returned by the reusable workflow through
needs.<job>.outputs.
jobs: build: uses: org/repo/.github/workflows/build.yml@v1.0.0
deploy: needs: build runs-on: ubuntu-24.04 env: VERSION: ${{ needs.build.outputs.version }} DIGEST: ${{ needs.build.outputs.image-digest }} steps: - run: | echo "Deploying version $VERSION" echo "Image digest: $DIGEST"A complete example
Here is a real end-to-end case: a Node.js CI template and the workflow of a service that calls it.
The reusable workflow (the template)
This template centralises Node.js linting and testing, driven by inputs.
name: Node.js CI
on: workflow_call: inputs: node-version: type: string default: '20' working-directory: type: string default: '.' run-tests: type: boolean default: true run-lint: type: boolean default: true secrets: npm-token: required: false outputs: test-passed: description: 'Whether the tests passed' value: ${{ jobs.test.outputs.passed }}
permissions: contents: read
jobs: lint: if: ${{ inputs.run-lint }} runs-on: ubuntu-24.04 defaults: run: working-directory: ${{ inputs.working-directory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} cache: 'npm' cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci - run: npm run lint
test: if: ${{ inputs.run-tests }} runs-on: ubuntu-24.04 outputs: passed: ${{ steps.test.outputs.passed }} defaults: run: working-directory: ${{ inputs.working-directory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} cache: 'npm' cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
- id: test run: | npm test echo "passed=true" >> $GITHUB_OUTPUTThe calling workflow
On the service side, the pipeline fits in a few lines: it calls the template then chains a conditional deployment.
name: CI
on: push: branches: [main] pull_request: branches: [main]
jobs: ci: uses: org/ci-templates/.github/workflows/node-ci.yml@v1.0.0 with: node-version: '20' run-lint: true run-tests: true secrets: inherit
deploy: needs: ci if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 env: TESTS_PASSED: ${{ needs.ci.outputs.test-passed }} steps: - run: echo "Tests passed: $TESTS_PASSED" - run: ./deploy.shAdvanced patterns
Once the basics are in place, these patterns cover the cases you meet across a real portfolio of projects.
A workflow with a matrix
A reusable workflow can hold a matrix:
on: workflow_call: inputs: os: type: string default: '["ubuntu-24.04"]' node: type: string default: '["18", "20"]'
jobs: test: strategy: matrix: os: ${{ fromJSON(inputs.os) }} node: ${{ fromJSON(inputs.node) }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} - run: npm ci && npm testCalling it with a custom matrix:
jobs: test: uses: org/templates/.github/workflows/reusable-test.yml@v1.0.0 with: os: '["ubuntu-24.04", "windows-2025"]' node: '["18", "20", "22"]'Chaining several reusable workflows
Several reusable workflows chain with needs, exactly like ordinary jobs.
jobs: lint: uses: org/templates/.github/workflows/lint.yml@v1.0.0
test: needs: lint uses: org/templates/.github/workflows/test.yml@v1.0.0
build: needs: test uses: org/templates/.github/workflows/build.yml@v1.0.0 with: version: ${{ needs.test.outputs.version }}
deploy: needs: build uses: org/templates/.github/workflows/deploy.yml@v1.0.0 with: image: ${{ needs.build.outputs.image }} secrets: deploy-key: ${{ secrets.DEPLOY_KEY }}A reusable workflow with permissions
A reusable workflow declares its own permissions:, and the calling
workflow must grant at least that level.
on: workflow_call: inputs: environment: type: string required: true
# Permissions declared inside the reusable workflowpermissions: contents: read id-token: write # For OIDC
jobs: deploy: runs-on: ubuntu-24.04 environment: ${{ inputs.environment }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Deploy run: ./deploy.shGood practices
Four rules avoid the costliest traps once your templates are shared by many repositories.
1. Version your templates
A reusable workflow is third-party code: it is pinned exactly like an
action. Never call a template at @main, a mobile reference the maintainer
can point anywhere. Use a version tag or, better, a commit SHA:
# Acceptable: a version taguses: org/templates/.github/workflows/ci.yml@v1.2.0
# Safest: a commit SHA, with the version in a commentuses: org/templates/.github/workflows/ci.yml@<sha40> # v1.2.0The pinning principle and its tooling are covered in Pinning actions by SHA.
2. Document the inputs and outputs
A clear description on every input saves consuming teams from reading the
template's code.
on: workflow_call: inputs: environment: description: | Target environment for the deployment. Accepted values: staging, production. Default: staging. type: string default: 'staging'3. Pick sensible defaults
Well-chosen default: values make the template usable without configuration
in the most common case.
inputs: node-version: type: string default: '20' # The current LTS timeout: type: number default: 30 # Reasonable for most builds4. Restrict the permissions
Declare the minimum at workflow level, and widen only on the job that genuinely needs it.
# Inside the reusable workflowpermissions: contents: read # The minimum, by default
jobs: publish: runs-on: ubuntu-24.04 permissions: contents: read packages: write # Only where required steps: - run: ./publish.shLimitations
Reusable workflows carry a few structural constraints, and it is better to know them before building a whole architecture on top.
- Nesting depth: reusable workflows can nest, but over four levels maximum
- Call count: at most 20 unique reusable workflows in the call tree
- Secrets: secrets cannot be passed dynamically through expressions
- Permissions: the calling workflow must hold at least the permissions the reusable workflow requires
Check your understanding
Make sure the essentials of this guide are yours. The questions only cover what was explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Skills profile
What to do next
Resources to go further
Hints before you try again?
A new full quiz with random questions
Work again only on the questions you missed
Key points
- A reusable workflow is declared with the
workflow_calltrigger and called throughuses:in place of a job'ssteps:. - Inputs are typed (
string,boolean,number); secrets are declared separately. - Outputs flow back to the calling workflow, which reads them through
needs.<job>.outputs. secrets: inheritpasses every secret of the parent repository: convenient, but to be avoided when the template needs one or two.- Version your templates by semver tag or by SHA, never
@mainin production. - Limits to know: nesting over four levels maximum, 20 unique reusable workflows per call tree, no secret passed dynamically.
For the complete reference, see the official documentation on reusing workflows.
Next steps
- Composite actions: the lighter alternative when you only need to group a few steps.
- Securing GitHub Actions: why
secrets: inheritwidens the attack surface of a central workflow. - Auditing with zizmor: the scanner that spots
secrets-inheritand unpinned references in your shared workflows.