DevOps

CI/CD Pipeline Explained: GitHub Actions Tutorial for Beginners (2026)

2026-07-05·12 min read
#CI/CD#GitHub Actions#DevOps#automation

CI/CD (Continuous Integration / Continuous Deployment) is the practice of automatically testing and deploying your code every time you push changes. It's how professional teams ship code — no manual testing, no FTP uploads, no "works on my machine."

GitHub Actions is the most popular CI/CD platform in 2026, with 20+ million active workflows. It's built into GitHub, free for public repositories, and has the largest ecosystem of pre-built actions.

This guide takes you from zero to production CI/CD pipelines.

CI/CD Basics: What's Actually Happening

Developer pushes code → GitHub Actions runs tests → If tests pass → Auto-deploy to production
                                          ↓
                                   If tests fail → Notify developer (don't deploy)

That's it. Every push triggers automated checks. Only good code reaches production.

Continuous Integration (CI)

Every code push automatically runs tests and checks. If something breaks, you know immediately — not in production at 3 AM.

Continuous Deployment (CD)

When CI passes, code automatically deploys to staging or production. No human intervention needed.

GitHub Actions Concepts

| Concept | What It Means | |---------|--------------| | Workflow | A YAML file defining automated processes | | Event | What triggers a workflow (push, PR, schedule, manual) | | Job | A set of steps that run on the same machine | | Step | An individual task (run a command, use an action) | | Action | Reusable unit of code (from the GitHub Marketplace) | | Runner | The machine executing your workflow | | Artifact | Files produced by a workflow (test reports, builds) |

Your First Workflow

Create .github/workflows/ci.yml:

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    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: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

This workflow:

  1. Triggers on every push to main or develop, and every PR to main
  2. Checks out your code
  3. Sets up Node.js 22 with npm cache
  4. Installs dependencies (npm ci is faster and stricter than npm install)
  5. Runs linting, tests, and build

Every push now gets automatically tested. If any step fails, you see a red ❌ on GitHub. If all pass, a green ✅.

Workflow Syntax Deep Dive

Triggers (When to Run)

on:
  # Push to specific branches
  push:
    branches: [main, develop]
    paths:
      - 'src/**'           # Only when src/ files change
      - 'package.json'

  # Pull requests
  pull_request:
    branches: [main]

  # Scheduled (cron syntax)
  schedule:
    - cron: '0 2 * * *'    # Every day at 2 AM UTC

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

  # When another workflow completes
  workflow_run:
    workflows: ["Build"]
    types: [completed]

Jobs (What to Run)

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30       # Kill job after 30 minutes
    strategy:
      matrix:
        node-version: [20, 22]  # Run with both versions
        os: [ubuntu-latest, macos-latest]  # Run on both OS
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This matrix runs your tests 4 times: Node 20 on Ubuntu, Node 20 on macOS, Node 22 on Ubuntu, Node 22 on macOS.

Job Dependencies

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  test:
    needs: lint               # Wait for lint to pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:
    needs: test               # Wait for test to pass
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # Only deploy from main
    steps:
      - run: echo "Deploying to production!"

Environment Variables and Secrets

# Define at workflow level
env:
  NODE_ENV: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DEPLOY_ENV: production  # Job-level env
    steps:
      - env:
          API_KEY: ${{ secrets.API_KEY }}  # From GitHub Secrets
        run: |
          curl -H "Authorization: Bearer $API_KEY" https://api.example.com/deploy

Setting up secrets:

  1. Go to your repo on GitHub
  2. Settings → Secrets and variables → Actions
  3. New repository secret
  4. Name: API_KEY, Value: your secret

Secrets are encrypted and never logged. They're masked as *** in output.

Conditional Execution

steps:
  - name: Only on main branch
    if: github.ref == 'refs/heads/main'
    run: echo "This is main"

  - name: Only if previous step failed
    if: failure()
    run: echo "Something broke"

  - name: Always run (even on failure)
    if: always()
    run: echo "Cleanup..."

  - name: Skip on specific paths
    if: |
      !contains(github.event.head_commit.message, '[skip ci]')
    run: npm test

Practical Examples

Node.js Full Pipeline

name: Node.js CI/CD

on:
  push:
    branches: [main]
  pull_request:

jobs:
  # Quality checks
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check

  # Tests with coverage
  test:
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info

  # Build and push Docker image
  build-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            myorg/app:latest
            myorg/app:${{ github.sha }}

  # Deploy to production
  deploy:
    needs: build-push
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval if configured
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /app
            docker compose pull
            docker compose up -d
            docker image prune -f

Python Pipeline

name: Python CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13']
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install ruff pytest pytest-cov

      - name: Lint
        run: ruff check .

      - name: Test
        run: pytest --cov=. --cov-report=xml

      - name: Type check
        run: pip install mypy && mypy .

Deploy to Cloudflare Pages

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=my-site

Deploy to AWS

name: Deploy to AWS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image
        env:
          ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
          docker push $ECR_REGISTRY/myapp:$IMAGE_TAG

      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster my-cluster \
            --service my-service \
            --force-new-deployment

Caching Dependencies

Speed up workflows by caching installed dependencies:

steps:
  - uses: actions/checkout@v4

  # Node.js (built-in cache via setup-node)
  - uses: actions/setup-node@v4
    with:
      node-version: '22'
      cache: 'npm'      # Automatically caches ~/.npm

  # Python
  - uses: actions/setup-python@v5
    with:
      python-version: '3.12'
      cache: 'pip'      # Automatically caches pip downloads

  # Docker layers
  - uses: docker/setup-buildx-action@v3
  - uses: docker/build-push-action@v6
    with:
      context: .
      push: true
      cache-from: type=gha    # Use GitHub Actions cache
      cache-to: type=gha,mode=max

Proper caching reduces workflow time from 5+ minutes to under 1 minute.

Notifications

Slack Notification on Failure

steps:
  - name: Notify Slack on failure
    if: failure()
    uses: slackapi/slack-github-action@v1
    with:
      slack-message: "❌ Deploy failed: ${{ github.run_id }}"
      slack-channel: C0123456789
    env:
      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

Discord Notification

steps:
  - name: Notify Discord
    if: always()
    uses: sarisia/actions-status-discord@v1
    with:
      webhook: ${{ secrets.DISCORD_WEBHOOK }}
      title: "Deploy ${{ job.status }}"

Reusable Workflows

Define a workflow once, reuse across repos:

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

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '22'

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

Use it from another workflow:

jobs:
  ci:
    uses: ./.github/workflows/reusable-ci.yml
    with:
      node-version: '22'

Cost Optimization

GitHub Actions free tier includes:

  • 2,000 minutes/month for private repos (public repos are unlimited)
  • 500MB of artifact storage

Tips to reduce usage:

  1. Cancel outdated runs. When you push a new commit, cancel the old run:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  1. Use specific paths. Don't run CI when only docs change:
on:
  push:
    paths-ignore:
      - '**.md'
      - 'docs/**'
  1. Cache everything. Dependencies, Docker layers, build output.

  2. Use smaller runners. ubuntu-latest is cheaper than macos-latest.

Conclusion

CI/CD isn't optional for modern development teams. It catches bugs early, automates repetitive tasks, and gives you confidence to deploy frequently.

Start simple:

  1. Add a basic CI workflow that runs lint and tests on every push
  2. Add caching to speed up builds
  3. Add deployment when your tests are reliable
  4. Add notifications so your team knows about deploys

GitHub Actions makes this all free, accessible, and powerful. The YAML syntax has a learning curve, but the patterns in this guide cover 90% of real-world use cases.

The best time to add CI/CD was when you started the project. The second best time is now.