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_HOST→203.0.113.10STAGING_SSH_USER→deploySTAGING_WEB_ROOT→/var/www/myappSTAGING_API_ROOT→/srv/myapp-apiSTAGING_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-nodeand match.nvmrcorengines.node. - Flaky tests: treat flakes as a bug. Add better waits/mocks, eliminate shared state, avoid time-based assertions.
- Secrets leaked to logs: never
echosecrets. 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