CI/CD Pipelines in Practice: From “It Builds on My Laptop” to Automatic Tests + Safe Deploys (GitHub Actions)

CI/CD Pipelines in Practice: From “It Builds on My Laptop” to Automatic Tests + Safe Deploys (GitHub Actions)

If you’re a junior/mid web developer, CI/CD can feel like a pile of YAML and mystery. The goal is simpler: every change should be automatically verified (CI) and safely shipped (CD) with as little manual clicking as possible.

In this hands-on guide, you’ll build a practical GitHub Actions pipeline for a typical web app with:

  • Fast pull request checks (lint + unit tests)
  • Integration tests against a real database service
  • Build artifacts (or a Docker image) only when tests pass
  • Safe deployments with environment protection and rollbacks

Examples use Node.js, but the structure applies to Python, PHP, etc.

Project Setup (Example App)

Imagine a repository like this:

my-app/ src/ package.json package-lock.json test/ scripts/ .github/workflows/ 

Your package.json should expose common CI commands:

{ "name": "my-app", "private": true, "scripts": { "lint": "eslint .", "test": "vitest run", "test:integration": "node scripts/integration-test.js", "build": "vite build" } }

Key idea: CI should call your existing scripts, not reinvent your tooling.

Step 1: Pull Request Workflow (Fast Feedback)

Create .github/workflows/ci.yml. This runs on every PR and push to main. It installs dependencies, caches them, runs lint + unit tests, then builds.

name: CI on: pull_request: push: branches: [ main ] permissions: contents: read jobs: ci: 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 dependencies run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test - name: Build run: npm run build 

Why this works well:

  • npm ci is deterministic: it uses the lockfile and fails if it’s out of sync.
  • actions/setup-node caching reduces “install time tax”.
  • Short timeouts prevent hung jobs from burning minutes.

Step 2: Integration Tests with a Real Database Service

Unit tests are great, but they don’t prove your app can talk to the DB. GitHub Actions lets you run service containers (Postgres, MySQL, Redis) alongside your job.

Here’s an integration test job that boots Postgres, waits for it to be healthy, then runs a Node script.

jobs: integration: runs-on: ubuntu-latest timeout-minutes: 15 services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd="pg_isready -U app -d app_test" --health-interval=5s --health-timeout=3s --health-retries=20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: "npm" - run: npm ci - name: Run integration tests env: DATABASE_URL: postgresql://app:app@localhost:5432/app_test run: npm run test:integration 

Now add a minimal integration test script at scripts/integration-test.js (using pg):

import pg from "pg"; const { Client } = pg; async function main() { const url = process.env.DATABASE_URL; if (!url) throw new Error("DATABASE_URL is required"); const client = new Client({ connectionString: url }); await client.connect(); await client.query("CREATE TABLE IF NOT EXISTS healthcheck (id serial primary key, ok boolean not null)"); await client.query("INSERT INTO healthcheck (ok) VALUES ($1)", [true]); const res = await client.query("SELECT count(*)::int AS count FROM healthcheck WHERE ok = true"); const count = res.rows[0].count; await client.end(); if (count < 1) { throw new Error("Integration test failed: expected at least 1 row"); } console.log("Integration test passed ✅"); } main().catch((err) => { console.error(err); process.exit(1); }); 

Practical tip: keep integration tests small. You’re verifying wiring and real dependencies, not re-testing your whole app end-to-end.

Step 3: Stop Wasting Time — Use Concurrency and Job Dependencies

Two improvements that make pipelines feel “pro”:

  • Concurrency: cancel old runs when a new commit arrives on the same PR.
  • Dependencies: don’t deploy if tests fail.

Add concurrency at the workflow level:

concurrency: group: ci-${{ github.ref }} cancel-in-progress: true 

Then make a single pipeline with dependencies:

jobs: ci: # lint/unit/build integration: needs: ci # db integration tests 

This ensures integration tests only run after your fast checks succeed.

Step 4: CD — Deploy Only on Main, with Environment Protection

CD should be boring and safe. A good baseline:

  • Deploy only from main
  • Require that CI + integration succeed
  • Use GitHub Environments (manual approvals for production)

Create .github/workflows/deploy.yml:

name: Deploy on: push: branches: [ main ] permissions: contents: read jobs: test: uses: ./.github/workflows/ci.yml deploy_staging: runs-on: ubuntu-latest needs: test environment: staging steps: - uses: actions/checkout@v4 - name: Deploy to staging env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: | echo "Deploying to staging..." # Replace this with your real deploy command: # ./scripts/deploy.sh staging deploy_production: runs-on: ubuntu-latest needs: deploy_staging environment: production steps: - uses: actions/checkout@v4 - name: Deploy to production env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: | echo "Deploying to production..." # ./scripts/deploy.sh production 

Important: in your repo settings, create environments named staging and production. For production, add required reviewers. Now production deploys can be “merge to main, then approve” — no SSH-ing into servers at 2 a.m.

Step 5: A Realistic Deploy Script (Atomic-ish, with Basic Rollback)

Your deployment mechanism depends on where you run, but the pattern is similar: build versioned releases and switch a pointer.

Here’s a simple example script that copies build output into versioned folders and flips a symlink (works well on a VM with Nginx, for example). Save as scripts/deploy.sh:

#!/usr/bin/env bash set -euo pipefail ENVIRONMENT="${1:-staging}" APP_DIR="/var/www/my-app" RELEASES_DIR="$APP_DIR/releases" CURRENT_LINK="$APP_DIR/current" TS="$(date +%Y%m%d%H%M%S)" NEW_RELEASE="$RELEASES_DIR/$TS" echo "Deploying to $ENVIRONMENT..." echo "New release: $NEW_RELEASE" mkdir -p "$RELEASES_DIR" mkdir -p "$NEW_RELEASE" # Example: build on the runner and rsync output to server, or build on server. # Here we assume build output exists locally in dist/ if [ ! -d "dist" ]; then echo "dist/ not found. Run the build step before deploy." exit 1 fi cp -R dist/* "$NEW_RELEASE/" # Switch symlink ln -sfn "$NEW_RELEASE" "$CURRENT_LINK" # Keep last 5 releases cd "$RELEASES_DIR" ls -1dt */ | tail -n +6 | xargs -r rm -rf echo "Deploy complete ✅" 

In GitHub Actions, you’d typically run this over SSH (or via your platform’s CLI). The key concept is the same: each deploy is a “release” you can roll back to by repointing the symlink.

Step 6: Common CI/CD Footguns (and How to Avoid Them)

  • “It passed locally but fails in CI”: pin versions. Use lockfiles, explicit runtime versions (node-version: 20), and consistent scripts.
  • Slow pipelines: keep PR checks fast, push expensive tests to nightly builds, and use caching.
  • Secret leaks: never echo secrets. Avoid printing env vars. Use GitHub secrets and least-privilege tokens.
  • Flaky integration tests: use container health checks, avoid hard-coded sleeps, and ensure the DB is ready before testing.
  • Deploying broken code: make deploy jobs depend on tests (needs:) and lock production behind environment approvals.

What You Can Add Next (When You’re Ready)

Once this baseline is running, the next “real world” upgrades are:

  • Matrix testing (multiple Node versions, multiple DB versions)
  • Preview deployments per PR (unique URL for reviewers)
  • Migrations as a controlled step (with locks to avoid running twice)
  • Observability hooks (post-deploy smoke test + alerting on failure)

If you implement just what’s in this article, you’ll already have the core of a professional CI/CD pipeline: fast PR feedback, real dependency checks, and guarded deployments that are repeatable and less stressful than manual releases.


Leave a Reply

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