March 27, 20269 min read

GitHub Actions: Automate Your CI/CD Pipeline in 20 Minutes

Set up GitHub Actions from scratch. Workflow files, triggers, jobs, caching, secrets, matrix builds, and a real lint-test-build-deploy pipeline.

github-actions cicd devops automation tutorial
Ad 336x280

Every team eventually reaches the same breaking point: someone pushes code that breaks the build, nobody notices until the demo, and suddenly it's a fire drill. CI/CD fixes this by running your tests automatically on every push. GitHub Actions makes it free and built into the platform you're already using.

If your code lives on GitHub, there's no reason not to have a CI pipeline. By the end of this tutorial, you'll have a workflow that lints, tests, builds, and deploys your code -- triggered automatically on every push.

What GitHub Actions Actually Is

GitHub Actions is a CI/CD platform built into GitHub. You define workflows in YAML files, and GitHub runs them on virtual machines whenever certain events happen (push, pull request, schedule, etc.).

The key concepts:

  • Workflow: A YAML file in .github/workflows/ that defines an automated process
  • Event: What triggers the workflow (push, pull_request, schedule, manual)
  • Job: A set of steps that run on the same virtual machine
  • Step: An individual task within a job (run a command, use an action)
  • Action: A reusable unit of code. Think of it like an npm package for CI/CD
  • Runner: The machine that executes your workflow (GitHub provides free ones)

Your First Workflow

Create .github/workflows/ci.yml in your repository:

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

Push this file, and GitHub immediately starts running it. Every subsequent push to main or PR against main triggers the workflow.

Let's break down what's happening:

on defines the triggers. This workflow runs on pushes to main and on pull requests targeting main. jobs.test defines a job called "test" that runs on an Ubuntu machine. GitHub provides free runners with Ubuntu, macOS, and Windows.

Each step either runs a shell command (run) or uses a pre-built action (uses). actions/checkout@v4 clones your repo. actions/setup-node@v4 installs Node.js. Then we install dependencies and run tests.

Building a Real Pipeline

A toy example runs tests. A real pipeline does more. Let's build one that lints, tests, builds, and deploys:

name: CI/CD Pipeline

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
name: Lint
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

test:
name: Test
runs-on: ubuntu-latest
needs: lint # Only run if lint passes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/

build:
name: Build
runs-on: ubuntu-latest
needs: test # Only run if tests pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build
path: dist/

deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Deploy to production
run: |
# Your deployment command here
echo "Deploying to production..."
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

The needs keyword creates dependencies between jobs. Lint runs first, then tests (only if lint passes), then build, then deploy. The deploy job has an extra condition: it only runs on pushes to main, not on pull requests.

Triggers: When Workflows Run

on:
  # Every push to any branch
  push:

# Push to specific branches
  push:
    branches: [main, develop]

# Push to branches matching a pattern
  push:
    branches: ['release/**']

# Pull requests
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

# On a schedule (cron syntax, UTC)
  schedule:
    - cron: '0 9   1'  # Every Monday at 9am UTC

# Manual trigger with inputs
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

# When a release is published
  release:
    types: [published]

# When specific files change
  push:
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - '**.md'
      - 'docs/**'

The paths filter is particularly useful. Why run your test suite when someone only changed a README?

Caching Dependencies

Installing node_modules on every run wastes time. The setup-node action has built-in caching:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'  # Caches based on package-lock.json

For more control, use the cache action directly:

- name: Cache node modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

  • run: npm ci
The first run caches the dependencies. Subsequent runs restore from cache if package-lock.json hasn't changed. This typically saves 30-60 seconds per run.

For Python projects:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

Secrets and Environment Variables

Never hardcode API keys or tokens in workflow files. Use GitHub Secrets.

Go to your repository Settings, then Secrets and Variables, then Actions. Add secrets like DEPLOY_TOKEN, AWS_ACCESS_KEY_ID, etc.

Use them in workflows:

steps:
  - name: Deploy
    run: ./deploy.sh
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Secrets are masked in logs automatically. If a secret accidentally appears in output, GitHub replaces it with *.

For non-secret configuration:

env:
  NODE_ENV: production
  APP_NAME: my-app

jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_MODE: release # Job-level env var
steps:
- run: echo "Building $APP_NAME in $BUILD_MODE mode"

Matrix Builds

Test across multiple versions or operating systems simultaneously:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
      fail-fast: false  # Don't cancel other jobs if one fails

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test

This creates 9 jobs (3 OS x 3 Node versions) running in parallel. fail-fast: false means all combinations run even if one fails, so you get the full picture.

You can also exclude specific combinations:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node-version: [18, 20]
    exclude:
      - os: windows-latest
        node-version: 18

Services: Databases in CI

Need a database for integration tests? Use service containers:

jobs:
  test:
    runs-on: ubuntu-latest

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

redis:
image: redis:7
ports:
- 6379:6379

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379

The services spin up as Docker containers before your steps run. The health-cmd ensures the database is ready before tests start.

Reusable Workflows

If multiple repos use the same CI setup, extract it into a reusable workflow:

# .github/workflows/reusable-node-ci.yml
name: Reusable Node.js CI

on:
workflow_call:
inputs:
node-version:
required: false
type: number
default: 20
secrets:
NPM_TOKEN:
required: false

jobs:
ci:
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 run lint
- run: npm test
- run: npm run build

Call it from another workflow:

# .github/workflows/ci.yml
name: CI

on:
push:
branches: [main]

jobs:
ci:
uses: ./.github/workflows/reusable-node-ci.yml
with:
node-version: 20
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Or call a workflow from a different repository:

jobs:
  ci:
    uses: your-org/shared-workflows/.github/workflows/node-ci.yml@main

Practical Recipes

Auto-label PRs Based on Changed Files

name: Label PRs

on:
pull_request:
types: [opened, synchronize]

jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

With a .github/labeler.yml config:

frontend:
  - changed-files:
    - any-glob-to-any-file: 'src/frontend/**'

backend:
- changed-files:
- any-glob-to-any-file: 'src/api/**'

docs:
- changed-files:
- any-glob-to-any-file: '*/.md'

Deploy on Release

name: Release

on:
release:
types: [published]

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
- run: npm run build
- name: Publish to npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Scheduled Health Checks

name: Health Check

on:
schedule:
- cron: '/30 *' # Every 30 minutes

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check production
run: |
status=$(curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/health)
if [ "$status" != "200" ]; then
echo "Health check failed with status $status"
exit 1
fi

- name: Notify on failure
if: failure()
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H 'Content-Type: application/json' \
-d '{"text":"Production health check failed!"}'

Common Mistakes

Not caching dependencies. A fresh npm ci on every run adds 30-60 seconds. Use the built-in cache options on setup-node or setup-python. Running deploy on pull requests. Always guard deploy jobs with if: github.ref == 'refs/heads/main' && github.event_name == 'push'. You don't want every PR deploying to production. Ignoring the fail-fast default. By default, matrix builds cancel all jobs when one fails. Set fail-fast: false if you want to see all failures, not just the first one. Not using npm ci instead of npm install. npm ci installs from the lockfile exactly, is faster, and is deterministic. npm install might update the lockfile. Committing secrets. Even if you delete them in the next commit, they're in the Git history forever. Use GitHub Secrets, and if you accidentally commit a key, rotate it immediately. Making workflows too complex. Start simple. One workflow, three jobs (lint, test, build). Add complexity only when you need it.

What's Next

You have a working CI/CD pipeline that catches bugs before they reach production. The natural extensions are adding deployment to specific platforms (AWS, Vercel, Cloudflare), setting up preview deployments for PRs, implementing semantic versioning and changelogs, and building Docker images in CI.

For more DevOps projects and guided CI/CD practice, check out CodeUp.

Ad 728x90