CI/CD Pipelines in Practice: GitHub Actions for a Web App (Tests → Build → Deploy)
CI/CD (Continuous Integration / Continuous Delivery) turns “works on my machine” into a repeatable process: every push runs tests, builds artifacts, and (optionally) deploys the result. If you’re a junior/mid developer, the biggest win is confidence: you merge with proof, and deployments become boring.
In this hands-on guide, you’ll build a practical GitHub Actions pipeline for a typical web project:
- CI: install dependencies, run tests + lint, and build
- CD: deploy on
mainto a server over SSH, with a simple rollback strategy
The examples below are “drop-in” patterns you can adapt whether you’re deploying a Node app, a static frontend, or a monorepo.
Project Layout (Example)
Let’s assume a basic full-stack layout:
my-app/ frontend/ # React/Vite/Next/etc. backend/ # Node/Express (or any backend) .github/workflows/
If your app is only frontend or only backend, keep the relevant parts and delete the rest.
Step 1: Add a CI Workflow (Tests + Lint + Build)
Create .github/workflows/ci.yml:
name: CI on: pull_request: push: branches: [ "main" ] jobs: test-and-build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" cache-dependency-path: | frontend/package-lock.json backend/package-lock.json # Frontend - name: Install frontend deps working-directory: frontend run: npm ci - name: Frontend lint working-directory: frontend run: npm run lint --if-present - name: Frontend tests working-directory: frontend run: npm test --if-present - name: Frontend build working-directory: frontend run: npm run build # Backend - name: Install backend deps working-directory: backend run: npm ci - name: Backend lint working-directory: backend run: npm run lint --if-present - name: Backend tests working-directory: backend run: npm test --if-present - name: Backend build working-directory: backend run: npm run build --if-present
Why this works well:
npm ciis deterministic (uses your lockfile). It’s ideal for CI.- Matrix testing (
node 18+20) catches version issues early. --if-presentprevents failures if a script doesn’t exist (useful while your project evolves).
Step 2: Upload Build Artifacts (Optional but Useful)
If your frontend produces static output (e.g., Vite builds to frontend/dist), you can store it as an artifact. This is handy for debugging and for deploy workflows that download artifacts instead of rebuilding.
Add to the end of the CI job:
- name: Upload frontend build artifact uses: actions/upload-artifact@v4 with: name: frontend-dist path: frontend/dist
Tip: artifacts also help when you want “build once, deploy many times” across environments.
Step 3: Add a CD Workflow (Deploy on Main)
Now create .github/workflows/deploy.yml. This workflow deploys on pushes to main. It uses SSH to run a small deploy script on your server (VPS). This is a common, practical setup for teams that aren’t using Kubernetes.
Server assumptions:
- You have a Linux server with
gitandnodeinstalled - Your app lives in
/var/www/my-app - You run the backend with
systemd(or swap it for PM2/Docker if you prefer)
name: Deploy on: push: branches: [ "main" ] concurrency: group: deploy-main cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout (for scripts, version info) uses: actions/checkout@v4 - name: Deploy via SSH uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} script: | set -euo pipefail APP_DIR="/var/www/my-app" RELEASES_DIR="$APP_DIR/releases" CURRENT_LINK="$APP_DIR/current" mkdir -p "$RELEASES_DIR" TS="$(date +%Y%m%d%H%M%S)" NEW_RELEASE="$RELEASES_DIR/$TS" echo "Creating new release: $NEW_RELEASE" git clone --depth 1 https://github.com/${{ github.repository }}.git "$NEW_RELEASE" cd "$NEW_RELEASE" git checkout ${{ github.sha }} echo "Installing backend deps..." cd backend npm ci npm run build --if-present echo "Building frontend..." cd ../frontend npm ci npm run build echo "Updating symlink..." ln -sfn "$NEW_RELEASE" "$CURRENT_LINK" echo "Restarting services..." sudo systemctl restart my-app-backend echo "Pruning old releases..." ls -1dt "$RELEASES_DIR"/* | tail -n +6 | xargs -r rm -rf echo "Deploy complete."
What this deploy does:
- Creates a timestamped release folder
- Checks out the exact commit that triggered the workflow (
${{ github.sha }}) - Builds backend + frontend on the server
- Switches a
currentsymlink atomically - Restarts the backend service
- Keeps only the last 5 releases (simple rollback safety net)
Step 4: Configure Secrets (Securely)
In your GitHub repo: Settings → Secrets and variables → Actions, add:
DEPLOY_HOST(e.g.203.0.113.10)DEPLOY_USER(e.g.deploy)DEPLOY_PORT(e.g.22)DEPLOY_SSH_KEY(private key for the deploy user)
Security notes: use a dedicated deploy user, limit SSH keys, and consider restricting the key to certain commands or IPs if your setup allows it.
Step 5: Set Up a systemd Service (Backend)
On your server, create /etc/systemd/system/my-app-backend.service:
[Unit] Description=My App Backend After=network.target [Service] Type=simple User=deploy WorkingDirectory=/var/www/my-app/current/backend Environment=NODE_ENV=production ExecStart=/usr/bin/node server.js Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
Then enable it:
sudo systemctl daemon-reload sudo systemctl enable my-app-backend sudo systemctl start my-app-backend
If your entry file isn’t server.js, update ExecStart.
Step 6: Add a Minimal Rollback Command
Because releases are timestamped and current is just a symlink, rollback is fast. SSH into the server and run:
cd /var/www/my-app/releases ls -1dt * | head # pick a previous timestamp folder, then: sudo ln -sfn "/var/www/my-app/releases/20260329123000" "/var/www/my-app/current" sudo systemctl restart my-app-backend
This is why “symlink releases” is such a popular pattern: it’s simple and reliable.
Common CI/CD Pitfalls (And Fixes)
- Slow builds: use dependency caching (already done via
actions/setup-nodecache) and avoid rebuilding twice (consider artifacts if needed). - Flaky tests: stabilize test data, mock network calls, and run tests in isolated environments.
- Deploys overlap: use
concurrency(we did) so a second push cancels the previous deploy. - Secrets leaked in logs: never
echosecrets; keep debug output clean. - “Works locally” config drift: pin Node versions and use
npm cieverywhere.
Where to Go Next
Once this pipeline is in place, you can level it up with:
- Environment-specific deploys (
stagingvsproduction) - Database migrations during deploy (carefully, with backups)
- Preview deployments for pull requests
- Notifications (Slack/Teams) on failures
But even without those upgrades, this setup gets you a real CI/CD loop: every change is tested, every merge is deployable, and deployments are consistent. That’s the heart of professional web delivery.
Leave a Reply