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
mainbuilds 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_requestensures 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_PATas 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 runwith 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
productionenvironment secrets. - Add a health check: After deployment, curl
/healthand 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
mainand 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