CI/CD Pipelines in Practice: Build a Safe, Fast GitHub Actions Pipeline for a Node + Docker App

CI/CD Pipelines in Practice: Build a Safe, Fast GitHub Actions Pipeline for a Node + Docker App

CI/CD can feel like a maze of YAML until you build one that actually helps you ship: tests run automatically, Docker images are reproducible, deployments are gated, and rollbacks are sane. In this hands-on guide, you’ll set up a practical GitHub Actions pipeline for a small Node.js app packaged with Docker. You’ll add caching, run unit tests, build and push an image, and deploy only when checks pass.

This approach works great for junior/mid developers because it focuses on a few core outcomes:

  • Every push runs tests and linting automatically.
  • Every merge to main builds a versioned Docker image.
  • Deploys are controlled (manual approval or environment protection).
  • Secrets are handled safely (no credentials in the repo).

Project Setup: A Minimal Node App with Tests and a Dockerfile

Start with a tiny Express app so the pipeline has something real to validate.

// package.json { "name": "ci-cd-demo", "version": "1.0.0", "type": "module", "scripts": { "start": "node src/server.js", "test": "node --test", "lint": "node scripts/lint.js" }, "dependencies": { "express": "^4.19.2" } } 
// src/server.js import express from "express"; const app = express(); app.get("/health", (req, res) => res.json({ ok: true })); app.get("/", (req, res) => res.send("Hello CI/CD")); const port = process.env.PORT || 3000; app.listen(port, () => console.log(`Listening on ${port}`)); 

Add a simple test using Node’s built-in test runner (no extra dependency required).

// test/basic.test.js import test from "node:test"; import assert from "node:assert/strict"; test("math still works", () => { assert.equal(2 + 2, 4); }); 

And a “lint” script to demonstrate running multiple checks. This example is intentionally basic—replace it with ESLint later.

// scripts/lint.js import fs from "node:fs"; const files = ["src/server.js"]; for (const f of files) { const content = fs.readFileSync(f, "utf8"); if (content.includes("var ")) { console.error(`Lint failed: avoid 'var' in ${f}`); process.exit(1); } } console.log("Lint OK"); 

Now add a production-friendly Dockerfile with a multi-stage build. Even if your app is small, this pattern scales well.

# Dockerfile FROM node:20-alpine AS base WORKDIR /app FROM base AS deps COPY package.json package-lock.json* ./ RUN npm ci --omit=dev FROM base AS runner ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY src ./src EXPOSE 3000 CMD ["node", "src/server.js"] 

Step 1: A CI Workflow That Runs Fast (Tests + Lint + Cache)

Create .github/workflows/ci.yml. This workflow runs on every push and PR. It uses built-in caching for npm to speed up installs.

# .github/workflows/ci.yml name: CI on: push: branches: ["**"] pull_request: jobs: 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 deps run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test 

Why this matters:

  • pull_request ensures checks run before merging.
  • cache: "npm" improves speed without custom cache keys.
  • Separate steps make failures easy to diagnose.

Step 2: Build and Push a Docker Image (Only on main)

Next, create a release workflow that builds a Docker image and pushes it to a registry. A common choice is GitHub Container Registry (ghcr.io), because it integrates cleanly with GitHub permissions.

Create .github/workflows/release.yml.

# .github/workflows/release.yml name: Release on: push: branches: ["main"] permissions: contents: read packages: write jobs: build-and-push: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=sha type=raw,value=latest - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max 

What you get here:

  • Images tagged by commit SHA (great for rollbacks) and latest (easy default).
  • Build caching via GitHub Actions cache backend (type=gha), so subsequent builds are faster.
  • Registry login uses GITHUB_TOKEN, so you don’t store long-lived credentials.

Step 3: Add a Deployment Job with Environment Protection

“CD” can mean many things, but a safe baseline is: build an immutable image, then deploy it after approval. GitHub Environments give you guardrails (required reviewers, wait timers, scoped secrets).

In your GitHub repo settings:

  • Create an Environment named production.
  • Enable “Required reviewers” (optional but recommended).
  • Add secrets like PROD_HOST, PROD_USER, PROD_SSH_KEY.

Then extend release.yml with a deploy job that runs after the image push. This example deploys via SSH and Docker on a single VM (a very common setup for small teams).

# Add this job under build-and-push in .github/workflows/release.yml deploy: needs: build-and-push runs-on: ubuntu-latest environment: production timeout-minutes: 15 steps: - name: Deploy over SSH uses: appleboy/[email protected] with: host: ${{ secrets.PROD_HOST }} username: ${{ secrets.PROD_USER }} key: ${{ secrets.PROD_SSH_KEY }} script: | set -euo pipefail IMAGE="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}" echo "Logging into GHCR..." echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin echo "Pulling $IMAGE" docker pull "$IMAGE" echo "Stopping old container (if any)" docker stop app || true docker rm app || true echo "Starting new container" docker run -d --name app \ -p 80:3000 \ --restart unless-stopped \ "$IMAGE" echo "Cleaning up dangling images" docker image prune -f 

Notes:

  • Using sha-${{ github.sha }} ties deployments to a specific commit.
  • You can store GHCR_PAT as an environment secret (a fine-grained PAT with package read access). For private images, your server needs auth to pull.
  • Environment protection can force manual approval before this job runs.

Step 4: Make Rollbacks Boring (That’s Good)

A rollback should be: “deploy the previous SHA-tagged image.” Because you publish immutable tags, you can redeploy any prior version.

Two easy rollback options:

  • Re-run workflow: Use GitHub Actions UI to re-run a previous workflow run from the commit you want.
  • Manual deploy command: SSH to the server and run docker run with an older image tag.

If you want a more formal approach, create a separate manual workflow using workflow_dispatch and an input for the image tag:

# .github/workflows/deploy.yml name: Deploy (manual) on: workflow_dispatch: inputs: image_tag: description: "Docker tag to deploy (e.g. sha-abc123...)" required: true jobs: deploy: runs-on: ubuntu-latest environment: production steps: - name: Deploy over SSH uses: appleboy/[email protected] with: host: ${{ secrets.PROD_HOST }} username: ${{ secrets.PROD_USER }} key: ${{ secrets.PROD_SSH_KEY }} script: | set -euo pipefail IMAGE="ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }}" docker pull "$IMAGE" docker stop app || true docker rm app || true docker run -d --name app -p 80:3000 --restart unless-stopped "$IMAGE" 

Step 5: Practical Hardening Tips (Worth Doing Early)

  • Fail fast: Keep CI checks (lint/tests) separate from build steps so feedback is immediate.
  • Pin major action versions: You used @v4, @v6, etc. That’s a good balance for most teams.
  • Don’t bake secrets into images: Pass env vars at runtime (Docker -e, compose files, or secret managers).
  • Use environment-scoped secrets: Put prod credentials only in production environment secrets.
  • Add a health check: After deployment, curl /health and fail if it’s not OK.

Example post-deploy health check (append to your deploy script):

echo "Health check..." curl -fsS http://localhost/health | grep -q '"ok":true' echo "Deployment healthy." 

What You Built

You now have a clean, practical pipeline:

  • CI runs tests and lint on every PR/push.
  • Release builds a Docker image on main and pushes it to a registry.
  • Deploy uses environment protection and deploys immutable, SHA-tagged images.
  • Rollbacks are simple because every build is traceable and reproducible.

Once this is working, the next “level up” is swapping the SSH deploy for a platform-native deploy (Kubernetes, ECS, Cloud Run, Fly.io, etc.). But even then, the core principles stay the same: validate early, build immutable artifacts, deploy with gates, and keep rollback paths easy.


Leave a Reply

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