NodeJSCI/CD Pipelines

CI/CD Pipelines

Continuous Integration / Continuous Deployment automates the path from a code change to a running deployment. CI runs your checks (tests, lint, type-check, security audit) on every push or pull request to catch problems fast and block bad code from merging. CD takes every successful build on the main branch and deploys it, so the gap between "merged" and "in production" is minutes rather than days. Together they make deployments low-risk, frequent, and boring — which is exactly what you want. This page covers a GitHub Actions workflow for a Node app: the CI check pipeline, building and pushing a Docker image, deploying, and the patterns that make pipelines reliable.

A CI pipeline for Node

.github/workflows/ci.yml

YAML
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'           # caches node_modules between runs → faster

      - run: npm ci              # exact install, lock file enforced

      - run: npm run lint        # ESLint
      - run: npm run typecheck   # tsc --noEmit
      - run: npm test            # unit + integration tests

      - run: npm audit --audit-level=high  # fail on high/critical vulnerabilities
CI installs exactly (`npm ci`), then runs lint, type-check, tests, and audit — every push, on a clean machine
The CI job runs on a **fresh, clean machine** for every push — no "it works on my laptop" surprises, no cached state from previous runs (except the `node_modules` layer you explicitly cache). **`npm ci`** is critical: unlike `npm install` it installs the *exact* versions from `package-lock.json` and errors if the lock file is out of sync, ensuring reproducible builds. Run checks in the order of **speed**: lint (fast) → type-check (fast) → tests (slower) → audit — so early failures fail fast and don't waste time on slower steps. `npm audit --audit-level=high` fails the build on high-severity vulnerabilities, making security part of the mandatory gate rather than something you remember to check. The whole chain exits non-zero on any failure, which blocks the PR or merge.
Building and pushing a Docker image

.github/workflows/deploy.yml (deploy job)

YAML
  build-and-push:
    needs: check              # only runs if CI checks pass
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'   # only on main branch

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha    # layer cache between workflow runs
          cache-to: type=gha,mode=max
Tag images with both `:latest` and the git SHA — use the SHA tag for deployments so you always know exactly what's running
Image tagging matters for traceability. **`:latest`** is convenient for "current" but tells you nothing about *which commit* you're running — if something goes wrong you can't easily identify the image or roll back to a specific commit. **Tag every image with the git commit SHA** (e.g. `ghcr.io/org/app:a3f8c12`) and use that SHA tag in your deployment config. Now "what is running in production?" has an exact answer, rollback means deploying the previous SHA tag, and you can correlate a production incident to the exact code. CI actions like `docker/build-push-action` make the commit SHA available via `${{ github.sha }}`. Also: use the GitHub Actions **layer cache** (`cache-from/to: type=gha`) so repeated Docker builds reuse the `npm ci` and build layers across runs — a 5-minute build drops to under 1 minute.
Deployment step

YAML
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest

    steps:
      # Pattern varies by platform — examples:

      # Render / Railway — trigger a deploy via API:
      - run: curl -X POST ${{ secrets.DEPLOY_HOOK_URL }}

      # SSH to a server and pull the new image (for VMs / bare metal):
      - uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker stop api && docker rm api
            docker run -d --name api -p 3000:3000 \
              -e NODE_ENV=production \
              -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
              ghcr.io/${{ github.repository }}:${{ github.sha }}
Deployment is a platform-specific step — the principle is always the same: pull the tested image, start the new version, verify health
The *deployment* step itself varies by platform but the **principle is constant**: fetch the image that CI just built and tested, start the new version, wait for health checks to pass, and (on a PaaS or orchestrator) route traffic to it while the old version drains. PaaS platforms like Render and Railway support **deploy hook URLs** — a single `curl POST` triggers a pull-and-restart on their end. For VMs you SSH in and do it yourself. Kubernetes and ECS update a deployment/service and handle rolling replacement. The key insight is that CI and deployment are **separate steps chained together**: CI produces a *tested artifact* (the image SHA); CD *delivers* that artifact. Secrets like `DATABASE_URL` and `SSH_PRIVATE_KEY` live in GitHub Actions **encrypted secrets** (Settings → Secrets), never hard-coded in the workflow file.
Pipeline best practices
  • Protect the main branch — require CI checks to pass and at least one review before merging; make CI the gatekeeper.

  • Build once, deploy the artifact — one image build per commit; the same image goes to staging and production.

  • Keep secrets in CI secrets — never put credentials in workflow files; use encrypted secrets and pass them as environment variables.

  • Cache aggressivelynode_modules via actions/setup-node cache, Docker layers via type=gha, and any other repeatable work.

  • Run in parallel where independent — lint, type-check, and test can run as parallel jobs; docker build can start as soon as checkout finishes.

  • Fail fast — put cheap, fast checks (lint) before expensive ones (e2e tests); use needs: to skip later stages if early ones fail.

The goal is a pipeline that runs in under 5 minutes, catches every real problem, and deploys automatically on green
A well-designed CI/CD pipeline should feel **boring**: every green merge automatically ships, every red build is caught before it reaches users, and the whole process finishes in a few minutes. The optimizations that get you there are caching (the biggest lever — `node_modules` cache and Docker layer cache can cut build time by 60–80%), parallelism (run independent checks simultaneously), and fail-fast ordering (cheap gates first). The goal is a pipeline that runs in **under five minutes** so developers get feedback quickly and aren't tempted to batch changes to amortize wait time. Frequent, fast, automated deployment is the foundation of a team that ships confidently — each change is small, well-tested, and reversible, rather than a high-stakes mega-merge.
Next
Manage config and secrets across your production environments: [Production Configuration](/nodejs/environment-config-prod).