| name | production-deployment |
| description | Deployment workflows, CI/CD pipelines, Docker containerization, health checks, rollback strategies, database migrations, and production readiness for web applications. Always activate when the user is setting up CI/CD, Dockerizing an application, planning a deployment strategy, implementing health checks, preparing a production release, configuring environments, or asking how to deploy, roll back, or migrate a database safely. Also activate proactively when spotting hardcoded secrets, missing health checks, or root-running containers. |
Production Deployment
Production deployment workflows, CI/CD patterns, and operational readiness.
Workflow
When this skill activates:
- Identify the task — new pipeline, Dockerfile, deployment strategy, health checks, database migration, or rollback plan.
- Navigate to the relevant section — don't apply every pattern to every situation.
- Apply the production readiness checklist before any release goes out.
- For structured logging, metrics, smoke tests, and observability implementation, see
references/observability.md.
- Flag violations proactively — root-running containers,
:latest tags, missing health check timeouts, and hardcoded secrets are the most common.
Deployment Strategies
Rolling (Default)
Replace instances gradually — old and new run simultaneously during rollout.
Instance 1: v1 → v2 ← updated first, traffic continues
Instance 2: v1 ← still v1
Instance 3: v1 ← still v1
Instance 1: v2
Instance 2: v1 → v2 ← updated second
Instance 3: v1
Instance 1: v2
Instance 2: v2
Instance 3: v1 → v2 ← updated last
Pros: Zero downtime, gradual rollout, no extra infrastructure
Cons: Two versions run simultaneously — API must be backward-compatible
Use when: Standard deployments, backward-compatible changes
Blue-Green
Two identical environments. Switch traffic atomically.
Blue (v1) ← all traffic
Green (v2) idle, new version deployed and verified
# After verification — atomic cutover:
Blue (v1) standby (instant rollback target)
Green (v2) ← all traffic
Pros: Instant rollback (flip back to blue), clean cutover, no mixed versions
Cons: Requires 2× infrastructure during deployment
Use when: Critical services, zero tolerance for partial failures
Canary
Route a small percentage of traffic to the new version first.
v1: 95% of traffic
v2: 5% of traffic ← canary watches error rate, latency, business metrics
# If metrics are healthy after N minutes:
v1: 0% → v2: 100%
# If metrics degrade:
v2: 0% → automatic rollback to v1
Pros: Real traffic validation before full rollout, automatic abort criteria
Cons: Requires traffic-splitting infrastructure and automated monitoring
Use when: High-traffic services, risky changes, when you need real traffic signal
Docker
Multi-Stage Dockerfile (Node.js)
# Stage 1: Install all dependencies (including dev)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev # --omit=dev replaces deprecated --production
# Stage 3: Production image — minimal, hardened
FROM node:22-alpine AS runner
WORKDIR /app
# Create group first, then assign user to it — both needed for --chown to work
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
USER appuser
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
Multi-Stage Dockerfile (Go)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Static binary — no libc dependency
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
FROM alpine:3.20 AS runner
RUN apk --no-cache add ca-certificates tzdata
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
USER appuser
# --chown ensures appuser can execute the binary
COPY --from=builder --chown=appuser:appgroup /server /server
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -qO- http://localhost:8080/health || exit 1
CMD ["/server"]
Multi-Stage Dockerfile (Python)
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt
FROM python:3.12-slim AS runner
WORKDIR /app
RUN groupadd -g 1001 appgroup && \
useradd -r -u 1001 -g appgroup appuser
# Copy installed packages and app source — set ownership before USER switch
COPY --from=builder --chown=appuser:appgroup /usr/local/lib/python3.12/site-packages \
/usr/local/lib/python3.12/site-packages
COPY --from=builder --chown=appuser:appgroup /usr/local/bin /usr/local/bin
COPY --chown=appuser:appgroup . .
USER appuser
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/')" || exit 1
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
.dockerignore
node_modules
.git
.env
.env.*
dist
build
coverage
*.log
.next
__pycache__
*.pyc
.pytest_cache
.venv
Health Checks
Liveness and readiness must use separate endpoints. This is the most important health check rule:
/health (liveness) — is the process alive? Returns 200 if the app is running, even if dependencies are down. A liveness failure causes Kubernetes to restart the pod.
/health/ready (readiness) — can the pod serve traffic? Checks dependencies. A readiness failure removes the pod from the load balancer without restarting it.
Why this matters: If /health checks the database and the database goes down, Kubernetes restarts every pod in a crash loop — which doesn't fix the database and makes things worse. The pod is alive; it just can't serve traffic yet.
app.get('/health', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version: process.env.APP_VERSION })
})
app.get('/health/ready', async (_req, res) => {
const timeout = (ms: number) =>
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms),
)
const check = async (name: string, fn: () => Promise<void>): Promise<HealthResult> => {
const start = performance.now()
try {
await Promise.race([fn(), timeout(2000)])
return { status: 'ok', latency_ms: Math.round(performance.now() - start) }
} catch (err) {
return {
status: 'error',
latency_ms: Math.round(performance.now() - start),
message: err instanceof Error ? err.message : 'unknown',
}
}
}
const checks = {
database: await check('database', () => db.query('SELECT 1').then(() => undefined)),
redis: await check('redis', () => redis.ping().then(() => undefined)),
}
const allOk = Object.values(checks).every(c => c.status === 'ok')
res.status(allOk ? 200 : 503).json({
status: allOk ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
checks,
})
})
Kubernetes Probes (Separate Endpoints)
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 2
startupProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30
CI/CD Pipeline
GitHub Actions — Complete Pipeline
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-${{ github.sha }}
path: coverage/
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.tags }}
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 }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,format=long
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging
run: |
# kubectl set image deployment/app app=ghcr.io/${{ github.repository }}:${{ github.sha }}
# railway up / vercel --prod --env staging
echo "Deploy ${{ github.sha }} → staging"
- name: Smoke test staging
run: |
sleep 30 # allow time for rollout
curl --fail --retry 5 --retry-delay 5 \
https://staging.example.com/health || exit 1
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to production
run: |
echo "Deploy ${{ github.sha }} → production"
Pipeline Stages
PR opened:
lint → typecheck → unit tests → integration tests → preview deploy
Merged to main:
lint → typecheck → unit tests → build image (SBOM + provenance)
→ deploy staging → smoke tests → [manual approval] → deploy production
Secrets Management in CI/CD
Never put secrets in workflow files, repository variables, or image layers:
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: ./deploy.sh
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActions
aws-region: us-east-1
Database Migrations
Database migrations are the highest-risk part of any deployment. The expand-contract pattern enables zero-downtime changes.
Expand-Contract (Zero-Downtime) Pattern
Never make a breaking schema change in a single deployment. Split it across three:
Step 1 — EXPAND: add the new thing, keep the old
→ Add new column with default or nullable
→ Deploy code that writes to BOTH old and new column
→ Old code still works (reads old column)
Step 2 — MIGRATE: backfill existing data
→ Run migration to populate new column from old
→ Deploy code that reads from new column
→ Keep writing to both during transition
Step 3 — CONTRACT: remove the old thing
→ Remove old column once all traffic reads new column
→ Remove dual-write code
Concrete example — renaming full_name to display_name:
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = full_name WHERE display_name IS NULL;
ALTER TABLE users DROP COLUMN full_name;
Migration Safety Rules
✅ Always safe:
- Adding a nullable column
- Adding a column with a default value
- Adding a new table
- Adding an index CONCURRENTLY (Postgres)
- Widening a column (VARCHAR(100) → VARCHAR(200))
❌ Never safe in a single deploy:
- Dropping a column currently read by live code
- Renaming a column used by live code
- Adding a NOT NULL column without a default
- Changing a column type incompatibly
- Dropping a table used by live code
Rollback Checklist
Environment Configuration
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
APP_VERSION: z.string().default('unknown'),
})
export const env = envSchema.parse(process.env)
Production Readiness Checklist
Run before every production release:
Application
Docker & Infrastructure
CI/CD & Secrets
Database
Monitoring & Alerting
Operations
For structured logging implementation, metrics export, and observability setup, see references/observability.md.