CI/CD Pipelines in Practice: Ship Every Merge with GitHub Actions (Tests, Builds, and Safe Deploys)

CI/CD Pipelines in Practice: Ship Every Merge with GitHub Actions (Tests, Builds, and Safe Deploys)

If you’ve ever merged a “small change” and accidentally broke production, you already understand why CI/CD matters. A good pipeline makes deployments boring: every push runs checks automatically, every merge produces a deployable artifact, and releases happen the same way every time.

This hands-on guide walks you through a practical CI/CD setup using GitHub Actions for a typical web app. You’ll build a pipeline that:

  • Runs linting and unit tests on every pull request
  • Builds the app only if checks pass
  • Uploads a build artifact
  • Deploys to a server via SSH when you merge to main
  • Uses caching + concurrency to keep it fast and safe

Examples below assume a Node.js web app, but the workflow patterns apply to any stack.

1) What “good” looks like (pipeline goals)

Before writing YAML, define what the pipeline should guarantee:

  • Quality gate: no deploy unless lint + tests pass
  • Repeatable builds: dependencies are pinned (lockfile) and builds run in a clean environment
  • Fast feedback: caching and parallel jobs reduce waiting
  • Safe releases: only one deploy per environment at a time, and deployments can be rolled back
  • Least privilege: secrets are stored in GitHub, not in the repo

Now let’s implement this in .github/workflows/ci-cd.yml.

2) The workflow: CI on PRs, CD on main

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

name: CI/CD on: pull_request: branches: [ "main" ] push: branches: [ "main" ] workflow_dispatch: {} concurrency: group: deploy-main cancel-in-progress: false jobs: ci: name: Lint + Test runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test -- --ci build: name: Build artifact runs-on: ubuntu-latest needs: [ ci ] timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install run: npm ci - name: Build run: npm run build - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: web-build path: | dist build if-no-files-found: error deploy: name: Deploy to server (main only) runs-on: ubuntu-latest needs: [ build ] if: github.ref == 'refs/heads/main' environment: name: production timeout-minutes: 15 steps: - name: Download build artifact uses: actions/download-artifact@v4 with: name: web-build path: artifact - name: Add SSH key uses: webfactory/[email protected] with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Deploy via rsync + restart env: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} SSH_PORT: ${{ secrets.SSH_PORT }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} run: | set -euo pipefail echo "Deploying to $SSH_USER@$SSH_HOST:$DEPLOY_PATH" rsync -az --delete -e "ssh -p ${SSH_PORT}" artifact/ ${SSH_USER}@${SSH_HOST}:${DEPLOY_PATH}/ ssh -p ${SSH_PORT} ${SSH_USER}@${SSH_HOST} "\ cd ${DEPLOY_PATH} && \ ./restart.sh \ "

This file does three jobs:

  • ci runs on PRs and pushes: lint + unit tests
  • build produces a build artifact, but only after CI passes
  • deploy runs only on main, pulls the artifact, and deploys it

Why artifacts? You deploy the exact output of the build job, not “whatever the server builds.” That makes deployments consistent and easier to debug.

3) Add the npm scripts (so the pipeline actually runs)

In your package.json, ensure you have these scripts (adapt to your toolchain):

{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "vite build" } }

If you’re using Jest instead of Vitest or a framework like Next.js, just update the commands. The pipeline doesn’t care—as long as exit codes are correct (0 = success).

4) Deployment script on the server: restart safely

The workflow expects a restart.sh on the server. Keep it simple and predictable. Example for a static site served by Nginx (copy into $DEPLOY_PATH/restart.sh):

#!/usr/bin/env bash set -euo pipefail # Example: static site deployed to /var/www/myapp # Ensure permissions allow the deploy user to write here. # Validate output exists if [ ! -d "dist" ] && [ ! -d "build" ]; then echo "No dist/ or build/ directory found. Aborting." exit 1 fi # If you're using Nginx to serve static content, you usually don't need a restart. # But you can reload to pick up config changes safely: sudo nginx -t sudo systemctl reload nginx echo "Restart complete."

Make it executable:

chmod +x restart.sh

If you deploy a Node app behind systemd, your restart.sh might run npm ci (if you deploy source) or just restart a service. For example:

sudo systemctl restart myapp.service

Tip: the more your deploy step does, the more it can fail. Prefer deploying a pre-built artifact whenever possible.

5) Secrets and environments (don’t leak credentials)

Add these repository secrets in GitHub:

  • SSH_PRIVATE_KEY (a deploy key)
  • SSH_HOST (e.g., 203.0.113.10)
  • SSH_USER (e.g., deploy)
  • SSH_PORT (e.g., 22)
  • DEPLOY_PATH (e.g., /var/www/myapp)

Then create a GitHub Environment called production and (optionally) require approvals. That’s a nice step up from “every merge deploys instantly” when your team is growing.

6) Make it faster: caching and smarter triggers

This workflow already uses dependency caching via actions/setup-node with cache: "npm". A few more practical improvements:

  • Only run on changed paths (monorepos): limit triggers with paths:
  • Parallelize checks: split lint and test into separate jobs if they’re slow
  • Cancel redundant CI on PR updates: use concurrency per PR (separate from deploy)

Example: cancel older CI runs for the same PR (add to the top-level workflow):

concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true

For deployments, you often want the opposite: don’t cancel in-progress deploys mid-flight. That’s why many teams use one concurrency group for CI and another for deploy.

7) Add a rollback plan (minimum viable)

Even juniors should think about rollback. A basic approach is versioned releases on the server:

  • Deploy into /var/www/myapp/releases/<timestamp>
  • Point /var/www/myapp/current symlink to the active release
  • Rollback = repoint symlink to previous release

Here’s a server-side restart.sh sketch using symlinks (works great for static builds):

#!/usr/bin/env bash set -euo pipefail RELEASES_DIR="/var/www/myapp/releases" CURRENT_LINK="/var/www/myapp/current" TS="$(date +%Y%m%d%H%M%S)" NEW_RELEASE="${RELEASES_DIR}/${TS}" mkdir -p "${RELEASES_DIR}" mkdir -p "${NEW_RELEASE}" # Assume the artifact was rsynced into /var/www/myapp/tmp-artifact rsync -a --delete "/var/www/myapp/tmp-artifact/" "${NEW_RELEASE}/" ln -sfn "${NEW_RELEASE}" "${CURRENT_LINK}" sudo nginx -t sudo systemctl reload nginx echo "Deployed ${TS}"

This turns “oops” moments into a 10-second fix.

8) Common pitfalls (and how to avoid them)

  • “Works on my machine” builds: use npm ci and commit your lockfile. Avoid npm install in CI.
  • Flaky tests: isolate external dependencies; mock HTTP; don’t depend on real time.
  • Secret leakage: never echo secrets; avoid printing environment variables; keep logs clean.
  • Deploying from PRs: don’t deploy untrusted code with secrets (fork PRs are especially risky).
  • Silent failures: use set -euo pipefail in bash steps so failures actually fail.

Wrap-up: your first “real” pipeline

With a single workflow file, you now have a practical CI/CD baseline:

  • Every PR is checked automatically
  • Merges to main produce a build artifact
  • Production deploys use SSH + rsync and can be guarded with environments
  • Concurrency and caching keep it efficient

Next upgrades (when you’re ready): add integration tests, deploy to a staging environment first, and introduce release tagging so you can trace exactly what’s running in production.


Leave a Reply

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