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

A CI/CD pipeline isn't a YAML file. It's 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'll 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'll break down each section.

JavaScript Runtime Comparison
Runtime Cold Start HTTP Throughput TypeScript
Node.js 22~50 ms~85k req/sVia tsx/ts-node
Deno 2~30 ms~120k req/sBuilt-in
Bun 1.x~10 ms~260k req/sBuilt-in
Cloudflare Workers0 ms (pre-warm)N/A (edge)Via Wrangler
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.

This connects to the ideas in Clean Architecture in Practice: Hexagonal, Onion, and Ports-.

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.

Related reading: Developer Experience Metrics: Lead Time, DORA Metrics, and E.

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 can't: 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.

Related reading: Secrets Management: HashiCorp Vault, AWS Secrets Manager, an.

# 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 can't 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 isn't 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 isn't automation. It's 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.

References & Standards: W3C Standards · Node.js documentation