CI/CD Pipeline Setup Guide: Automated build, test, and deploy

"It works on my machine, but not in production" is a pain every team knows. Manually uploading code to a server means a missing dependency, a forgotten environment variable, or a skipped rebuild all turn into incidents. CI/CD turns "build, test, deploy" from a manual ritual into an automatic reaction to every commit: push to main and a clean machine runs the whole pipeline for you, flagging problems the moment they appear instead of waiting for users to report them. That is why CI/CD has become the standard practice for modern web development.

Core Value

Aspect Traditional CI/CD
Build Manual local, inconsistent Auto-triggered in a clean env
Tests Easy to skip, relies on discipline Always run, gate the release
Deploy SSH upload by hand Automated pipeline
Rollback Dig through chat logs for the old build One-click to previous version

The hidden win is the clean environment: CI runs in a fresh container every time, exposing "I installed this package locally, so it works" style problems.

How to break the pipeline into stages

A typical website pipeline progresses in this order, with each stage able to fail independently for quick localization:

Commit → Install deps → Lint → Unit tests → Build → Upload artifact → Deploy → Health check

Between "build" and "deploy," pass artifacts explicitly instead of rebuilding on the server. That guarantees production runs exactly the package that passed tests, avoiding drift from server-side rebuilds.

Putting the testing pyramid into practice

Don't chase 100% coverage from day one; allocate by the pyramid instead — many cheap, fast unit tests at the base, a handful of integration tests in the middle, and a few end-to-end tests at the top. For most marketing sites and business apps, running "lint + unit tests on critical modules + one smoke test" already blocks the bulk of regressions; the rest can go to manual acceptance.

GitHub Actions Workflow

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

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

jobs:
  build-and-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: Lint
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/

  deploy:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest

    steps:
      - name: Download build
        uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/

      - name: Deploy to server
        uses: easingthemes/ssh-deploy@main
        with:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.DEPLOY_HOST }}
          REMOTE_USER: ${{ secrets.DEPLOY_USER }}
          TARGET: /var/www/example.com/

Notice how the deploy job chains its dependency with needs: build-and-test and restricts itself to main with the if condition: pull requests only run tests, while a merged, fully-green main branch is the only thing that actually ships.

Frontend CI/CD Essentials

Build caching

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

Keying the cache on the package-lock.json hash means unchanged dependencies hit the cache, shrinking a build from five minutes to a few tens of seconds.

Environment variable management

Manage sensitive values with GitHub Secrets and use different variable sets per environment (dev/staging/prod). A common pattern is to keep only a .env.example in the repo and route every real value through Secrets. For the deploy job above, configure these in the repository's Settings → Secrets and variables first:

SSH_PRIVATE_KEY     # the server login private key
DEPLOY_HOST         # deployment server address
DEPLOY_USER         # deployment user

Frontend projects usually generate per-environment config, for example by passing the environment name to the build command:

- name: Build for production
  run: npm run build -- --mode production
  env:
    VITE_API_BASE: ${{ secrets.API_BASE_URL }}

Branch protection

CI only means something when combined with branch protection. In GitHub, open Settings → Branches, protect main, enable "Require status checks to pass before merging," and select the build-and-test job. Then any PR that fails tests cannot merge, making "shipped without tests" impossible at the process level.

Deployment Strategies

Strategy Description Best for
Direct deploy Upload the build straight to the server Small sites
Blue-green Run old and new versions, switch traffic High-availability requirements
Rolling update Replace instances gradually Cluster environments
Canary release Let a fraction of users try the new version Large products

For a static site or small business, direct deploy plus keeping the previous build's directory is enough — one command flips back if anything goes wrong. For example, deploy builds into timestamped directories like releases/20260713/ and point a symlink at the current release:

ln -sfn /var/www/example.com/releases/20260713 /var/www/example.com/current

Rolling back is just pointing the symlink at the previous directory — one command.

Troubleshooting common problems

Problem Cause Fix
Works locally, fails in CI Inconsistent dependency versions Use npm ci to lock the lockfile and remove environment drift
502 after deploy Server process did not start Check process manager and logs; add a health check after deploy
Leaked secret Private key committed to the repo Revoke immediately, rotate it in Secrets, and scrub it from history
Slow builds No caching Configure actions/cache; hits can save the majority of build time

A Real-World Case

A three-person team runs a corporate website built with Nuxt; previously every release meant someone manually scp-ing files and restarting the process. After adopting GitHub Actions, every merged PR automatically runs lint, unit tests, and a build, and the output deploys straight to the server with rollback a single click away. Within six months the release cadence went from once a week to two or three times a day, while production incidents dropped from a couple per month to zero — because problems were caught before the merge, not left to one person's care.

16IDC Takeaway

CI/CD benefits may not be obvious on small projects, but once the flow exists, release confidence and quality both jump. Start with a simple GitHub Actions workflow, get "build + deploy" working first, then add tests, static analysis, and automated rollback. Don't over-engineer from day one.

Reference: GitHub Actions official docs https://docs.github.com/actions

Related: Git workflow guide, Docker deployment guide