GitHub Actions Workflow Examples: CI/CD, Matrix & Secrets
How to build and deploy with GitHub Actions using workflows, matrices, caching, secrets, environments, and reusable workflows.
Overview
Teams that still verify code by hand — running tests locally, building artifacts on a developer machine, deploying over SSH — ship slowly and break things. GitHub Actions turns every Git event into an automated, reproducible workflow running in isolated environments, so the pipeline lives next to the code it tests.
Because your pipeline is defined as YAML inside the repository, it gets version control, peer review, and auditability for free. A green checkmark on a pull request means the code compiles, tests pass, and style rules hold before anyone merges.
This recipe collects the workflow patterns you actually copy into .github/workflows/: a Node.js CI pipeline, build matrices, scheduled jobs, secrets and environments, dependency caching, and reusable workflows. For the full conceptual walkthrough, see the CI/CD with GitHub Actions guide.
When to Use
Use this recipe when:
- Catching regressions on every pull request, before they reach production — see Unit Testing for test automation.
- Publishing Docker images off release tags so deployments stay immutable — see Docker Basics for image building.
- Deploying to staging or production with gated approvals. See Environment Variables for managing deployment config.
- Linting and formatting checks before merge. See Pre-Commit Hooks for local code quality.
- Kicking off recurring work — dependency audits, nightly backups — on a UTC cron schedule.
- Scanning dependencies for vulnerabilities on every commit.
Skip GitHub Actions when you need GPU runners or exotic build agents, when your code lives outside GitHub (use GitLab CI or Jenkins instead), or when a single hosted-minute cost per build exceeds what a self-hosted runner would cost at your commit volume.
Solution
Basic Workflow (Node.js CI)
This workflow runs on every push and pull request targeting main. It pulls the code, prepares Node.js, installs the dependencies, then walks through the whole validation pipeline.
# .github/workflows/ci.yml
name: CI
# Trigger: run on push and PR to main branch
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Check out the repository code
- uses: actions/checkout@v4
# Set up Node.js with built-in npm caching
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies using lock file for reproducibility
- run: npm ci
# Run linting to catch style issues early
- run: npm run lint
# Execute the test suite
- run: npm run test
# Build the production artifact
- run: npm run build
Build Matrix
A matrix strategy runs the same job across combinations of OS and runtime versions. It catches platform-specific bugs and confirms compatibility without duplicating workflow code. See GitHub Actions Matrix Strategy for include/exclude combos and dynamic matrices.
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Scheduled Workflows (Cron)
The schedule trigger runs workflows on a UTC cron schedule — useful for dependency audits, nightly builds, or cleanup jobs. Add workflow_dispatch alongside it so you can still run the job manually.
# .github/workflows/nightly-audit.yml
name: Nightly Audit
on:
schedule:
- cron: '0 3 * * *' # Every day at 03:00 UTC
workflow_dispatch: # Allow manual runs from the Actions tab
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=high
Deploy with Secrets
This job only runs after the test job succeeds and only on the main branch. Secrets are injected at runtime from the repository settings and masked in logs.
jobs:
deploy:
runs-on: ubuntu-latest
needs: test # wait for test job to pass
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying with token"
curl -X POST https://api.example.com/release \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"
secrets and vars behave differently — pick the right one for each value:
| Context | Encrypted at rest | Masked in logs | Use for |
|---|---|---|---|
secrets.* | Yes | Yes | API tokens, deploy credentials, signing keys |
vars.* | No | No | Feature flags, environment URLs, non-sensitive config |
Caching Dependencies
Caching stores downloaded dependencies between workflow runs. The cache key includes the OS and a hash of the lock file, so it invalidates automatically when dependencies change.
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
Reusable Workflow
When several repositories need the same pipeline logic, extract it into a reusable workflow. Callers pass inputs and receive outputs, keeping the shared logic in one place. See Reusable Workflows in GitHub Actions for secrets: inherit, output passing, and versioning strategies.
# .github/workflows/reusable-lint.yml
name: Reusable Lint
on:
workflow_call:
inputs:
node-version:
required: true
type: string
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm run lint
Call it from another repository:
jobs:
call-lint:
uses: org/shared-workflows/.github/workflows/reusable-lint.yml@main
with:
node-version: '20'
Explanation
How the Execution Model Works
A workflow is a YAML file triggered by an event (push, pull_request, schedule, workflow_dispatch, or an external repository_dispatch call). GitHub queues each job in the workflow and assigns it to a runner — a hosted VM (ubuntu-latest, windows-latest, macos-latest) or a machine you operate (self-hosted). Inside a job, steps run sequentially in the same VM, sharing the filesystem and environment.
Jobs run in parallel by default. needs turns the graph into a sequence: a job waits for its dependencies to succeed before starting. Add if conditions on top to express branches — for example, deploy only when the ref is main:
Contexts and Expressions
Everything inside ${{ }} is an expression evaluated against a context. The ones you use most: github (event payload, ref, SHA), secrets (encrypted values), vars (plain configuration), matrix (current matrix combination), inputs (caller-supplied values in reusable and dispatch workflows), and env / runner for environment and machine data. Expressions work in run commands, if conditions, and with inputs.
Permissions and GITHUB_TOKEN
Every workflow runs with a GITHUB_TOKEN whose default permissions come from repository settings. Leave the workflow at permissions: read-all and grant contents: write or packages: write only to the jobs that need them — a compromised third-party action can only do what the token allows. For cloud deployments, prefer OIDC federation over long-lived secrets: the runner exchanges an ephemeral JWT for a cloud role, so there’s no AWS_ACCESS_KEY_ID sitting in Secrets.
Concurrency and Billing
A concurrency group cancels the older run when a newer push lands on the same ref, which stops stale deployments from racing newer ones:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
Hosted runner minutes are billed per OS: Linux minutes count 1x, Windows 2x, and macOS 10x. A 3-OS × 3-runtime matrix produces 9 jobs per commit, so matrix size has a direct price tag on private repositories — see GitHub Actions billing.
Variants
Reusable Workflow with Matrix Strategy
# .github/workflows/test-matrix.yml
name: Test Matrix
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: "20"
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel other jobs on failure
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # Skip old Node on macOS
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.node == 20
Environment Protection Rules
# .github/workflows/deploy.yml
name: Deploy
on:
push:
tags: ['v*']
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
# Required reviewers must approve before deployment
# Configured in repo Settings > Environments
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying ${{ github.ref_name }} to production"
./deploy.sh --env prod --version ${{ github.ref_name }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Composite Action
# .github/actions/setup-project/action.yml
name: Setup Project
description: Install dependencies and cache them
inputs:
node-version:
required: false
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
shell: bash
run: npm ci
- name: Build
shell: bash
run: npm run build
# Usage in a workflow
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '22'
- run: npm test
Conditional Jobs and Steps
jobs:
lint:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- run: npm run lint
deploy:
needs: [lint, test]
if: github.ref == 'refs/heads/main' && success()
runs-on: ubuntu-latest
steps:
- run: echo "Deploying only on main after lint+test pass"
Job Summary for PR Comments
$GITHUB_STEP_SUMMARY renders Markdown in the Actions run view — a quick way to surface results without a bot comment. The durations here are illustrative; in a real run you would write measured values.
- name: Generate summary
if: always()
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "| Suite | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Unit | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY
echo "| E2E | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY
What Works
- Pin action versions using full commit SHAs for supply-chain security. Tags like
v4are convenient, but a compromised maintainer could repoint the tag to malicious code; a SHA runs the exact code every time. - Run
npm ci, notnpm install—ciinstalls exactly whatpackage-lock.jsonpins, so the build can’t drift between a developer’s machine and the runner. - Split jobs: test, build, and deploy as separate jobs with explicit dependencies (
needs). Failures stay isolated, jobs run in parallel, and logs are easier to read. - Use concurrency groups to cancel outdated runs when new commits are pushed. This saves CI minutes and prevents stale deployments from overwriting newer ones.
- Store secrets in GitHub Secrets, never hardcode tokens in workflow files. Secrets are encrypted at rest and masked in logs. Even private repositories can leak through fork PRs.
- Extract shared pipeline logic into reusable workflows — callers in other repos pass inputs, and a fix in the central repo reaches every consumer at once.
- Fail fast with strict permissions: set
permissions: read-allat the workflow level and grant write only where needed — a compromised action can’t do more than the token allows. - Watch the Actions bill — set a spending limit and review usage monthly. A matrix across 3 OSes and 3 Node versions produces 9 jobs per commit, and macOS minutes count 10x.
- Add a restore-keys fallback to
actions/cacheso a stale cache still speeds upnpm ciwhen the lock file changes:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- Use self-hosted runners for heavy builds when the hosted queue or CPU is the bottleneck:
runs-on: self-hosted # Your own hardware
- Shard test suites across runners with the matrix to parallelize long suites:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
- Enable Docker layer caching to speed up image builds:
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
Common Mistakes
- Using
actions/checkoutwithout specifying a ref, causing detached HEAD issues or checking out the wrong commit when triggered by a tag. Useref: ${{ github.ref }}explicitly when needed. - Deploying on every push instead of filtering by branch or tag — unfinished feature branches end up in production and your release process is bypassed entirely.
- Not caching dependencies, causing builds that download the internet on every run. A well-tuned cache can cut install time from minutes to seconds.
- Reaching for
ubuntu-latestwhen you need a specific OS for native compilation. Thelatestlabel migrates over time — pinubuntu-22.04and the build stays reproducible. - Notification steps that skip
if: failure()— Slack or email alerts fire only when the pipeline is green, which is exactly when you don’t need them. - Environment values written into the workflow file instead of repository variables — every URL or version bump becomes a YAML edit and a commit.
permissions: write-alland other broad grants — a compromised third-party action can rewrite your code, create releases, or touch secrets. Least privilege isn’t optional here.
Further Reading
- Workflow syntax for GitHub Actions — the full
on:/jobs:/steps:reference. - Using secrets in GitHub Actions — repository vs environment secrets and masking.
- Environments and protection rules — required reviewers, branches, and environment URLs.
- Reusing workflows —
workflow_call, inputs, outputs, and secrets inheritance. - Companion runnable workflows — the YAML files from this recipe ready to drop into a repository.
Frequently Asked Questions
How do I run a workflow manually?
Add workflow_dispatch: to the on: block. It then shows up under the Actions tab as a manual run, with optional inputs the workflow can read.
Can I reuse workflows across repositories?
Yes. Create a workflow with on: workflow_call in a central repository, then reference it from others using the uses: org/shared-workflows/.github/workflows/reusable.yml@main form. The shared logic stays updated in one place.
How do I debug a failing workflow?
Use the tmate action to spawn an interactive SSH session inside the runner, or add set -x in shell steps for verbose output. You can also enable step debugging by setting the ACTIONS_STEP_DEBUG secret to true.
How do I prevent workflow runs from forks?
Use if: github.event.pull_request.head.repo.full_name == github.repository to skip workflows triggered by external forks. For workflows that touch secrets or production, go further: turn off fork PR triggers in the repo settings and gate runs behind manual approval.
What is the difference between workflow_dispatch and repository_dispatch?
workflow_dispatch is the manual trigger — the Actions tab or the API. repository_dispatch only exists for external callers: a third-party service hits the API endpoint when it needs a build that no Git event can start.
How do I share secrets across environments?
Use repository-level secrets for non-sensitive values and environment-level secrets for production credentials. Reference them with ${{ secrets.SECRET_NAME }}:
steps:
- run: deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }} # Environment secret
LOG_LEVEL: ${{ vars.LOG_LEVEL }} # Repository variable
How do I run jobs sequentially vs in parallel?
By default, jobs run in parallel. Use needs to create dependencies:
jobs:
build: # Runs first
runs-on: ubuntu-latest
test: # Runs after build
needs: build
runs-on: ubuntu-latest
deploy: # Runs after test
needs: test
runs-on: ubuntu-latest
How do I pass artifacts between jobs?
Use actions/upload-artifact and actions/download-artifact:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh dist/
Related Resources
Across Multiple OS and Language Versions with GitHub
How to use GitHub Actions matrix strategy to test across multiple operating systems, language versions, and configurations with include, exclude, and dynamic matrices.
RecipeShare Workflow Logic with GitHub Actions Reusable Workflows
How to create and consume reusable workflows in GitHub Actions, covering inputs, secrets, conditional jobs, matrix strategy, and organization-wide sharing.
GuideComplete Guide to CI/CD with GitHub Actions
Build CI/CD pipelines from scratch with GitHub Actions. Covers workflows, runners, matrix builds, caching, secrets, environments, deployment strategies, and reusable workflows.
RecipeCI/CD Pipeline Setup
Set up automated CI/CD pipelines for testing, building, and deploying applications with GitHub Actions and what works.
RecipeEnvironment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
RecipeDocker Basics
How to containerize an application, write a Dockerfile, and run containers with Docker Compose.