CI/CD with GitHub Actions: From Code to Production in Minutes

Learn how to build a robust CI/CD pipeline with GitHub Actions, including automated testing, building, and deploying to production.

Jean-Pierre Broeders

Freelance DevOps Engineer

February 17, 20266 min. read

CI/CD with GitHub Actions: From Code to Production in Minutes

Every developer knows the feeling: you've finished a feature, but deploying it means a manual process full of clicking, waiting, and hoping nothing breaks. GitHub Actions eliminates that pain — with a handful of YAML files, you can automate the entire journey from code commit to live production.

What Is CI/CD, Really?

Continuous Integration (CI) means every code push triggers an automatic test run. Push code, tests run, get feedback. No surprises waiting for you at release time.

Continuous Deployment (CD) takes it further: when tests pass, the code is automatically rolled out to staging or production. No manual handoffs, no deployment scripts run by hand.

GitHub Actions is Microsoft's built-in answer to this challenge, woven directly into GitHub. No external CI server to maintain, no third-party integrations to configure.

Your First Workflow

Workflows live in .github/workflows/ and are written in YAML. A basic workflow for a Node.js project looks like this:

name: CI/CD Pipeline

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

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test
      
      - name: Build
        run: npm run build

Save this file and push it — that's all it takes to automatically run tests on every push and pull request.

Managing Secrets Safely

Your deployment step almost always needs credentials: API keys, SSH keys, passwords. These should never live in your code. GitHub provides Secrets for exactly this:

  1. Go to your repo → Settings → Secrets and variables → Actions
  2. Add a secret, for example DEPLOY_SSH_KEY
  3. Reference it in your workflow:
- name: Deploy to server
  env:
    SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
  run: |
    echo "$SSH_KEY" > /tmp/deploy_key
    chmod 600 /tmp/deploy_key
    ssh -i /tmp/deploy_key user@server.example.com "cd /app && git pull && npm install && pm2 restart all"

Secrets are stored encrypted and never appear in logs — even if someone forks your repository.

Deploying to Multiple Environments

In practice, you want a clear separation between staging and production. Environments and branch rules handle this elegantly:

jobs:
  deploy-staging:
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        run: ./deploy.sh staging

  deploy-production:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    needs: [test]  # Only runs after successful tests
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: ./deploy.sh production

The needs: [test] directive ensures deployment only happens when all tests pass. Non-negotiable for production.

Matrix Builds: Test Multiple Versions in Parallel

Want to be sure your code works on Node 18, 20, and 22? Use matrix strategy:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

GitHub spins up three parallel jobs, one per version. When one version fails, you know exactly which one — without manually switching environments.

Caching for Faster Builds

Every time npm install runs, GitHub downloads all packages from scratch. That adds up. Caching solves this:

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

The cache stores downloaded packages keyed by a hash of package-lock.json. If the lockfile hasn't changed, packages load from cache — often 2–3x faster builds.

Production-Ready Tips

Pin action versions — never use @main:

uses: actions/checkout@v4  # Good
uses: actions/checkout@main  # Dangerous — can break unexpectedly

Restrict permissions to only what your workflow needs:

permissions:
  contents: read
  packages: write

Set timeouts so hung jobs don't run indefinitely:

jobs:
  build:
    timeout-minutes: 15

Use concurrency groups to prevent duplicate deployments:

concurrency:
  group: production-deploy
  cancel-in-progress: false  # Let running deployments finish

From Zero to Automated in an Afternoon

GitHub Actions has a low barrier to entry: all you need is a GitHub account and a repository. The free tier provides 2,000 minutes per month for private repos, with unlimited minutes for public repos.

Start small: add tests first. Once that's running smoothly, layer in the deployment step. Incrementally, you build a reliable pipeline that saves you hours every week — and eliminates that pre-deployment anxiety for good.


GitHub Actions makes CI/CD accessible to any team: automated testing, multi-environment deployments, and parallel matrix builds all from a single YAML file. Start with tests, add deployment, and iterate from there.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch