Building a Modern CI/CD Pipeline: From Code to Production

A CI/CD pipeline is not a YAML file. It is the backbone of your engineering organization's ability to ship software reliably. A well-designed pipeline catches bugs before they reach production, enforces code quality standards automatically, and gives the team confidence that any commit on main can be deployed safely.

This guide walks through building a production-grade CI/CD pipeline using GitHub Actions for orchestration, Docker for containerization, and Kubernetes for deployment. We will cover the architecture decisions that matter, the testing strategies that provide actual safety, and the monitoring practices that close the feedback loop.

Pipeline Architecture

A modern CI/CD pipeline has five stages. Each stage gates the next — a failure at any point stops the pipeline and notifies the team.

  1. Build — Compile code, install dependencies, generate artifacts
  2. Test — Unit tests, integration tests, security scans
  3. Package — Build container images, tag with commit SHA
  4. Deploy — Roll out to staging, then production
  5. Verify — Health checks, smoke tests, metric validation

The key principle is immutable artifacts. The Docker image built in stage 3 is the exact image that runs in staging and production. You never rebuild for different environments — you change configuration, not code.

GitHub Actions: Workflow Structure

Here is the complete workflow file for a Node.js application. We will break down each section.

name: CI/CD Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm test -- --coverage
        env:
          CI: true

      - name: Upload coverage
        if: matrix.node-version == 22
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  integration-test:
    runs-on: ubuntu-latest
    needs: test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
      - run: npm ci
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

  build-and-push:
    runs-on: ubuntu-latest
    needs: [test, integration-test]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4

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

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Several design decisions in this workflow are worth highlighting:

Matrix testing: Running tests against Node 20 and 22 catches compatibility issues before they bite you in production upgrades. The cost is minimal — GitHub Actions runs matrix jobs in parallel.

Service containers: Integration tests run against real PostgreSQL and Redis instances, not mocks. Service containers start automatically and are health-checked before tests run. This catches issues that mocks silently pass, such as incorrect SQL syntax, missing indexes, or Redis data structure mismatches.

SHA-based tags: Images are tagged with the Git commit SHA, not latest or version numbers. This makes every deployment traceable to an exact commit and eliminates tag mutation issues.

Docker: Production-Ready Images

Container image quality directly affects deployment speed, security posture, and runtime performance. Here is a multi-stage Dockerfile that produces a minimal production image.

FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
RUN npm prune --production

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

EXPOSE 3000
USER nonroot
CMD ["dist/server.js"]

The multi-stage build separates build dependencies from runtime dependencies. The final image uses Google's distroless base, which contains only the Node.js runtime — no shell, no package manager, no utilities an attacker could exploit. The resulting image is typically 80-120 MB compared to 400+ MB for a standard node:22 image.

Running as nonroot prevents privilege escalation attacks inside the container. This is a security baseline, not an option.

Kubernetes: Deployment Strategy

The deployment configuration determines how your application rolls out and how it recovers from failures. Here is a production deployment manifest.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: api-server
  template:
    spec:
      containers:
        - name: api
          image: ghcr.io/org/api:abc123f
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20
          env:
            - name: NODE_ENV
              value: production
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: database-url

maxUnavailable: 0 ensures zero downtime during deployments. Kubernetes brings up a new pod before terminating an old one. Combined with readiness probes, traffic only routes to pods that have confirmed they can serve requests.

Resource requests and limits prevent noisy-neighbor problems and enable the Kubernetes scheduler to make intelligent placement decisions. Set requests based on observed P50 usage and limits based on P99 peaks, with headroom.

Testing Strategy

The testing pyramid is still the right model, but the ratio has shifted. Modern applications benefit from more integration tests and fewer unit tests than the traditional pyramid suggests.

Test Type Count Run Time What It Catches
Unit 200–500 < 30s Logic errors in pure functions
Integration 50–150 2–5 min Database queries, API contracts, service interactions
E2E 10–30 5–15 min Critical user flows, cross-service workflows
Security Continuous 1–3 min Dependency vulnerabilities, SAST findings

The critical insight is that integration tests with real databases catch an entire class of bugs that unit tests cannot: schema mismatches, transaction isolation issues, query performance problems, and constraint violations. The CI workflow above provisions real PostgreSQL and Redis containers specifically for this reason.

Security scanning runs in parallel with other test jobs using tools like Trivy for container scanning and npm audit for dependency vulnerabilities. These should block deployment, not just warn.

Deployment Patterns

Three deployment patterns cover the majority of production needs:

Rolling deployments (shown above) are the default. They work well for stateless services where any version can handle any request. The maxSurge/maxUnavailable settings control the rollout speed.

Blue-green deployments maintain two identical environments. Traffic switches atomically from the old version (blue) to the new version (green) after verification. This enables instant rollback by switching back to blue. The cost is double the infrastructure during the transition.

Canary deployments route a small percentage of traffic (typically 1-5%) to the new version first. If error rates and latency remain stable after an observation period, traffic gradually shifts to the new version. This catches issues that only manifest under production load patterns.

# Canary with Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
  strategy:
    canary:
      steps:
        - setWeight: 5
        - pause: { duration: 5m }
        - setWeight: 25
        - pause: { duration: 10m }
        - setWeight: 75
        - pause: { duration: 10m }
      analysis:
        templates:
          - templateName: success-rate
        startingStep: 2

Production Monitoring

A deployment without monitoring is a deployment you cannot learn from. The monitoring stack should answer three questions: Is the service healthy? Is it performing well? What changed?

Health endpoints report application readiness. A health check should verify database connectivity, cache availability, and critical dependency access — not just return 200 OK.

Metrics track the four golden signals: latency (P50, P95, P99), traffic (requests/second), error rate (5xx percentage), and saturation (CPU, memory, connection pool utilization). Prometheus plus Grafana is the standard open-source stack. Set alerts on P99 latency and error rate, not averages.

Structured logging makes debugging possible. Every log entry should include a request ID, timestamp, service name, and severity level. Use JSON format for machine parsing. Correlate logs across services using distributed tracing (OpenTelemetry).

Pipeline Anti-Patterns

These patterns consistently cause pipeline failures and deployment incidents:

  • Building different artifacts per environment. If your staging build is different from your production build, staging is not actually testing what ships.
  • Skipping integration tests to speed up the pipeline. The time saved is borrowed against incident response time later.
  • Using latest tags. Tag mutation makes deployments non-deterministic and rollbacks impossible.
  • Manual approval gates for every deployment. These create bottlenecks and incentivize batching changes, which increases deployment risk.
  • Not monitoring after deployment. A deploy without automated verification is a hope-driven process.
The goal of CI/CD is not automation. It is confidence. Every stage of the pipeline should increase your confidence that this code is safe to run in production.

Start with the simplest pipeline that provides real safety — build, test, deploy — and add complexity only when you have evidence that the current pipeline is insufficient. An overengineered pipeline that nobody maintains is worse than a simple one that actually runs.