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.
- Build — Compile code, install dependencies, generate artifacts
- Test — Unit tests, integration tests, security scans
- Package — Build container images, tag with commit SHA
- Deploy — Roll out to staging, then production
- 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
latesttags. 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.