GitHub Actions CI/CD: Build, Test, and Deploy Workflows from Scratch
Learn how to set up GitHub Actions for continuous integration and deployment. Covers triggers, jobs, matrices, secrets, caching, Docker, and real-world workflow examples.
GitHub Actions turns every push, pull request, and merge into an automated pipeline — running tests, building artifacts, scanning for vulnerabilities, and deploying to production. It's the CI/CD platform that lives right inside your repository, with zero infrastructure to manage.
How GitHub Actions works
Every workflow is a YAML file in .github/workflows/. When a trigger event fires, GitHub spins up a fresh virtual machine, runs your jobs, and reports the results — all within seconds.
Push to main
↓
Workflow triggered
↓
Job: Build & Test (ubuntu-latest)
├── Step 1: Checkout code
├── Step 2: Install Node.js
├── Step 3: npm ci
├── Step 4: npm test
└── Step 5: npm run build
↓
✅ All checks passed
Your first workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
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 test
- run: npm run build
That's it. Every push to main and every PR now runs your test suite automatically.
Triggers: when workflows run
Code events
on:
push:
branches: [main, develop]
paths: ['src/**', 'package.json'] # Only run when these change
pull_request:
types: [opened, synchronize]
Scheduled (cron)
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6:00 UTC
Manual dispatch
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy target'
required: true
type: choice
options: [staging, production]
This adds a "Run workflow" button in the GitHub UI where you can select parameters.
Reusable workflows
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
Call it from another workflow with uses: ./.github/workflows/reusable.yml.
Jobs and steps
Parallel jobs
Jobs run in parallel by default:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test] # Waits for both to pass
steps:
- uses: actions/checkout@v4
- run: npm run build
Matrix strategy — test across versions
Run the same job across multiple configurations:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 6 parallel jobs (3 versions × 2 operating systems).
Secrets and environment variables
Using secrets
Store sensitive values in Settings → Secrets and variables → Actions:
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./deploy.sh
Secrets are masked in logs and never exposed to forks.
Environment variables
# Workflow-level
env:
NODE_ENV: production
jobs:
build:
# Job-level
env:
CI: true
steps:
- name: Build
# Step-level
env:
VITE_API_URL: https://api.example.com
run: npm run build
Caching for faster builds
Without caching, every run downloads all dependencies from scratch. Caching cuts build times by 50-80%:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Built-in npm caching
# Or manual caching for other tools
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Artifacts: sharing data between jobs
Upload build outputs from one job and download in another:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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
- run: ./deploy.sh
Real-world workflow examples
Docker build and push
name: Docker
on:
push:
tags: ['v*']
permissions:
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
Deploy to Cloudflare Pages
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy dist --project-name=my-site
Release with changelog
name: Release
on:
push:
tags: ['v*.*.*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
run: |
git log $(git describe --tags --abbrev=0 HEAD^)..HEAD \
--pretty=format:"- %s" > CHANGELOG.md
- uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
generate_release_notes: true
Conditional execution
Control when steps and jobs run:
steps:
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy-prod.sh
- name: Deploy to staging
if: github.event_name == 'pull_request'
run: ./deploy-staging.sh
- name: Notify on failure
if: failure()
run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d '{"text":"Build failed!"}'
Security best practices
1. Pin action versions to SHA
# ❌ Mutable tag — could be compromised
- uses: actions/checkout@v4
# ✅ Pinned to exact commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
2. Use least-privilege permissions
permissions:
contents: read # Only what's needed
packages: write
3. Never echo secrets
# ❌ NEVER do this
- run: echo ${{ secrets.API_KEY }}
# ✅ Use environment variables
- env:
API_KEY: ${{ secrets.API_KEY }}
run: ./script-that-uses-api-key.sh
4. Limit permissions for PRs from forks
on:
pull_request_target: # Runs in context of base branch
types: [opened, synchronize]
Debugging failed workflows
- Check the logs — Click any failed step to see full output
- Add debug logging — Set secret
ACTIONS_STEP_DEBUGtotrue - Use
actlocally — Run workflows on your machine with nektos/act - SSH into runner — Use
mxschmitt/action-tmatefor interactive debugging
Build workflows visually
Don't want to write YAML by hand? Use our GitHub Actions Generator to build workflows visually with templates for Node.js, Python, Docker, deployments, and more — then download production-ready YAML.
Wrapping up
GitHub Actions gives you CI/CD that lives inside your repository:
- Start simple — One workflow file with build + test
- Add caching — Cut build times with
actions/cacheor built-in language caching - Use matrix — Test across versions and operating systems
- Protect secrets — Never hardcode, always use encrypted secrets
- Automate everything — Releases, deployments, security scans, scheduled tasks
The best CI/CD pipeline is the one that runs on every commit without you thinking about it. Set it up once, and let GitHub handle the rest.