Claude Code for Zero-Downtime Deployments: Blue-Green, Rolling, and Database Migrations — Claude Skills 360 Blog
Blog / Infrastructure / Claude Code for Zero-Downtime Deployments: Blue-Green, Rolling, and Database Migrations
Infrastructure

Claude Code for Zero-Downtime Deployments: Blue-Green, Rolling, and Database Migrations

Published: September 29, 2026
Read time: 9 min read
By: Claude Skills 360

Zero-downtime deployments require coordinating application code and database schema carefully. Code changes must be backward compatible with the current schema, and schema changes must be backward compatible with the current code. Violating either causes errors during the deployment window. Claude Code implements the patterns that make this systematic: expand-contract migrations, feature flags for data migrations, and rollback strategies that actually work.

The Expand-Contract Pattern

We need to rename the column `customer_email` to `email` in the users table.
How do we do this without downtime?

Naive approach (breaks during deployment):

  1. Deploy SQL: ALTER TABLE users RENAME COLUMN customer_email TO email
  2. Deploy code: use email instead of customer_email

Between steps 1 and 2, the running code references customer_email which doesn’t exist. Between steps 2 and 1 (if you do code first), email doesn’t exist yet.

Expand-Contract (4 deploys, zero downtime):

-- Migration 1: EXPAND — add new column (old code still writes to old column)
ALTER TABLE users ADD COLUMN email TEXT;

-- Backfill existing rows
UPDATE users SET email = customer_email WHERE email IS NULL;

-- Make new column non-null with a default for new inserts
-- (can't be NOT NULL yet — old code doesn't know about it)
// Code Deploy 1: write to BOTH columns (old and new)
// Old code: reads/writes customer_email
// New code: reads customer_email, writes customer_email AND email
async function updateUserEmail(userId: string, newEmail: string) {
  await db('users')
    .where('id', userId)
    .update({ 
      customer_email: newEmail,  // Keep until old code is gone
      email: newEmail,           // Start populating new column
    });
}
-- Migration 2 (after ALL servers run new code): switch reads to new column
-- At this point, both columns exist and are in sync
// Code Deploy 2: read from new column, still write to both
async function getUserEmail(userId: string) {
  const user = await db('users').where('id', userId).first();
  return user.email; // Read from new column now
}
// Code Deploy 3: stop writing to old column
async function updateUserEmail(userId: string, newEmail: string) {
  await db('users')
    .where('id', userId)
    .update({ email: newEmail }); // Old column write removed
}
-- Migration 3: CONTRACT — drop old column (old code is fully gone)
ALTER TABLE users DROP COLUMN customer_email;

Kubernetes Rolling Deployment

Configure our Kubernetes deployment for zero-downtime rolling updates.
New pods must be ready before old ones terminate.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api
spec:
  replicas: 4
  
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2        # Spin up 2 extra pods before terminating old ones
      maxUnavailable: 0  # NEVER reduce below 4 replicas during rollout
  
  selector:
    matchLabels:
      app: order-api
  
  template:
    metadata:
      labels:
        app: order-api
    spec:
      # Graceful shutdown: finish in-flight requests before terminating
      terminationGracePeriodSeconds: 60
      
      containers:
        - name: api
          image: myregistry/order-api:2.1.0
          
          # Readiness: don't send traffic until this passes
          readinessProbe:
            httpGet:
              path: /readyz
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
            successThreshold: 1
          
          # Liveness: restart if this fails
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 15
            failureThreshold: 3
          
          # Startup probe: generous timeout for slow starts
          startupProbe:
            httpGet:
              path: /healthz
              port: 3000
            failureThreshold: 30
            periodSeconds: 5
          
          lifecycle:
            preStop:
              exec:
                # Tell Node.js to stop accepting connections, drain existing ones
                command: ["/bin/sleep", "5"]
          
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"

Health check implementation:

// Readiness vs liveness distinction matters
app.get('/healthz', (req, res) => {
  // Liveness: is the process alive? Simple check — avoid DB calls here
  // If this fails, Kubernetes RESTARTS the container
  res.json({ status: 'alive' });
});

app.get('/readyz', async (req, res) => {
  // Readiness: is this instance ready to receive traffic?
  // If this fails, Kubernetes REMOVES this pod from the Service (no restart)
  
  try {
    // Check critical dependencies
    await db.raw('SELECT 1');
    
    const redisOk = await redis.ping() === 'PONG';
    if (!redisOk) throw new Error('Redis not responding');
    
    res.json({ status: 'ready', checks: { db: 'ok', redis: 'ok' } });
  } catch (err) {
    res.status(503).json({ 
      status: 'not ready',
      error: err.message,
    });
  }
});

Feature Flags for Long-Running Data Migrations

We need to add full-text search to 50 million product records.
We can't block the table for hours. How do we do this without downtime?

Phase 1: Deploy schema change + disabled flag

-- Non-blocking: add column as nullable (no table lock)
ALTER TABLE products ADD COLUMN search_tsvector tsvector;
CREATE INDEX CONCURRENTLY products_search_idx ON products USING GIN(search_tsvector);
-- CONCURRENTLY: builds index without locking the table

Phase 2: Background migration with progress tracking

// scripts/migrate-search-vectors.ts — run as a background job
async function migrateBatch(lastId: number, batchSize = 1000): Promise<number> {
  const products = await db('products')
    .where('id', '>', lastId)
    .where('search_tsvector', null)  // Only unmigrated rows
    .orderBy('id')
    .limit(batchSize)
    .select('id', 'name', 'description', 'brand');
  
  if (products.length === 0) return -1; // Done
  
  // Build tsvectors
  const updates = products.map(p => ({
    id: p.id,
    tsvector: `
      setweight(to_tsvector('english', coalesce(${db.client.raw('?', [p.name])}, '')), 'A') ||
      setweight(to_tsvector('english', coalesce(${db.client.raw('?', [p.brand])}, '')), 'B') ||
      setweight(to_tsvector('english', coalesce(${db.client.raw('?', [p.description])}, '')), 'C')
    `
  }));
  
  // Batch update — minimize lock time per row
  await db.raw(`
    UPDATE products
    SET search_tsvector = data.tsvector
    FROM (VALUES ${updates.map(() => '(?, ?::tsvector)').join(',')}) AS data(id, tsvector)
    WHERE products.id = data.id::bigint
  `, updates.flatMap(u => [u.id, u.tsvector]));
  
  const lastMigratedId = products[products.length - 1].id;
  
  // Log progress
  const total = await db('products').count('* as count').first();
  const migrated = await db('products').whereNotNull('search_tsvector').count('* as count').first();
  console.log(`Progress: ${migrated.count}/${total.count} (last ID: ${lastMigratedId})`);
  
  return lastMigratedId;
}

// Run continuously with throttling
async function runMigration() {
  let lastId = 0;
  while (true) {
    lastId = await migrateBatch(lastId);
    if (lastId === -1) break;
    await new Promise(resolve => setTimeout(resolve, 50)); // 50ms pause between batches
  }
  console.log('Migration complete');
}

Phase 3: Enable search behind feature flag

// Only route search queries to new column when migration is complete
async function searchProducts(query: string) {
  if (await featureFlags.isEnabled('product_full_text_search')) {
    // New implementation using tsvector
    return db('products')
      .whereRaw('search_tsvector @@ plainto_tsquery(?)', [query])
      .orderByRaw('ts_rank(search_tsvector, plainto_tsquery(?)) DESC', [query])
      .limit(20);
  }
  
  // Fallback: LIKE search (slower but works without migration)
  return db('products')
    .whereILike('name', `%${query}%`)
    .limit(20);
}

Phase 4: Add trigger for new inserts, clean up flag

-- Keep search_tsvector current for new products
CREATE OR REPLACE FUNCTION products_search_update() RETURNS trigger AS $$
BEGIN
  NEW.search_tsvector :=
    setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(NEW.brand, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(NEW.description, '')), 'C');
  RETURN NEW;
END
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_search_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_update();

For the SQL optimization that makes these patterns performant at scale, see the PostgreSQL advanced guide. For the database migration tooling (Knex, Liquibase, Flyway) that manages migration versioning, the database migrations guide covers migration system design. The Claude Skills 360 bundle includes deployment skill sets covering zero-downtime patterns, Kubernetes rollout configuration, and safe schema migration strategies. Start with the free tier to try deployment configuration generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free