🚀 Production Readiness Checklist
Shipping to production is not a single git push — it's a promise to real users that your app will stay up, stay fast, and stay safe. This lesson turns that promise into a concrete, repeatable checklist you can run before every launch.
Week 13 · Day 5 (Friday: Production Deployment) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Separate configuration from code using environment variables and a secrets manager
- Add health checks, structured logging, and monitoring so you can see your app in production
- Harden an Express API with security headers, rate limiting, and centralized error handling
- Design backups, graceful shutdown, and autoscaling for reliability under load
- Wire a CI/CD pipeline with an explicit rollback plan you have actually tested
- Run a go-live checklist and make a confident Go / No-Go decision
Estimated Time: 75 minutes
Project: Turn a working Express + React app into a production-ready service by completing the readiness checklist.
In This Lesson
Why a Checklist?
In aviation, even veteran pilots read a printed checklist before every takeoff. Not because they've forgotten how to fly, but because the cost of a missed step is catastrophic and human memory is unreliable under pressure. Production deployment is the same: the difference between a smooth launch and a 3 a.m. incident is rarely cleverness — it's whether someone remembered to configure backups before the database filled up.
Development and staging forgive mistakes. Production does not. It serves real users, holds real data, and is watched by real attackers. A checklist converts the tribal knowledge of "things that bite you in production" into steps anyone on the team can follow — which is exactly what makes launches boring, and boring is the goal.
📖 The Twelve-Factor mindset
Many items below trace back to the Twelve-Factor App methodology: store config in the environment, treat logs as event streams, run the app as stateless processes, and keep dev/prod as similar as possible. You don't have to adopt all twelve rules dogmatically, but they explain why the checklist looks the way it does.
The Readiness Pipeline
Code doesn't leap from a laptop to production. It flows through gates, and each gate answers one question. If any gate says "no," the release stops. Here is the pipeline this lesson equips you to build.
pass?"} D -->|No| E["Fix and retry"] E --> B D -->|Yes| F["Production deploy"] F --> G["Health checks
and monitoring"] G --> H{"Healthy?"} H -->|No| I["Automated rollback"] H -->|Yes| J["Live to users"]
Notice the two decision points. The first is a human-and-machine gate before production; the second is an automated gate after deploy that can roll you back without waking anyone up. The rest of the lesson fills in each box.
💡 Categories to verify
A complete readiness review touches seven areas: configuration, observability (health/logs/metrics), security, reliability (backups, graceful shutdown), scalability (autoscaling, load), delivery (CI/CD), and recovery (rollback). Skip any one and you've left a gap an incident will find.
Config & Secrets
The single most common production bug is a hardcoded value that was fine on a laptop and wrong everywhere else — a localhost database URL, a test API key, a debug flag left on. The fix is a firm rule: configuration lives in the environment, never in the code.
Environment variables
// config.js — read once, validate, and export a typed object
const required = ['DATABASE_URL', 'JWT_SECRET', 'NODE_ENV'];
for (const key of required) {
if (!process.env[key]) {
// Fail fast: a missing secret should crash on boot, not at 2am
throw new Error(`Missing required environment variable: ${key}`);
}
}
export const config = {
env: process.env.NODE_ENV, // 'production' | 'staging' | 'development'
port: Number(process.env.PORT) || 3000,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
logLevel: process.env.LOG_LEVEL || 'info',
};
Validating config at startup is a small habit with a huge payoff: the process refuses to boot with a broken configuration, so the failure happens during deploy (visible, safe) instead of on the first user request (invisible, painful).
⚠️ Secrets are not environment config
A PORT is config; a database password is a secret. Never commit secrets to git (not even in a .env file — add it to .gitignore), and never bake them into a Docker image. Use a managed secrets store such as AWS Secrets Manager, HashiCorp Vault, or your platform's built-in "environment variables" UI (Render, Railway, Fly.io, Vercel). Rotate them on a schedule and immediately if one leaks.
Separate config per environment
| Variable | Development | Production |
|---|---|---|
NODE_ENV | development | production |
LOG_LEVEL | debug | info |
DATABASE_URL | local Postgres | managed cluster w/ SSL |
| Source maps | inline | uploaded to error tracker only |
Setting NODE_ENV=production is not cosmetic: Express disables verbose error pages, React strips dev warnings, and many libraries switch to faster code paths.
Health, Logging & Monitoring
You cannot fix what you cannot see. Observability is how a black-box process running on someone else's server becomes something you can reason about.
Health checks
A load balancer or orchestrator needs a cheap endpoint to ask "are you alive?" Distinguish two questions: liveness (is the process running at all?) and readiness (can it serve traffic right now — is the database reachable?).
// Liveness: dead simple, no dependencies. If this fails, restart me.
app.get('/healthz', (req, res) => res.status(200).send('ok'));
// Readiness: check the things a real request needs.
app.get('/readyz', async (req, res) => {
try {
await db.query('SELECT 1'); // can we reach the database?
res.status(200).json({ status: 'ready' });
} catch (err) {
// 503 tells the load balancer to stop sending traffic here
res.status(503).json({ status: 'not ready', reason: 'database' });
}
});
Structured logging
In production, logs are searched by machines, not read by humans scrolling a terminal. Emit JSON so your log platform can filter by field.
import pino from 'pino';
const logger = pino({ level: config.logLevel });
// Attach a request id so you can trace one user's journey across log lines
app.use((req, res, next) => {
req.log = logger.child({ requestId: crypto.randomUUID() });
next();
});
app.post('/orders', (req, res) => {
req.log.info({ userId: req.user.id, amount: req.body.total }, 'order created');
// never log secrets, passwords, tokens, or full card numbers
res.status(201).json({ ok: true });
});
✅ The three pillars
Logs tell you what happened (events). Metrics tell you how much and how fast (request rate, error rate, p95 latency). Traces tell you where the time went across services. Ship metrics to Prometheus/Grafana or a hosted APM, and set alerts on the ones that matter — error rate and latency first.
Security & Rate Limiting
The moment your app is public, it is scanned by bots within minutes. A few lines of middleware close the most common doors.
Security headers with Helmet
import helmet from 'helmet';
// Sets sensible security headers: HSTS, X-Content-Type-Options,
// X-Frame-Options, a baseline Content-Security-Policy, and more.
app.use(helmet());
// Only trust the proxy in front of you (needed for correct client IPs & HTTPS detection)
app.set('trust proxy', 1);
Rate limiting
Rate limiting protects login endpoints from brute force and shields your app from accidental or malicious traffic spikes.
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per IP per window
standardHeaders: true, // send RateLimit-* headers
message: { error: 'Too many requests, please try again later.' },
});
app.use('/api', apiLimiter);
Centralized error handling
Never leak stack traces to users — they reveal file paths, library versions, and logic. Funnel every error through one handler.
// Must be the LAST middleware, with four arguments so Express treats it as an error handler
app.use((err, req, res, next) => {
req.log.error({ err }, 'unhandled error'); // full detail in your logs
const status = err.status || 500;
res.status(status).json({
error: status === 500 ? 'Internal Server Error' : err.message, // generic to the client
});
});
⚠️ Security checklist quick-hits
- Serve everything over HTTPS and redirect HTTP → HTTPS (next lesson)
- Validate and sanitize all input; use parameterized queries / an ORM
- Run
npm auditin CI and patch known vulnerabilities - Set
httpOnly,secure, andsameSiteon session cookies
Reliability & Scaling
Backups you have actually restored
An untested backup is a hope, not a plan. Automate database snapshots, store them in a different region, encrypt them, and — critically — practice restoring one. Define your targets in plain numbers:
- RPO (Recovery Point Objective): how much data can you afford to lose? (e.g. 5 minutes → back up at least that often)
- RTO (Recovery Time Objective): how long can you be down while restoring? (e.g. 1 hour)
Graceful shutdown
When a deploy or autoscaler stops your process, it sends SIGTERM. If you exit immediately, in-flight requests are dropped and users see errors. Instead, stop accepting new connections, finish the ones you have, then exit.
const server = app.listen(config.port);
function shutdown(signal) {
logger.info({ signal }, 'shutting down gracefully');
server.close(async () => { // stop taking new requests, drain existing ones
await db.end(); // close the connection pool
logger.info('shutdown complete');
process.exit(0);
});
// Safety net: force-exit if draining hangs
setTimeout(() => process.exit(1), 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Autoscaling & load testing
Stateless processes scale horizontally: add more instances behind a load balancer as traffic rises, remove them when it falls. Configure autoscaling on a signal like CPU or request latency, and verify your assumptions with a load test before real traffic arrives.
# Kubernetes HorizontalPodAutoscaler — add pods when CPU passes 70%
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 2 # never fewer than 2 (survive one node dying)
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
💡 Why minReplicas ≥ 2
A single instance means a single point of failure and zero-downtime deploys become impossible. Two or more instances let the load balancer route around a crashing or updating pod. This is the cheapest reliability upgrade you can buy.
CI/CD & Rollback
Manual deploys are error-prone and unrepeatable. A CI/CD pipeline makes every release identical: the same tests run, the same artifact ships, and a bad release can be reverted with one command.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test # a failing test blocks the deploy
- run: npm run build
- name: Deploy
run: npm run deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
A rollback plan you tested
Every deploy strategy is really a rollback strategy. Keep the previous version ready so reverting is instant, not a rebuild:
- Blue-green: run the new version alongside the old; switch traffic, and flip back instantly if it misbehaves.
- Canary: send 5% of traffic to the new version first; promote only if error rates stay flat.
- Rolling: replace instances one at a time; halt on the first failed health check.
Whichever you choose, write down the exact command to roll back and run it once in staging so it isn't a mystery during a real incident.
✅ Immutable artifacts
Build once, deploy the same artifact everywhere. If staging runs image app:1.4.2 and it passes, production runs the identical app:1.4.2 — not a fresh rebuild that might pull different dependency versions. This is what makes a rollback trustworthy.
Practice & Quiz
🏋️ Exercise 1: A readiness health check
Goal: Write an Express /readyz handler that returns 200 only when both the database and a Redis cache respond, and 503 otherwise, without leaking internal error details.
app.get('/readyz', async (req, res) => {
// TODO: check db and redis; respond 200 ready or 503 not ready
});
💡 Hint
Run both checks with Promise.all so they happen in parallel. Wrap them in try/catch; on any failure respond 503 with a generic reason. Return a small JSON body, never the raw error object.
✅ Solution
app.get('/readyz', async (req, res) => {
try {
await Promise.all([
db.query('SELECT 1'),
redis.ping(),
]);
res.status(200).json({ status: 'ready' });
} catch (err) {
req.log.error({ err }, 'readiness check failed');
res.status(503).json({ status: 'not ready' });
}
});
🏋️ Exercise 2: Fail fast on missing config
Goal: Write a loadConfig() function that throws immediately if any required variable is missing, and coerces PORT to a number with a default of 3000.
✅ Solution
function loadConfig() {
const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter((k) => !process.env[k]);
if (missing.length) {
throw new Error(`Missing env vars: ${missing.join(', ')}`);
}
return {
port: Number(process.env.PORT) || 3000,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
};
}
🎯 Quick Quiz
Question 1: Where should a production database password live?
Question 2: On receiving SIGTERM, a well-behaved server should:
Question 3: What makes a backup trustworthy?
Best Practices & Pitfalls
✅ Do
- Validate config at startup and fail fast on anything missing
- Expose separate
/healthz(liveness) and/readyz(readiness) endpoints - Log structured JSON with a request id; alert on error rate and p95 latency
- Run at least two instances and test a rollback before you need one
- Automate backups and practice restoring them
❌ Don't
- Leak stack traces or internal errors to clients
- Deploy straight to production with no staging gate
- Rely on one big instance ("we'll scale later" becomes "we're down now")
- Log secrets, tokens, passwords, or full payment details
- Treat "it worked in dev" as evidence it will work in prod
⚠️ The silent NODE_ENV bug
# Forgetting this in production leaves dev-mode error pages and slow paths on:
NODE_ENV=production node server.js
Many frameworks only enable production optimizations and safe error output when NODE_ENV is exactly production. Double-check it on the running server, not just in your config file.
Summary
🎉 Key Takeaways
- Keep config in the environment and secrets in a secrets manager; validate at boot
- Observability — health checks, structured logs, metrics — lets you see and fix production
- Harden with Helmet, rate limiting, and centralized error handling
- Reliability comes from tested backups, graceful shutdown, and ≥2 instances
- A CI/CD pipeline plus a rehearsed rollback makes launches boring — which is the point
📚 Additional Resources
- The Twelve-Factor App
- Express — Production security best practices
- OWASP Top Ten
- Google SRE Books (free online)
🚀 What's Next?
Your checklist called for "serve everything over HTTPS" — but how does that padlock actually get there? The next lesson goes deep on SSL/TLS Certificates: what TLS does, how a certificate proves identity, and how to get free, auto-renewing certificates with Let's Encrypt.
🎉 Launch-ready thinking
You now think like an operator, not just a developer. Every box on this checklist is a 3 a.m. page you'll never receive.