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 (mobile, avoid in production)uses: org/repo/.github/workflows/reusable.yml@main
# External repository with a taguses: org/repo/.github/workflows/reusable.yml@v1.0.0
# External repository with a SHA (recommended for security)uses: org/repo/.github/workflows/reusable.yml@a1b2c3d4e5f6Passing 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 SHAuses: org/templates/.github/workflows/ci.yml@a1b2c3d4e5f6The 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
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.