Every modern software team needs a reliable way to build, test, and deploy code automatically. Manual deployments are slow, error-prone, and don’t scale. GitHub Actions solves this by embedding CI/CD directly into your repository — no external services, no complex integrations, no extra billing accounts.
In this guide, you’ll build production-grade CI/CD pipelines from scratch. We’ll cover everything from basic workflow syntax to advanced patterns like matrix builds, reusable workflows, environment protection rules, and real-world deployment examples for Cloudflare Pages and Docker-based servers.
Prerequisites
Before you begin, make sure you have:
- A GitHub account with at least one repository
- Git installed locally and configured
- A text editor or IDE (VS Code recommended)
- Basic familiarity with YAML syntax
- An application you want to build and deploy (we’ll use Node.js examples, but the concepts apply to any language)
Understanding GitHub Actions Core Concepts
GitHub Actions is built around five key concepts. Understanding them is essential before writing your first workflow.
Workflows
A workflow is an automated process defined in a YAML file inside the .github/workflows/ directory of your repository. A repository can have multiple workflows, each handling different automation tasks — one for CI, one for deployment, one for issue labeling.
Events (Triggers)
An event is something that triggers a workflow to run. Common events include:
push— code is pushed to a branchpull_request— a PR is opened, updated, or mergedschedule— a cron-based scheduleworkflow_dispatch— manual trigger from the GitHub UIrelease— a new release is published
Jobs
A job is a set of steps that run on the same runner. Jobs run in parallel by default, but you can configure dependencies between them using the needs keyword to create sequential execution.
Steps
A step is an individual task within a job. Each step either runs a shell command (run) or uses a pre-built action (uses). Steps execute sequentially within a job and share the same filesystem.
Runners
A runner is the server that executes your workflow. GitHub provides hosted runners with Ubuntu Linux, Windows, and macOS. You can also configure self-hosted runners on your own infrastructure for specialized requirements.
Workflow Syntax Deep Dive
File Location
All workflow files must live in .github/workflows/ and use the .yml or .yaml extension:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Triggers
Push and Pull Request
The most common triggers fire on code changes:
on:
push:
branches: [main, develop]
paths:
- "src/**"
- "package.json"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
The paths filter restricts the workflow to only run when specific files change. The types filter controls which PR activities trigger the workflow.
Schedule (Cron)
Run workflows on a schedule using POSIX cron syntax:
on:
schedule:
# Run at 06:00 UTC every Monday
- cron: "0 6 * * 1"
Scheduled workflows run on the default branch. The shortest interval is every 5 minutes, but GitHub may delay execution during periods of high load.
Manual Dispatch
Allow manual triggering from the GitHub UI with optional inputs:
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
dry_run:
description: "Perform a dry run"
required: false
type: boolean
default: false
Access the input values in your steps with ${{ github.event.inputs.environment }}.
Tag-Based Triggers
Trigger workflows when tags are pushed, useful for release pipelines:
on:
push:
tags:
- "v*.*.*"
Jobs Configuration
Jobs define what runs and where. Each job gets a fresh runner instance:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- run: npm run build
The needs keyword creates a dependency: build only runs if test succeeds.
Steps and Actions
Steps are the atomic units of a job. They can run commands or use actions:
steps:
# Use a pre-built action
- name: Checkout code
uses: actions/checkout@v4
# Run a shell command
- name: Install dependencies
run: npm ci
# Multi-line command
- name: Run tests and lint
run: |
npm run lint
npm test
# Use a specific shell
- name: Run PowerShell script
shell: pwsh
run: Write-Host "Hello from PowerShell"
Expressions and Contexts
GitHub Actions provides expressions for dynamic values and conditional logic:
steps:
- name: Print event info
run: |
echo "Event: ${{ github.event_name }}"
echo "Branch: ${{ github.ref_name }}"
echo "SHA: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
echo "Repository: ${{ github.repository }}"
- name: Only on main branch
if: github.ref == 'refs/heads/main'
run: echo "This is the main branch"
- name: Only on pull requests
if: github.event_name == 'pull_request'
run: echo "PR #${{ github.event.pull_request.number }}"
Your First Workflow: Node.js CI
Let’s build a complete CI workflow for a Node.js project. Create .github/workflows/ci.yml:
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 3
The concurrency block cancels in-progress runs when a new push arrives on the same branch, saving runner minutes.
Using Actions from the Marketplace
The GitHub Marketplace contains thousands of pre-built actions. Here are essential ones you’ll use regularly:
| Action | Purpose |
|---|---|
actions/checkout@v4 | Clone your repository |
actions/setup-node@v4 | Install Node.js |
actions/setup-python@v5 | Install Python |
actions/cache@v4 | Cache dependencies |
actions/upload-artifact@v4 | Store build artifacts |
actions/download-artifact@v4 | Retrieve artifacts in another job |
docker/build-push-action@v5 | Build and push Docker images |
cloudflare/wrangler-action@v3 | Deploy to Cloudflare |
To use a marketplace action, reference it with uses and pass configuration through with:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
Caching Dependencies
Caching dramatically reduces workflow run time by reusing downloaded dependencies across runs. Use actions/cache or the built-in cache support in setup actions.
Using Setup Action Cache
The simplest approach — setup actions handle caching automatically:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
Explicit Cache Control
For fine-grained control, use actions/cache directly:
- name: Cache node_modules
id: cache-deps
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
- name: Install dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm ci
The key uses a hash of your lockfile to create a unique cache entry. When the lockfile changes, a new cache is created. The restore-keys provide fallback cache keys for partial matches.
Caching for Other Languages
# Python with pip
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
# Go modules
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('go.sum') }}
# Rust with Cargo
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
Matrix Builds
Matrix builds let you test across multiple versions, operating systems, or configurations in parallel. This is critical for libraries and tools that must work across different environments.
Basic Matrix
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
This creates 9 parallel jobs (3 OS × 3 Node versions). Setting fail-fast: false ensures all combinations run even if one fails.
Matrix with Include and Exclude
Customize specific matrix combinations:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
exclude:
# Skip Node 18 on Windows
- os: windows-latest
node-version: 18
include:
# Add an extra combination with experimental flag
- os: ubuntu-latest
node-version: 22
experimental: true
Dynamic Matrix
Generate the matrix dynamically from a previous job:
jobs:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo 'matrix={"version":["18","20","22"]}' >> $GITHUB_OUTPUT
test:
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
steps:
- run: echo "Testing version ${{ matrix.version }}"
Secrets and Environment Variables
Repository Secrets
Store sensitive values as encrypted secrets in Settings → Secrets and variables → Actions:
# These are added through the GitHub UI or CLI
gh secret set DEPLOY_TOKEN --body "your-token-here"
gh secret set AWS_ACCESS_KEY_ID --body "AKIA..."
Reference secrets in workflows:
steps:
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh
- name: Push Docker image
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Important: Secrets are not available in workflows triggered by pull requests from forks. This is a security measure to prevent unauthorized access.
Environment Variables
Define variables at the workflow, job, or step level:
env:
NODE_ENV: production
CI: true
jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_DIR: dist
steps:
- name: Build
env:
API_URL: https://api.example.com
run: |
echo "Node env: $NODE_ENV"
echo "Build dir: $BUILD_DIR"
echo "API: $API_URL"
Setting Dynamic Variables
Pass values between steps using $GITHUB_OUTPUT and $GITHUB_ENV:
steps:
- name: Set version
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set global env
run: echo "RELEASE_TAG=v${{ steps.version.outputs.version }}" >> $GITHUB_ENV
- name: Use values
run: |
echo "Version: ${{ steps.version.outputs.version }}"
echo "Tag: $RELEASE_TAG"
Artifacts
Artifacts let you persist data from a workflow run and share files between jobs.
Uploading Artifacts
- name: Build application
run: npm run build
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: webapp-build
path: |
dist/
!dist/**/*.map
retention-days: 5
if-no-files-found: error
Downloading Artifacts in Another Job
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build
uses: actions/download-artifact@v4
with:
name: webapp-build
path: dist/
- name: Deploy
run: ./deploy.sh dist/
Downloading All Artifacts
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-artifacts/
merge-multiple: true
Environments and Protection Rules
GitHub Environments let you define deployment targets with protection rules, secrets, and approval gates.
Creating Environments
Navigate to Settings → Environments and create environments like staging and production. Configure:
- Required reviewers — specific team members must approve before the deployment proceeds
- Wait timer — add a delay before deployment starts
- Branch restrictions — only allow deployments from specific branches
Using Environments in Workflows
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh staging
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh production
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
Environment-specific secrets override repository-level secrets with the same name, allowing different credentials per environment.
Deployment Examples
Deploy to Cloudflare Pages
This workflow builds a static site and deploys it to Cloudflare Pages:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment:
name: production
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name=my-project
Add your Cloudflare API token and account ID as repository secrets.
Deploy Docker to a Server via SSH
This workflow builds a Docker image, pushes it to a registry, and deploys to a remote server over SSH:
name: Deploy Docker via SSH
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
name: Build & Push Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Server
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ needs.build-and-push.outputs.image-tag }}
docker compose -f /opt/app/docker-compose.yml up -d --no-deps app
docker image prune -f
This pattern separates the build (which runs on GitHub’s infrastructure) from the deployment (which connects to your server), keeping your CI pipeline fast and your server doing minimal work.
Reusable Workflows
Reusable workflows eliminate duplication across repositories. Define a workflow once and call it from other workflows.
Creating a Reusable Workflow
Create .github/workflows/reusable-deploy.yml:
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
node-version:
required: false
type: number
default: 20
secrets:
deploy-token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.deploy-token }}
run: ./scripts/deploy.sh ${{ inputs.environment }}
Calling a Reusable Workflow
name: Production Deploy
on:
push:
branches: [main]
jobs:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets:
deploy-token: ${{ secrets.STAGING_TOKEN }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
secrets:
deploy-token: ${{ secrets.PRODUCTION_TOKEN }}
You can also reference reusable workflows from other repositories:
jobs:
deploy:
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
with:
environment: production
secrets: inherit
Self-Hosted Runners
GitHub-hosted runners work for most workloads, but self-hosted runners are necessary when you need:
- Custom hardware — GPU access, ARM architecture, high-memory machines
- Network access — connecting to internal services behind a firewall
- Persistent tools — specialized software that’s expensive to install on every run
- Cost control — avoiding minute charges for heavy workloads
Setting Up a Self-Hosted Runner
Navigate to Settings → Actions → Runners → New self-hosted runner and follow the installation instructions:
# Download the runner package
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
# Configure the runner
./config.sh --url https://github.com/YOUR-ORG/YOUR-REPO \
--token YOUR_REGISTRATION_TOKEN \
--labels gpu,high-memory
# Install and start as a service
sudo ./svc.sh install
sudo ./svc.sh start
Using Self-Hosted Runners
jobs:
gpu-training:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- run: python train_model.py
Use labels to target specific runner configurations.
Security Best Practices
Pin Actions by SHA
Instead of using mutable tags, pin actions to specific commit SHAs. This prevents supply chain attacks where a compromised action tag is pointed to malicious code:
# Avoid — tags can be moved to a compromised commit
- uses: actions/checkout@v4
# Prefer — pinned to exact commit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Use Dependabot to keep pinned SHAs up to date:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Minimal Permissions
Follow the principle of least privilege. Set restrictive default permissions and only grant what each job needs:
# Restrict default permissions for all jobs
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
deployments: write
steps:
- uses: actions/checkout@v4
Protect Secrets
- Never print secrets in logs. GitHub masks known secrets automatically, but derived values won’t be masked.
- Use environment-level secrets for sensitive deployment credentials.
- Rotate secrets regularly.
- Avoid passing secrets to actions you don’t trust.
# BAD — secret could leak in error output
- run: curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com
# BETTER — use an environment variable
- run: curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
Restrict Fork PR Workflows
Pull requests from forks can run modified workflow files. Restrict this to prevent malicious code execution:
on:
pull_request_target:
types: [labeled]
jobs:
test:
if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
Monitoring and Debugging
Local Testing with act
act runs your GitHub Actions workflows locally using Docker. Install it and test before pushing:
# Install act
brew install act # macOS
curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash # Linux
# Run all workflows
act
# Run a specific workflow
act -W .github/workflows/ci.yml
# Run a specific job
act -j test
# Simulate a push event to a specific branch
act push --eventpath event.json
# List all available jobs without running
act -l
# Use a specific runner image
act -P ubuntu-latest=catthehacker/ubuntu:act-latest
Create an .actrc file in your repository root for default settings:
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--secret-file .env.secrets
Debug Logging
Enable debug logging by setting the ACTIONS_STEP_DEBUG secret to true in your repository settings, or re-run a failed workflow with debug logging from the GitHub UI.
Add your own debug output in steps:
steps:
- name: Debug context
run: |
echo "::debug::Event payload: ${{ toJson(github.event) }}"
echo "::notice::Deployment starting for ${{ github.ref_name }}"
echo "::warning::Cache miss, full install required"
- name: Group log output
run: |
echo "::group::Install Output"
npm ci
echo "::endgroup::"
Workflow Run Monitoring
Use the GitHub CLI to monitor workflow runs from your terminal:
# List recent workflow runs
gh run list
# Watch a running workflow in real time
gh run watch
# View a specific run
gh run view 12345678
# Download run logs
gh run view 12345678 --log
# Re-run a failed workflow
gh run rerun 12345678 --failed
Common Patterns
Conditional Steps
Run steps based on conditions like branch, event type, or previous step results:
steps:
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: ./deploy.sh staging
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh production
- name: Cleanup on failure
if: failure()
run: ./rollback.sh
- name: Notify always
if: always()
run: echo "Workflow completed with status ${{ job.status }}"
- name: Only when files changed
if: contains(github.event.head_commit.modified, 'docs/')
run: npm run build-docs
Manual Approval Gate
Require manual approval before proceeding with a deployment:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
approve:
needs: test
runs-on: ubuntu-latest
environment: production # Requires approval if protection rules are configured
steps:
- run: echo "Approved for deployment"
deploy:
needs: approve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh production
Scheduled Maintenance Tasks
Automate recurring tasks like dependency updates, cleanup, or reporting:
name: Weekly Maintenance
on:
schedule:
- cron: "0 3 * * 0" # Sunday at 03:00 UTC
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Clean old artifacts
uses: actions/github-script@v7
with:
script: |
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const artifacts = await github.rest.actions.listArtifactsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
});
for (const artifact of artifacts.data.artifacts) {
if (new Date(artifact.created_at) < thirtyDaysAgo) {
await github.rest.actions.deleteArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
});
}
}
security-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=high
Path-Based Monorepo Workflows
Run specific workflows only when relevant code changes in a monorepo:
name: Backend CI
on:
push:
paths:
- "packages/backend/**"
- "packages/shared/**"
- "package-lock.json"
jobs:
test-backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm test
Status Badges
Add workflow status badges to your README:


Composite Actions
Create your own reusable action that combines multiple steps:
# .github/actions/setup-project/action.yml
name: "Setup Project"
description: "Install Node.js and project dependencies"
inputs:
node-version:
description: "Node.js version"
required: false
default: "20"
runs:
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- name: Install dependencies
shell: bash
run: npm ci
- name: Verify installation
shell: bash
run: node --version && npm --version
Use it in your workflows:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: 20
- run: npm test
Complete Production Pipeline
Here’s a full pipeline that ties together everything covered in this guide — linting, testing with matrix builds, building, and deploying with environment protection:
name: Production Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
name: Lint & Format
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 run lint
- run: npm run format:check
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: matrix.node-version == 20
with:
name: coverage
path: coverage/
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/
deploy-staging:
name: Deploy Staging
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
permissions:
contents: read
deployments: write
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- name: Deploy to Staging
run: ./scripts/deploy.sh staging
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
deploy-production:
name: Deploy Production
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
permissions:
contents: read
deployments: write
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- name: Deploy to Production
run: ./scripts/deploy.sh production
env:
DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}
Summary
GitHub Actions transforms your repository into a complete CI/CD platform. The key takeaways:
- Start simple — a basic test workflow on push is better than no automation at all
- Cache aggressively — use the built-in cache support in setup actions to save minutes
- Use matrix builds for libraries that must support multiple environments
- Protect production with environment rules, required reviewers, and branch restrictions
- Pin actions by SHA and enable Dependabot for supply chain security
- Reuse workflows across repositories to maintain consistency and reduce duplication
- Test locally with act before pushing to save iteration time
Every push, every pull request, every release — automated, tested, and deployed without manual intervention. That’s the power of a well-designed CI/CD pipeline.