| name | production-deploy |
| description | Pre-deployment validation and release management — structured checklists for database migrations, environment variables, rollback plans, backward compatibility, and deployment strategies. Use this skill when the user mentions deploy, release, ship to prod, merge to main, CI/CD pipeline, or says /production deploy. Triggers on deployment-related discussions, release planning, or pre-release validation. |
Production Deploy
This skill encodes the pre-deployment discipline that separates teams who ship confidently from teams who ship and pray. Every checklist item here exists because someone skipped it and caused an outage. The patterns are opinionated and battle-tested — this is not a deployment tutorial, it is a deployment gate.
If you cannot check every box, you are not ready to deploy. Partial deploys are how you get partial outages.
1. Pre-Deployment Checklist
Run through this before every production deploy. No exceptions, no shortcuts, no "we'll check it in staging." Print this out and check the boxes with a pen if that is what it takes.
Data Layer
Application Layer
Infrastructure
Observability
Rollback Readiness
Detection — find deploys that skip the checklist:
gh pr list --state merged --base main --limit 20 --json title,body \
| jq '.[] | select(.body | test("deploy checklist|pre-deploy|rollback plan"; "i") | not) | .title'
git tag -l "rollback-*" --sort=-creatordate | head -5
2. Migration Safety Review
Every migration must be classified before it ships. This is the single most important step in any deploy that touches the database. Get it wrong and you take the service down for every user, not just the ones using the new feature.
Classification
Additive (Safe) — Green Light
These migrations are safe for zero-downtime rolling deploys. Old code ignores the new structures.
- New tables
- New nullable columns (without defaults on large tables)
- New indexes (with
CONCURRENTLY)
- New views
- New functions/procedures
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
message TEXT NOT NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE orders ADD COLUMN tracking_url TEXT;
CREATE INDEX CONCURRENTLY ix_notifications_user_id ON notifications (user_id);
Transformative (Risky) — Yellow Light
These require extra review, staging testing with production-volume data, and explicit sign-off. They can be done safely but the safe pattern is non-obvious.
- Column type changes (expand-contract)
- Data backfills on large tables (batch, not single UPDATE)
- Adding NOT NULL constraints (use NOT VALID + VALIDATE)
- Adding CHECK constraints on existing data
- Adding foreign keys on existing data
def upgrade():
conn = op.get_bind()
batch_size = 1000
while True:
result = conn.execute(text(
"UPDATE orders SET status = 'active' "
"WHERE id IN ("
" SELECT id FROM orders WHERE status IS NULL LIMIT :batch"
")"
), {"batch": batch_size})
if result.rowcount == 0:
break
conn.execute(text("COMMIT"))
Destructive (Dangerous) — Red Light
REFUSE to deploy destructive migrations without explicit confirmation from the user. These are irreversible. Suggest a zero-downtime alternative first.
- Column drops
- Table drops
- Data deletion (DELETE/TRUNCATE)
- Column renames (breaks old code during rolling deploy)
- Type changes that narrow data (e.g., TEXT to VARCHAR(50))
def downgrade():
op.add_column('users', sa.Column('legacy_role', sa.String(50)))
Detection — find risky migrations before they ship:
grep -rn "drop_column\|drop_table\|DROP TABLE\|DROP COLUMN\|TRUNCATE\|DELETE FROM" \
alembic/versions/ migrations/
grep -rL "lock_timeout" alembic/versions/*.py
squawk alembic/versions/latest_migration.sql
Migration Lint with squawk
squawk catches unsafe migration patterns automatically. Add it to CI:
- name: Lint migrations
run: |
pip install squawk-cli
# Generate SQL from Alembic migrations
alembic upgrade head --sql > migration.sql
squawk migration.sql
squawk catches: missing CONCURRENTLY on indexes, NOT NULL additions without defaults, missing lock_timeout, and more.
3. Environment Variable Validation
Missing environment variables are the #2 cause of deploy failures (after bad migrations). The fix is simple: validate everything at startup, fail fast with a clear error message.
Python — Pydantic BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, field_validator
from typing import Annotated
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
database_url: str
secret_key: str
allowed_hosts: list[str]
environment: str
log_level: str = "INFO"
redis_url: str = "redis://localhost:6379/0"
cors_origins: list[str] = ["http://localhost:3000"]
db_pool_size: int = 10
db_max_overflow: int = 5
sentry_dsn: str | None = None
app_version: str = "dev"
deploy_sha: str = "unknown"
@field_validator("environment")
@classmethod
def validate_environment(cls, v: str) -> str:
allowed = {"development", "staging", "production"}
if v not in allowed:
raise ValueError(f"environment must be one of {allowed}, got '{v}'")
return v
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if v.upper() not in allowed:
raise ValueError(f"log_level must be one of {allowed}, got '{v}'")
return v.upper()
settings = Settings()
Node.js — envalid
import { cleanEnv, str, num, url, bool } from "envalid";
const env = cleanEnv(process.env, {
DATABASE_URL: url(),
SECRET_KEY: str({ desc: "JWT signing key" }),
ALLOWED_HOSTS: str({ desc: "Comma-separated allowed hosts" }),
NODE_ENV: str({ choices: ["development", "staging", "production"] }),
LOG_LEVEL: str({
choices: ["debug", "info", "warn", "error"],
default: "info",
}),
REDIS_URL: url({ default: "redis://localhost:6379/0" }),
PORT: num({ default: 3000 }),
SENTRY_DSN: str({ default: "" }),
APP_VERSION: str({ default: "dev" }),
DEPLOY_SHA: str({ default: "unknown" }),
});
export default env;
Rules
- Every required env var must be validated at startup, not on first use
- Fail with a human-readable error: "Missing required environment variable: DATABASE_URL" — not a cryptic NoneType error 3 stack frames deep
- Document every env var in a
.env.example file committed to the repo
- New env vars added in a PR MUST be set in production BEFORE the deploy, or have a safe default
- Never use
os.getenv("SECRET") without a fallback or validation — it silently returns None
Detection — find unvalidated env vars:
grep -rn "os\.getenv\|os\.environ\[" --include="*.py" src/ app/ \
| grep -v "settings\|config\|test"
grep -rn "process\.env\." --include="*.ts" --include="*.js" src/ \
| grep -v "node_modules\|config\|env\.ts\|env\.js\|test"
comm -23 \
<(grep -rhoP '(?:os\.getenv|os\.environ\[|process\.env\.)["'"'"']?\K[A-Z_]+' src/ | sort -u) \
<(grep -oP '^[A-Z_]+' .env.example 2>/dev/null | sort -u)
4. Deployment Strategies
Choose the right strategy for the risk level. There is no universal best — each has tradeoffs.
Rolling Update (Default for Stateless Services)
New instances start, pass health checks, and begin receiving traffic. Old instances drain and shut down. At any point during the deploy, both old and new code are running simultaneously.
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
containers:
- name: api
readinessProbe:
httpGet:
path: /health/ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 8000
initialDelaySeconds: 15
periodSeconds: 20
terminationGracePeriodSeconds: 30
When to use: Standard deploys of stateless services with backward-compatible changes. Most deploys.
Risk: Old and new code run simultaneously. Database schema and API contracts must be backward-compatible.
Blue-Green (Zero-Downtime with Instant Rollback)
Run two identical environments. Deploy to the inactive one ("green"), verify, then switch traffic. Rollback is instant — switch traffic back to "blue."
gcloud run deploy my-service \
--image gcr.io/my-project/my-service:${NEW_SHA} \
--no-traffic \
--tag canary
curl -s https://canary---my-service-xxxxx.a.run.app/health/ready
gcloud run services update-traffic my-service --to-latest
gcloud run services update-traffic my-service \
--to-revisions=my-service-00042-abc=100
services:
blue:
image: myapp:current
ports: ["8001:8000"]
green:
image: myapp:${NEW_TAG}
ports: ["8002:8000"]
nginx:
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports: ["80:80"]
When to use: Critical services where rollback speed matters more than resource cost. Services with hard availability SLAs.
Cost: Requires 2x infrastructure during deploy window. Both environments must be fully functional.
Canary (High-Risk Changes)
Route a small percentage of traffic to the new version. Monitor error rates and latency. Gradually increase traffic if metrics are healthy.
apiVersion: networking.istio.io/v1
kind: VirtualService
spec:
hosts: ["my-service"]
http:
- route:
- destination:
host: my-service
subset: stable
weight: 95
- destination:
host: my-service
subset: canary
weight: 5
gcloud run services update-traffic my-service \
--to-revisions=my-service-00043-def=5,my-service-00042-abc=95
When to use: Large schema changes, major refactors, new integrations, anything where "it worked in staging" is not sufficient confidence.
Canary promotion criteria:
- Error rate within 1.5x of stable baseline for 15 minutes
- Latency p99 within 1.5x of stable baseline
- No new error types in logs
- No increase in pod restarts
Feature Flags (Gradual Rollout at Application Level)
Decouple deploy from release. Ship the code, then enable the feature gradually. This is the safest approach for user-facing changes.
import structlog
logger = structlog.get_logger()
def get_recommendations(user_id: str, feature_flags: FeatureFlags) -> list:
if feature_flags.is_enabled("new_recommendation_engine", user_id=user_id):