CI/CD Pipelines with GitHub Actions: From “It Works on My Machine” to Repeatable Deploys

CI/CD Pipelines with GitHub Actions: From “It Works on My Machine” to Repeatable Deploys

CI/CD (Continuous Integration / Continuous Delivery) is how you stop shipping by ritual and start shipping by system. For junior/mid web developers, the goal isn’t “fancy DevOps,” it’s simple: every push should run the same checks, build the same artifacts, and (optionally) deploy the same way—without you remembering steps.

This hands-on guide shows a practical GitHub Actions pipeline for a typical web app:

  • Frontend: Node.js (React/Vue/Next—same idea)
  • Backend: Node.js API (Express/Fastify—same idea)
  • Deploy: copy artifacts to a server over SSH (simple and common)

You can adapt this to other stacks easily. The important parts are: triggers, caching, testing, build artifacts, environments, and secrets.

Repo Structure (Example)

Assume a monorepo layout like:

my-app/ frontend/ package.json src/... backend/ package.json src/... .github/ workflows/ ci.yml deploy.yml 

If you have a single package.json, you’ll simplify the workflow paths—same concepts apply.

Step 1: A Solid CI Workflow (Lint + Test + Build)

Continuous Integration should run on every PR and push to main branches. Here’s a practical .github/workflows/ci.yml that:

  • checks out code
  • sets up Node
  • caches dependencies
  • runs lint + tests
  • builds frontend and backend
name: CI on: pull_request: push: branches: [ "main" ] jobs: ci: runs-on: ubuntu-latest strategy: matrix: node: [18] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: "npm" # Frontend - name: Install frontend deps working-directory: frontend run: npm ci - name: Lint frontend working-directory: frontend run: npm run lint - name: Test frontend working-directory: frontend run: npm test -- --ci - name: Build frontend working-directory: frontend run: npm run build # Backend - name: Install backend deps working-directory: backend run: npm ci - name: Lint backend working-directory: backend run: npm run lint - name: Test backend working-directory: backend run: npm test -- --ci - name: Build backend working-directory: backend run: npm run build 

Why npm ci? It installs exactly what’s in package-lock.json, which keeps CI reproducible.

Why cache? actions/setup-node with cache: "npm" speeds up installs across runs.

Make the Scripts Real

Your CI is only as good as the scripts it calls. Here’s a minimal set you can add to both frontend/package.json and backend/package.json:

{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "tsc -p tsconfig.json" } } 

If you’re using Jest, swap vitest run for jest. If your frontend uses Vite/Next, your build may be vite build or next build. The workflow stays the same.

Step 2: Upload Build Artifacts (So Deploy Jobs Don’t Rebuild)

A common beginner mistake is rebuilding in every job (or worse: rebuilding locally and uploading by hand). Instead, build once in CI and publish artifacts for later steps.

Add these steps to the end of the CI job:

 - name: Upload frontend build uses: actions/upload-artifact@v4 with: name: frontend-dist path: frontend/dist - name: Upload backend build uses: actions/upload-artifact@v4 with: name: backend-dist path: backend/dist 

Now you can create a separate deploy workflow that downloads these artifacts.

Step 3: A Deploy Workflow (Staging on Push to main)

This deploy example targets a simple Linux VM with SSH access. It does:

  • download artifacts from CI
  • copy them to the server via scp
  • restart the backend service

Create .github/workflows/deploy.yml:

name: Deploy (Staging) on: workflow_run: workflows: ["CI"] types: [completed] jobs: deploy: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} runs-on: ubuntu-latest environment: staging steps: - name: Download frontend artifact uses: actions/download-artifact@v4 with: name: frontend-dist path: artifacts/frontend - name: Download backend artifact uses: actions/download-artifact@v4 with: name: backend-dist path: artifacts/backend - name: Add SSH key uses: webfactory/[email protected] with: ssh-private-key: ${{ secrets.STAGING_SSH_KEY }} - name: Copy frontend to server run: | scp -o StrictHostKeyChecking=no -r artifacts/frontend/* \ ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }}:${{ secrets.STAGING_WEB_ROOT }} - name: Copy backend to server run: | scp -o StrictHostKeyChecking=no -r artifacts/backend/* \ ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }}:${{ secrets.STAGING_API_ROOT }} - name: Restart backend service run: | ssh -o StrictHostKeyChecking=no \ ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }} \ "sudo systemctl restart myapp-api" 

Where do those paths come from? You define them as repository secrets (or environment secrets). For example:

  • STAGING_HOST203.0.113.10
  • STAGING_SSH_USERdeploy
  • STAGING_WEB_ROOT/var/www/myapp
  • STAGING_API_ROOT/srv/myapp-api
  • STAGING_SSH_KEY → private key with access to the server

In GitHub: Settings → Secrets and variables → Actions. If you use environment: staging, you can store environment-level secrets and add approval rules later.

Step 4: Make Deploy Safe-ish (Atomic Frontend + Health Check)

Copying files directly into a live directory can cause partial updates. A simple improvement is to copy into a timestamped folder and swap a symlink.

Replace the “Copy frontend” step with this pattern:

 - name: Deploy frontend atomically run: | RELEASE_DIR="myapp-$(date +%Y%m%d%H%M%S)" ssh -o StrictHostKeyChecking=no ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }} \ "mkdir -p /var/www/releases/$RELEASE_DIR" scp -o StrictHostKeyChecking=no -r artifacts/frontend/* \ ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }}:/var/www/releases/$RELEASE_DIR/ ssh -o StrictHostKeyChecking=no ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }} \ "ln -sfn /var/www/releases/$RELEASE_DIR /var/www/myapp_current" 

Your web server (Nginx/Apache) should serve /var/www/myapp_current. This makes updates effectively “swap in one move.”

Add a quick backend health check after restarting:

 - name: Health check run: | curl -fsS https://staging.example.com/api/health 

Make sure your backend exposes /api/health:

// backend/src/health.ts (Express example) import type { Request, Response } from "express"; export function health(_req: Request, res: Response) { res.json({ ok: true, time: new Date().toISOString() }); } 

Step 5: Add “PR Preview” Deploys (Optional, Very Useful)

Preview deployments are gold for teams: each PR gets a live URL. The simplest version deploys only the frontend to a unique folder named by PR number.

Trigger on PRs and deploy to /var/www/previews/pr-123:

name: PR Preview (Frontend) on: pull_request: types: [opened, synchronize, reopened] jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 cache: "npm" - name: Build frontend working-directory: frontend run: | npm ci npm run build - name: Add SSH key uses: webfactory/[email protected] with: ssh-private-key: ${{ secrets.STAGING_SSH_KEY }} - name: Deploy preview env: PR: ${{ github.event.pull_request.number }} run: | ssh -o StrictHostKeyChecking=no ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }} \ "mkdir -p /var/www/previews/pr-$PR" scp -o StrictHostKeyChecking=no -r frontend/dist/* \ ${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_HOST }}:/var/www/previews/pr-$PR/ 

Then configure your web server to serve previews under something like https://staging.example.com/previews/pr-123/.

Common CI/CD Mistakes (And Fixes)

  • “CI passes locally but fails in Actions”: lock Node versions. Use actions/setup-node and match .nvmrc or engines.node.
  • Flaky tests: treat flakes as a bug. Add better waits/mocks, eliminate shared state, avoid time-based assertions.
  • Secrets leaked to logs: never echo secrets. Use GitHub Secrets and keep scripts quiet.
  • One giant workflow doing everything: separate CI from deploy. Build once, deploy from artifacts.
  • Deploying on every branch: deploy previews for PRs, deploy staging on main, and require approvals for production via GitHub Environments.

What You Have Now

With the workflows above, you get:

  • automatic lint/test/build on every PR
  • a staging deploy only when CI succeeds on main
  • optional PR previews to review UI changes quickly
  • a clean upgrade path to production approvals and more environments

If you want one next upgrade: add a production environment in GitHub, require manual approval, and reuse the deploy job with production secrets. That single change prevents “oops deployed to prod” days—without making your pipeline complicated.


Leave a Reply

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