Deployment & CI/CD
Ship code reliably and automatically.
Deployment Philosophy
The Deployment Pipeline
Code → Build → Test → Stage → Production
│ │ │ │ │ └───────┴───────┴───────┴────────┘ Automated, Repeatable
Principles
-
Automate everything - No manual steps
-
Fail fast - Catch issues early
-
Rollback ready - Always have an escape
-
Environment parity - Dev ≈ Staging ≈ Prod
-
Observability - Know what's happening
GitHub Actions
Basic Workflow
.github/workflows/ci.yml
name: CI
on: push: branches: [main] pull_request: branches: [main]
jobs: build-and-test: runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Build
run: npm run build
Matrix Builds
jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Deploy on Push to Main
name: Deploy
on: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Secrets Management
env: DATABASE_URL: ${{ secrets.DATABASE_URL }} API_KEY: ${{ secrets.API_KEY }}
In GitHub: Settings → Secrets and variables → Actions
Caching
- name: Cache node modules uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-
Docker
Node.js Dockerfile
Build stage
FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build
Production stage
FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public
EXPOSE 3000 CMD ["npm", "start"]
Docker Compose
docker-compose.yml
version: '3.8'
services: app: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgresql://postgres:password@db:5432/mydb depends_on: - db
db: image: postgres:15-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: mydb volumes: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432"
volumes: postgres_data:
.dockerignore
node_modules .git .gitignore README.md .env .env.* Dockerfile docker-compose.yml .next coverage
Platform Deployments
Vercel (Next.js)
// vercel.json { "framework": "nextjs", "buildCommand": "npm run build", "outputDirectory": ".next", "env": { "DATABASE_URL": "@database-url" }, "headers": [ { "source": "/api/(.*)", "headers": [ { "key": "Cache-Control", "value": "no-store" } ] } ] }
Railway
railway.toml
[build] builder = "nixpacks" buildCommand = "npm run build"
[deploy] startCommand = "npm start" healthcheckPath = "/api/health" healthcheckTimeout = 100 restartPolicyType = "on_failure" restartPolicyMaxRetries = 3
Fly.io
fly.toml
app = "my-app" primary_region = "ord"
[build] dockerfile = "Dockerfile"
[http_service] internal_port = 3000 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 0
[[services]] internal_port = 3000 protocol = "tcp"
[[services.ports]] port = 80 handlers = ["http"]
[[services.ports]] port = 443 handlers = ["tls", "http"]
Environment Management
Environment Files
.env # Shared defaults (committed) .env.local # Local overrides (gitignored) .env.development # Dev-specific .env.production # Prod-specific .env.test # Test-specific
Environment Variables Pattern
// lib/env.ts import { z } from 'zod';
const envSchema = z.object({ DATABASE_URL: z.string().url(), API_KEY: z.string().min(1), <!-- allow-secret --> NODE_ENV: z.enum(['development', 'production', 'test']), });
export const env = envSchema.parse(process.env);
Database Migrations
Prisma Migrations
Create migration
npx prisma migrate dev --name init
Apply migrations (CI/CD)
npx prisma migrate deploy
Generate client
npx prisma generate
Migration in CI/CD
- name: Run database migrations run: npx prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }}
Rollback Strategies
Blue-Green Deployment
┌─────────┐
Traffic → │ Blue │ ← Current └─────────┘
┌─────────┐
│ Green │ ← New version (testing)
└─────────┘
After verification: Switch traffic to Green
Canary Deployment
┌─────────┐
90% ────→ │ Current │ └─────────┘
┌─────────┐
10% ────→ │ New │ ← Monitor for issues └─────────┘
Gradually increase new version traffic
Instant Rollback (Vercel)
Rollback to previous deployment
vercel rollback
Or via dashboard: Deployments → ... → Promote to Production
Health Checks
Health Endpoint
// app/api/health/route.ts import { db } from '@/lib/db';
export async function GET() {
try {
// Check database
await db.$queryRawSELECT 1;
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || 'unknown',
});
} catch (error) { return Response.json( { status: 'unhealthy', error: 'Database connection failed' }, { status: 503 } ); } }
Complete CI/CD Pipeline
name: CI/CD
on: push: branches: [main, develop] pull_request: branches: [main]
env: NODE_VERSION: '20'
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - run: npm ci - run: npm run lint
test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - run: npm ci - run: npm test -- --coverage - uses: codecov/codecov-action@v3
build: needs: [lint, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - run: npm ci - run: npm run build - uses: actions/upload-artifact@v3 with: name: build path: .next
deploy-preview: if: github.event_name == 'pull_request' needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
deploy-production: if: github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod'
References
-
references/github-actions-recipes.md
-
Common workflow patterns
-
references/docker-patterns.md
-
Docker best practices
-
references/monitoring-setup.md
-
Observability configuration