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