Complete 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.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Complete Guide to CI/CD with GitHub Actions
Introduction
GitHub Actions is GitHub’s built-in CI/CD platform. It lets you automate build, test, and deployment directly from your repository. This guide covers workflow syntax, runners, matrix builds, caching, secrets, environments, deployment strategies, and reusable workflows.
Workflow Basics
Simple CI workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- 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: Lint
run: npm run lint
- name: Test
run: npm test -- --coverage
- name: Build
run: npm run build
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
Triggers
on:
push:
branches: [main]
paths:
- "src/**"
- "tests/**"
- ".github/workflows/**"
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch: # Manual trigger
inputs:
environment:
description: "Target environment"
required: true
default: staging
type: choice
options:
- staging
- production
release:
types: [published]
Runners
GitHub-hosted runners
jobs:
test:
runs-on: ubuntu-latest # 4 CPU, 16GB RAM, 14GB SSD
build:
runs-on: ubuntu-22.04 # Pin to specific version
macos:
runs-on: macos-latest # macOS for iOS builds
windows:
runs-on: windows-latest # Windows for .NET builds
Larger runners (GitHub Enterprise)
jobs:
build:
runs-on: ubuntu-latest-large # 16 CPU, 64GB RAM
Self-hosted runners
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./deploy.sh
Matrix Builds
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, macos-latest, windows-latest]
exclude:
- os: macos-latest
node-version: 18
include:
- node-version: 20
os: ubuntu-latest
experimental: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Fail-fast vs continue-on-error
strategy:
fail-fast: false # Don't cancel other matrix jobs on failure
max-parallel: 4 # Limit concurrent jobs
Caching
npm cache (built-in)
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Automatically caches ~/.npm
Custom cache
- name: Cache build output
uses: actions/cache@v4
with:
path: |
dist/
.cache/
key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-
Docker layer caching
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and cache
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Secrets and Variables
Repository secrets
steps:
- name: Deploy to production
env:
API_KEY: ${{ secrets.API_KEY }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: ./deploy.sh
Environment secrets
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires approval
steps:
- name: Deploy
env:
PROD_TOKEN: ${{ secrets.PROD_TOKEN }}
run: ./deploy.sh
Variables (non-secret)
steps:
- name: Use variable
env:
API_URL: ${{ vars.API_URL }}
run: echo "Deploying to $API_URL"
Masked outputs
- name: Generate token
id: token
run: echo "value=$(openssl rand -hex 32)" >> $GITHUB_OUTPUT
- name: Use token
env:
TOKEN: ${{ steps.token.outputs.value }}
run: |
echo "::add-mask::$TOKEN"
curl -H "Authorization: Bearer $TOKEN" https://api.example.com
Environments and Deployments
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./deploy.sh production
Required reviewers
Configure in GitHub: Settings → Environments → production → Required reviewers. The workflow pauses until approval.
Job Dependencies
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
needs: lint # Wait for lint to pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
build:
needs: [lint, test] # Wait for both
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build
deploy:
needs: build
if: github.ref == 'refs/heads/main' # Only on main
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
Reusable Workflows
Define a reusable workflow
# .github/workflows/test-suite.yml
name: Test Suite
on:
workflow_call:
inputs:
node-version:
required: false
type: number
default: 20
secrets:
API_KEY:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm test
env:
API_KEY: ${{ secrets.API_KEY }}
Call a reusable workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
jobs:
test:
uses: ./.github/workflows/test-suite.yml
with:
node-version: 22
secrets:
API_KEY: ${{ secrets.API_KEY }}
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
Conditional Execution
steps:
- name: Only on main
if: github.ref == 'refs/heads/main'
run: ./deploy.sh
- name: Only on PRs
if: github.event_name == 'pull_request'
run: npm run test:e2e
- name: Skip on docs change
if: !contains(github.event.head_commit.message, '[skip ci]')
run: npm test
- name: On failure
if: failure()
run: ./notify-slack.sh
- name: Always run (cleanup)
if: always()
run: ./cleanup.sh
Docker Build and Push
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- 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
Best Practices
- Pin action versions — use
@v4not@mainto avoid supply chain attacks - Use
npm cinotnpm install— deterministic installs from lockfile - Cache dependencies — saves 30-60s per job
- Use environments for deployments — required approvals for production
- Set
concurrencygroups — cancel outdated runs on the same branch - Use reusable workflows — DRY your pipeline across repositories
- Minimize secret scope — use environment secrets over repository secrets
- Use
permissionskey — restrict GITHUB_TOKEN to minimum needed - Run tests in parallel — split test suites across matrix jobs
- Use
if: always()for cleanup — ensure resources are cleaned up - Tag Docker images with SHA — trace deployments back to commits
- Use path filters — skip workflows when only docs change
Common Mistakes
- Not pinning action versions —
@maincan introduce breaking changes or malicious code - Using
npm installinstead ofnpm ci— non-deterministic builds - Not setting
concurrency— multiple runs waste minutes and race on deployments - Overusing
secrets.GITHUB_TOKEN— it has broad permissions by default - Not using
environmentfor production deploys — no approval gate - Hardcoding secrets in workflow files — they are visible in the UI
- Not caching dependencies — slow builds waste Actions minutes
- Running everything on every push — use path filters to skip irrelevant workflows
- Not using
if: failure()for notifications — failures go unnoticed - Not testing workflows locally — use
actto run workflows before pushing
Frequently Asked Questions
How do I run GitHub Actions locally?
Use act — it runs workflows in Docker containers locally:
# Install act
brew install act
# Run a specific workflow
act -W .github/workflows/ci.yml
# Run with secrets
act -W .github/workflows/ci.yml --secret-file .secrets
How do I share workflows across repositories?
Create a reusable workflow in a central repository, then call it with uses: org/repo/.github/workflows/ci.yml@main. This lets you maintain one workflow definition used by all repositories in your organization.
How do I debug a failing workflow?
- Enable step debug logging: set
ACTIONS_STEP_DEBUG=trueas a repository secret - Enable runner diagnostic logging: set
ACTIONS_RUNNER_DEBUG=true - Use
tmatesession for interactive debugging:
- name: Debug
uses: mxschmitt/action-tmate@v3
if: failure()
timeout-minutes: 30