CI/CD Pipelines in Practice: GitHub Actions for a Web App (Tests → Build → Deploy)

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 main to 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 ci is deterministic (uses your lockfile). It’s ideal for CI.
  • Matrix testing (node 18 + 20) catches version issues early.
  • --if-present prevents 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 git and node installed
  • 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 current symlink 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-node cache) 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 echo secrets; keep debug output clean.
  • “Works locally” config drift: pin Node versions and use npm ci everywhere.

Where to Go Next

Once this pipeline is in place, you can level it up with:

  • Environment-specific deploys (staging vs production)
  • 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

Your email address will not be published. Required fields are marked *