GitHub Actions has become the default CI/CD platform for projects hosted on GitHub. It is integrated directly into the repository, runs workflows for free on public repositories, and has a massive marketplace of community-maintained actions. Whether you are setting up your first test pipeline or orchestrating complex multi-environment deployments, everything starts with a YAML file.
This tutorial takes you from zero to production-ready CI/CD. Every example is a complete, working workflow you can copy into your repository.
Validate your workflow YAML. The YAML Editor catches syntax errors, indentation problems, and structural issues before you commit and wait for GitHub to report an error.
Workflow Basics: Syntax and Structure
Workflow files live in .github/workflows/ at the root of your repository. Each .yml file defines one workflow. A workflow contains triggers, jobs, and steps.
# .github/workflows/ci.yml
name: CI # Workflow name (shows in GitHub UI)
on: [push, pull_request] # Triggers
jobs: # One or more jobs
test: # Job ID
runs-on: ubuntu-latest # Runner OS
steps: # Sequence of steps
- uses: actions/checkout@v4 # Step using an action
- run: echo "Hello CI" # Step running a command
The core hierarchy is: Workflow (file) > Jobs (run in parallel by default) > Steps (run sequentially within a job). Jobs can depend on other jobs using the needs keyword, creating a pipeline of sequential stages.
Triggers: When Workflows Run
The on key defines what events trigger the workflow. You can combine multiple triggers and filter by branches, paths, and tags.
on:
# Run on push to main or release branches
push:
branches: [main, 'release/**']
paths-ignore:
- '**.md'
- 'docs/**'
# Run on all pull requests targeting main
pull_request:
branches: [main]
# Run on a schedule (UTC)
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
# Run manually from the GitHub UI
workflow_dispatch:
inputs:
environment:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options: [staging, production]
# Run when a release is published
release:
types: [published]
The paths and paths-ignore filters are critical for performance. If your workflow only tests backend code, use paths: ['src/**', 'package.json'] so documentation changes do not trigger a build.
Build cron expressions for your scheduled workflows with the Cron Expression Generator. It shows a human-readable description and upcoming run times.
Jobs, Steps, and Actions
Jobs Run in Parallel
By default, all jobs in a workflow run simultaneously on separate runners. Use needs to create dependencies.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
# Deploy only after lint AND test succeed
deploy:
needs: [lint, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm run deploy
Steps: Commands and Actions
Steps either run shell commands with run or use pre-built actions with uses. Actions are reusable units of automation published to the GitHub Marketplace.
steps:
# Use a published action (org/repo@version)
- uses: actions/checkout@v4
# Use an action with inputs
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
# Run a shell command
- run: npm ci
# Run multiple commands
- run: |
npm run build
npm run test:coverage
# Set environment variables for a step
- run: echo "Deploying to $TARGET"
env:
TARGET: production
# Name your steps for readability
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: ${{ secrets.TEST_DB_URL }}
Your First CI Pipeline
Here is a complete CI workflow for a Node.js project that lints, tests, and builds on every push and pull request.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npx tsc --noEmit
- name: Run tests
run: npm test -- --coverage
- name: Build
run: npm run build
- name: Upload coverage
if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
The concurrency block cancels in-progress runs when new commits are pushed to the same branch. This avoids wasting runner minutes on outdated code.
Always set timeout-minutes on jobs. The default is 360 minutes (6 hours). A stuck process or infinite loop will consume your entire monthly runner allocation if left unchecked.
Matrix Builds
Matrix builds test your code across multiple configurations in parallel. Essential for libraries and tools that need to work on different platforms and runtime versions.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
include:
- os: ubuntu-latest
node: 22
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
- name: Upload coverage
if: matrix.coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
fail-fast: false lets all matrix jobs complete even if one fails. This is useful for seeing the full picture of which platforms pass and which fail, rather than stopping at the first failure.
Secrets and Environment Variables
Never hardcode sensitive values. Use GitHub's encrypted secrets and environment variables.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Links to GitHub Environment settings
env:
NODE_ENV: production # Available to all steps
steps:
- uses: actions/checkout@v4
- name: Deploy to AWS
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DIST_ID }} \
--paths "/*"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
OIDC: No Long-Lived Secrets
For AWS, GCP, and Azure, use OIDC (OpenID Connect) to get short-lived credentials without storing access keys as secrets.
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: us-east-1
# No AWS keys needed - OIDC token is exchanged automatically
- run: aws s3 sync dist/ s3://my-bucket
The Env File Editor helps you organize which variables belong in secrets versus regular environment configuration.
Dependency Caching
Caching dependencies is the single biggest speedup for most workflows. Instead of downloading packages from npm, pip, or Docker Hub on every run, cache them between workflow runs.
# Option 1: Built-in cache in setup actions (recommended)
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm' # Automatically caches ~/.npm
# Option 2: Explicit cache action (more control)
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Python pip caching
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
# Docker layer caching
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: my-app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The cache key uses hashFiles('package-lock.json') so the cache invalidates automatically when dependencies change. The restore-keys fallback means a partial cache miss still uses the most recent cache rather than downloading everything from scratch.
Artifacts and Build Outputs
Artifacts let you pass files between jobs and download build outputs from the GitHub UI.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
run: ./deploy.sh dist/
Deployment Workflows
Deploy to Vercel
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Deploy Docker to Container Registry
name: Build and Push Docker
on:
push:
tags: ['v*']
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
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
Generate your Docker Compose and Dockerfile configurations with the Docker Compose Generator before adding them to your CI pipeline.
Deploy with Environment Protection
name: Deploy to Production
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy-staging:
needs: test
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh production
Configure the production environment in GitHub Settings to require manual approval. Reviewers must approve the deployment before the production job starts.
Custom Actions
Create reusable actions for logic that multiple workflows share. Composite actions are the simplest type, defined in a single YAML file.
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build the project'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '22'
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:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '22'
- run: npm test
Create your .gitignore file to exclude build artifacts, node_modules, and environment files from your repository before setting up your CI pipeline.
Monorepo Strategies
In a monorepo with multiple packages or services, you need workflows that only build what changed.
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'packages/api/**'
- 'packages/shared/**'
web:
- 'packages/web/**'
- 'packages/shared/**'
shared:
- 'packages/shared/**'
test-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test --workspace=packages/api
test-web:
needs: detect-changes
if: needs.detect-changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test --workspace=packages/web
The dorny/paths-filter action detects which directories changed and sets outputs that downstream jobs use to decide whether to run. If only the API package changed, the web test job is skipped entirely.
Include shared package paths in the filters for all dependent packages. When packages/shared/ changes, both API and web test jobs should run since both depend on it.
Related Developer Tools
Free browser-based tools for your CI/CD workflow.
Frequently Asked Questions
GitHub Actions is a CI/CD platform built into GitHub that automates software workflows directly from your repository. You define workflows in YAML files inside the .github/workflows directory. Each workflow contains one or more jobs that run on virtual machines called runners. Jobs contain steps that either run shell commands or use pre-built actions from the GitHub Marketplace. Workflows are triggered by events like pushing code, opening a pull request, creating a release, or on a cron schedule. GitHub provides free runners for public repositories and 2000 minutes per month for private repositories on the free plan.
Create a file at .github/workflows/ci.yml in your repository. Define the trigger (typically on push and pull_request to the main branch), specify a job that runs on ubuntu-latest, and add steps to check out the code, set up your runtime like Node.js or Python, install dependencies, run linting, and run tests. Push the file to your repository and GitHub will automatically run the workflow on the next matching event. The workflow status appears as a check on pull requests and commits. A basic Node.js CI pipeline takes about 10 lines of YAML and typically runs in under two minutes.
Matrix builds run the same job across multiple configurations in parallel. You define a strategy.matrix in your job with variables like operating system and language version. GitHub Actions creates a separate job for each combination. For example, a matrix of os: [ubuntu-latest, windows-latest] and node: [18, 20, 22] creates six parallel jobs testing every OS and Node version combination. You can exclude specific combinations with the exclude key or add one-off configurations with the include key. Matrix builds are essential for libraries that need to verify compatibility across multiple platforms and runtime versions.
Store sensitive values like API keys, tokens, and passwords as encrypted secrets in your repository or organization settings under Settings then Secrets and variables then Actions. Reference them in workflows using the syntax secrets.SECRET_NAME in the env block or directly in step inputs. GitHub automatically masks secret values in logs so they are never printed in plain text. For additional security, use environment-specific secrets that require approval before deployment, use OIDC tokens instead of long-lived credentials for cloud providers like AWS and GCP, and limit secret access to specific branches using environment protection rules. Never hardcode secrets in workflow files or pass them as command line arguments where they might appear in process listings.
The most effective optimizations are dependency caching, parallel jobs, and targeted triggers. Cache npm, pip, or other package manager dependencies using actions/cache or the built-in cache option in setup actions like actions/setup-node. This avoids re-downloading packages on every run and can save two to five minutes per workflow. Split independent tasks like linting, unit tests, and integration tests into separate parallel jobs. Use path filters in your triggers so workflows only run when relevant files change. Use concurrency groups to cancel redundant runs when new commits are pushed to the same branch. For Docker-based workflows, cache Docker layers. These optimizations together can reduce a 15-minute workflow to under three minutes.
Yes. GitHub Actions supports deployment to any platform including AWS, Google Cloud, Azure, Vercel, Netlify, DigitalOcean, and custom servers. You can set up continuous deployment that automatically deploys on push to the main branch, or use GitHub Environments with protection rules that require manual approval before production deployments. A typical deployment workflow runs tests first, builds the application, then deploys using platform-specific actions or CLI tools. For production safety, use environment protection rules that require one or more reviewers to approve the deployment, restrict which branches can deploy to production, and add a wait timer between approval and deployment. This gives you automation with human oversight for critical environments.