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):
- Deploy SQL:
ALTER TABLE users RENAME COLUMN customer_email TO email - Deploy code: use
emailinstead ofcustomer_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.