GitHub Actions: Workflow Syntax to Production Deploys
Build CI/CD pipelines with GitHub Actions. Workflow syntax, real-world examples, caching, matrix builds, and cost-saving tips.

Push code, tests run automatically. Merge to main, production deploys. That's CI/CD in a nutshell. Setting this up used to mean running Jenkins on a separate server. GitHub Actions puts the whole thing inside your GitHub repository.
Add a YAML file, commit it, done. No extra servers, just your GitHub account.
Basic Workflow Structure
Drop a YAML file in .github/workflows/ and GitHub picks it up automatically. Here's the simplest possible version:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
on defines the trigger, jobs defines the work, steps are individual actions. Commit this file and every push or PR to main kicks off the tests.
One thing: use npm ci instead of npm install. ci installs exact versions from package-lock.json and wipes node_modules clean. Reproducibility matters in CI.
Triggers — When Does It Run?
The on field controls execution conditions. Some common patterns:
# Only run when specific paths change
on:
push:
paths:
- 'src/**'
- 'package.json'
# Scheduled (cron) — every day at 3am UTC
on:
schedule:
- cron: '0 3 * * *'
# Manual trigger (workflow_dispatch)
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
paths filtering is especially useful in monorepos. Why run backend tests when only frontend code changed? workflow_dispatch adds a manual "Run workflow" button in the GitHub UI — handy when you want to control deploy timing.
Jobs and Steps
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint # runs after lint succeeds
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh
needs sets dependency order: lint, then test, then deploy. Jobs without needs run in parallel.
The if condition restricts execution. Here, the deploy job only runs on pushes to main. PRs get testing only, no deployment.
Secrets and Environment Variables
Store sensitive values (API keys, deploy tokens) in repository Settings, then reference them in workflows.
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying with token..."
./deploy.sh
Secrets are automatically masked in logs. echo $API_KEY shows ***. But be careful — encoding a secret as base64 or splitting it character by character can bypass masking. Best practice: never print secret values at all.
You can also separate environments. Adding an approval requirement on a production environment prevents accidental production deploys.
Caching — Faster Builds
Installing packages from scratch every run wastes time. Caching reuses node_modules from previous runs.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # one line is all it takes
- run: npm ci
- run: npm test
Adding cache: 'npm' to actions/setup-node@v4 enables automatic caching based on package-lock.json. With a warm cache, npm ci drops from tens of seconds to just a few.
For monorepo build tools like Turbo or nx, cache build artifacts separately:
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json') }}
Real-World Example — Next.js Project
A workflow you might actually use for a Next.js project:
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Lint
run: npm run lint
- name: Type Check
run: npx tsc --noEmit
- name: Build
run: npm run build
preview:
runs-on: ubuntu-latest
needs: quality
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- 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 }}
production:
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- 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'
PRs get lint + type check + build, then a Vercel preview deploy. Merging to main triggers production deploy. Not using Vercel? Swap the preview/production jobs for your own deploy method.
Matrix Strategy
Test across multiple Node.js versions or operating systems:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This creates 2 (OS) x 3 (Node versions) = 6 combinations running in parallel. Great for library compatibility testing. For a typical web app, one combination is usually enough.
Cost and Limits
Public repos are free. Virtually unlimited. Open source projects have nothing to worry about.
Private repos get 2,000 free minutes per month (Free plan). Beyond that, you pay per minute. But 2,000 minutes is generous — a 5-minute workflow running 10 times a day uses about 1,500 minutes per month.
Cost-saving tips:
- Use
pathsfilters to skip unnecessary runs - Cache aggressively
- Set
concurrencyto cancel outdated runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
With this, a new push to the same branch automatically cancels any in-progress workflow. Prevents wasted minutes when you push multiple times to a PR.
Common Mistakes
Accessing secrets from forked PRs. For security reasons, secrets aren't available in PRs from forks. This is intentional. If you need secrets for fork PRs, pull_request_target exists but carries its own security risks — use it carefully.
YAML syntax errors. YAML is whitespace-sensitive. One wrong indent and the workflow won't even register. Install the GitHub Actions VS Code extension for syntax validation.
Forgetting actions/checkout. It's easy to assume the code is just there. It's not. Without this step, your workflow runs in an empty directory.
GitHub Actions is infrastructure that runs itself once set up. The initial YAML writing is a bit tedious, but weigh that against manually running tests and deploying for months. The upfront investment pays off quickly.