CI/CD Pipelines in Practice: Build, Test, and Deploy with GitHub Actions (No Drama)

CI/CD Pipelines in Practice: Build, Test, and Deploy with GitHub Actions (No Drama)

CI/CD (Continuous Integration / Continuous Delivery) is just a fancy way of saying: “Every change gets checked automatically, and safe changes get shipped automatically.” For junior/mid developers, the biggest win is confidence: your pipeline becomes a guardrail that catches broken builds, failing tests, style issues, and deployment mistakes before they reach users.

This hands-on guide walks you through a practical GitHub Actions pipeline that:

  • Runs on every pull request and on pushes to main
  • Installs dependencies with caching
  • Runs lint + tests
  • Builds your app
  • Deploys to a simple Linux server via SSH (only on main)

The examples below assume a typical repo with a backend and frontend:

your-repo/ api/ # Node.js API (or any backend) web/ # frontend (React/Vite/etc.) .github/ workflows/ ci-cd.yml 

1) Start with “CI” before “CD”

A common mistake is rushing into auto-deploy. Instead, make sure your “CI” (checks) are solid first. The minimum useful set:

  • Install dependencies
  • Lint (style + common errors)
  • Test (unit/integration)
  • Build (ensures production build works)

Once those pass reliably, you can add “CD” (deploy) as a separate job that runs only when CI succeeds on main.

2) Add scripts your pipeline can run locally

CI is easiest when it runs the same commands developers run locally. In api/package.json, add scripts like these:

{ "name": "api", "private": true, "type": "module", "scripts": { "lint": "eslint .", "test": "node --test", "start": "node src/server.js" }, "devDependencies": { "eslint": "^9.0.0" } } 

For a minimal API you can actually test, here’s api/src/server.js:

import http from "node:http"; export function handler(req, res) { if (req.url === "/health") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not_found" })); } if (process.env.NODE_ENV !== "test") { http.createServer(handler).listen(3000, () => { console.log("API listening on :3000"); }); } 

And a simple Node test using the built-in runner (api/test/health.test.js):

import test from "node:test"; import assert from "node:assert/strict"; import { handler } from "../src/server.js"; function mockRes() { const res = { statusCode: 0, headers: {}, body: "", writeHead(code, headers) { this.statusCode = code; this.headers = headers; }, end(chunk) { this.body += chunk || ""; } }; return res; } test("GET /health returns ok", async () => { const req = { url: "/health" }; const res = mockRes(); handler(req, res); assert.equal(res.statusCode, 200); const data = JSON.parse(res.body); assert.deepEqual(data, { ok: true }); }); 

For the frontend (web), the scripts typically look like:

{ "name": "web", "private": true, "scripts": { "lint": "eslint .", "test": "vitest run", "build": "vite build" } } 

3) Create the GitHub Actions workflow

Create .github/workflows/ci-cd.yml:

name: CI/CD on: pull_request: push: branches: [ "main" ] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: ci: runs-on: ubuntu-latest strategy: matrix: node-version: [ "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: | api/package-lock.json web/package-lock.json - name: Install API deps working-directory: api run: npm ci - name: Lint API working-directory: api run: npm run lint - name: Test API working-directory: api env: NODE_ENV: test run: npm test - name: Install Web deps working-directory: web run: npm ci - name: Lint Web working-directory: web run: npm run lint - name: Build Web working-directory: web run: npm run build - name: Upload web build artifact uses: actions/upload-artifact@v4 with: name: web-dist path: web/dist deploy: needs: ci runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 - name: Download web build artifact uses: actions/download-artifact@v4 with: name: web-dist path: web/dist - name: Deploy via SSH (rsync) uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} source: "api/,web/dist" target: "/var/www/myapp" strip_components: 0 - name: Restart service uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /var/www/myapp/api npm ci --omit=dev sudo systemctl restart myapp-api 

What you just built:

  • ci runs on PRs and main pushes, and fails fast if lint/tests/build break.
  • deploy runs only after ci passes and only on main.
  • The frontend build is saved as an artifact and reused in deploy (so you deploy exactly what CI built).

4) Configure the server side (simple systemd service)

On your Linux server, create a systemd service so “restart” is reliable. Example: /etc/systemd/system/myapp-api.service

[Unit] Description=MyApp API After=network.target [Service] Type=simple WorkingDirectory=/var/www/myapp/api Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=/usr/bin/node src/server.js Restart=always User=www-data Group=www-data [Install] WantedBy=multi-user.target 

Enable it once:

sudo systemctl daemon-reload sudo systemctl enable myapp-api sudo systemctl start myapp-api 

5) Add GitHub Secrets correctly

In your GitHub repo settings, add these secrets:

  • DEPLOY_HOST (e.g. 203.0.113.10)
  • DEPLOY_USER (e.g. deploy)
  • DEPLOY_SSH_KEY (private key that matches a public key in ~deploy/.ssh/authorized_keys)

Practical tips:

  • Use a dedicated deploy user with limited permissions.
  • Prefer a key without a passphrase for CI (or use an action that supports passphrases securely).
  • Never echo secrets in logs. GitHub masks known secrets, but avoid printing them anyway.

6) Common pipeline upgrades (worth doing)

  • Run on PR, deploy on merge: already done using pull_request + main condition.
  • Add a “format check” step: e.g. prettier --check . to reduce style debates.
  • Split jobs for speed: separate api and web jobs that run in parallel.
  • Add environment protection: require manual approval for production deploys using GitHub Environments.
  • Smoke check after deploy: hit /health to confirm the service is up.

Example smoke check step (add to the end of deploy):

- name: Smoke check run: | curl -fsS http://${{ secrets.DEPLOY_HOST }}:3000/health | grep '"ok":true' 

7) Debugging: when CI fails, make it obvious

When a job fails, your goal is to reduce guesswork:

  • Keep steps small and named clearly (Lint API, Test API).
  • Make sure your local scripts match CI exactly (npm run lint, npm test).
  • Pin major versions (node-version: "20") to avoid random breakage.
  • Use artifacts for outputs that matter (builds, test reports, coverage).

Wrap-up: a pipeline juniors can trust

You now have a CI/CD setup that’s realistic for production work: it checks every change, blocks broken code from merging, and deploys only when the main branch is green. The best part is cultural: once the pipeline is stable, your team stops arguing about “works on my machine” and starts shipping with confidence.

If you want a next step, add one improvement: parallel jobs (API + Web) and a post-deploy smoke test. Those two changes usually cut build time and catch “deployed but broken” incidents early.


Leave a Reply

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