BlogDevOps
DevOps

GitHub Actions CI/CD: From Zero to Production Pipeline

Build, test, and deploy your application automatically — with real workflow YAML examples for Node.js, Python, and Docker.

A
Ankit Sharma
DevOps Engineer
·Jan 25, 2025·9 min read

GitHub Actions is now the default CI/CD platform for most teams — it is free for public repos, integrates natively with GitHub, and requires no separate infrastructure to manage. This guide gets you from zero to a production-grade pipeline with automated testing, Docker builds, and deployment.

Core Concepts in 60 Seconds

  • Workflow: a YAML file in .github/workflows/ — triggered by events
  • Event: push, pull_request, schedule, workflow_dispatch (manual), etc.
  • Job: a unit of work that runs on a runner — jobs run in parallel by default
  • Step: an individual command or action within a job — steps run sequentially
  • Runner: the machine that executes your job — ubuntu-latest is free and covers 90% of use cases
  • Action: a reusable unit (actions/checkout, actions/setup-node) from the marketplace

Node.js CI Pipeline

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

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

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]  # Test against multiple versions

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

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci  # Use ci not install — faster, uses package-lock.json exactly

      - name: Run linter
        run: npm run lint

      - name: Run type check
        run: npm run typecheck

      - name: Run tests
        run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        if: matrix.node-version == 22  # Only upload once

Docker Build and Push

yaml
# .github/workflows/docker.yml
name: Build and Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']  # Also trigger on version tags

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write  # Required to push to GitHub Container Registry

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login 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=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern={{version}}

      - 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   # GitHub Actions cache — dramatically speeds up rebuilds
          cache-to: type=gha,mode=max

Deployment to a VPS/Server

yaml
# .github/workflows/deploy.yml
name: Deploy to Production

on:
  workflow_run:
    workflows: ["Build and Push Docker Image"]
    types: [completed]
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            # Pull latest image
            docker pull ghcr.io/${{ github.repository }}:main

            # Zero-downtime restart using Docker Compose
            cd /opt/myapp
            docker compose pull
            docker compose up -d --remove-orphans

            # Verify deployment
            sleep 10
            curl -f http://localhost:3000/health || exit 1

            # Clean up old images
            docker image prune -f

Secrets Management

Store secrets in GitHub repository Settings → Secrets and variables → Actions. Never hardcode credentials in workflow files.

  • GITHUB_TOKEN is automatically provided — use it for pushing to GHCR and creating releases
  • Environment secrets (Settings → Environments) can be restricted to specific branches
  • Use environment protection rules to require manual approval before deploying to production
  • Rotate secrets quarterly — use the GitHub CLI to update: gh secret set MY_SECRET
  • Scan for accidentally committed secrets: add trufflesecurity/trufflehog-actions to your CI

Cost Optimisation

NOTE

GitHub Actions is free for public repositories with unlimited minutes. For private repos: 2,000 minutes/month free on the Free plan, 3,000 on Pro, 50,000 on Teams. Ubuntu runners cost 0.008 USD/minute. Optimise by caching dependencies and using job conditions to skip unnecessary runs.

  • Cache node_modules with actions/cache or use actions/setup-node cache option
  • Use paths filters to skip CI for docs-only changes: on.push.paths
  • Run expensive jobs (E2E tests) only on PRs to main, not every feature branch push
  • Use concurrency to cancel in-progress runs when a new push arrives on the same branch
  • Self-hosted runners for long-running jobs — 10× cheaper than GitHub-hosted at scale
Category:DevOps