The Production Readiness Checklist
Configuration and secrets are locked down
The most common first-launch mistake isn't a code bug. It's a config that quietly behaved differently than everyone assumed. A .env file with production credentials committed by accident. A CORS policy left wide open because it was easier during development. A database connection pool sized for a laptop, not for concurrent traffic.
Before launch, walk through every environment variable your app reads and confirm three things:
- It has a real value in production.
- That value came from a secrets manager rather than a plaintext file in the repo.
- There's a documented fallback (or a hard failure) if it's missing.
import { z } from ' zod ' ;
const envSchema = z . object ({
NODE_ENV : z . enum ([ ' development ' , ' staging ' , ' production ' ]),
DATABASE_URL : z . string (). url (),
REDIS_URL : z . string (). url (),
JWT_SECRET : z . string (). min ( 32 ),
CORS_ORIGIN : z . string (). url (),
SENTRY_DSN : z . string (). url (). optional (),
});
export type Env = z . infer < typeof envSchema > ;
export function validateEnv ( config : Record < string , unknown > ): Env {
const result = envSchema . safeParse ( config );
if ( ! result . success ) {
console . error ( ' Invalid environment configuration: ' , result . error . format ());
process . exit ( 1 );
}
return result . data ;
}
Wiring this into NestJS's ConfigModule at bootstrap means a missing or malformed secret fails the deploy. It doesn't fail silently at 2pm on a Tuesday when a customer hits the one code path that touches it. The tradeoff is a slightly stricter local dev setup, since every new environment variable now needs a schema entry. That's a cost worth paying; a schema that drifts from reality is a bug waiting to be discovered by a user, not by CI.
The pitfall I see most often here is treating staging and production as identical except for the URL. They rarely are. Different rate limits on a third-party API, different database sizes, different feature flags. Document the differences explicitly rather than assuming staging behavior is a preview of production behavior.
Health, readiness, and graceful shutdown
Your orchestrator, whether it's ECS, Kubernetes, or a plain load balancer, needs a reliable way to know if an instance can take traffic. Without it, a deploy can route requests to a container that's still booting, or keep sending traffic to one that's stuck. The three things worth having are:
- A liveness check (is the process alive).
- A readiness check (can it actually serve requests - meaning the database and cache connections are up).
- A graceful shutdown handler that stops accepting new work before the process exits.
async function bootstrap () {
const app = await NestFactory . create ( AppModule );
app . enableShutdownHooks ();
const server = await app . listen ( process . env . PORT ?? 3000 );
process . on ( ' SIGTERM ' , async () => {
console . log ( ' SIGTERM received, draining connections ' );
await app . close ();
server . close (() => process . exit ( 0 ));
});
}
bootstrap ();
Without this, a rolling deploy or an autoscaler scaling down can kill an instance mid-request, and the client gets a connection reset instead of a response. The fix costs almost nothing to implement. It removes an entire class of intermittent, hard-to-reproduce errors that show up as noise in your error tracker rather than as a clear signal.
Backups exist and, more importantly, restores are tested
Every team says they have backups. Far fewer have actually restored from one. A backup you've never restored is a hypothesis, not a safety net. Automated snapshots from a managed Postgres provider are a reasonable starting point, but they don't protect you from a bad migration that corrupts data quietly over a week, or a schema change that makes an old backup incompatible with current application code.
The practice worth adopting before launch is a quarterly restore drill: take a recent backup, restore it to a scratch environment, and run your application's smoke tests against it. This surfaces two things people usually get wrong:
- A restore that silently drops a table due to permission issues.
- A restore time that's much longer than anyone assumed - which matters a great deal when you're calculating an acceptable recovery time objective.
pg_restore \
--dbname = " $SCRATCH_DATABASE_URL " \
--clean \
--if-exists \
--no-owner \
--jobs = 4 \
./backups/latest.dump
The tradeoff is time. A quarterly drill takes an afternoon and produces nothing customer-visible, which makes it easy to deprioritize against feature work. I'd argue that's exactly why it needs to be scheduled rather than left to "whenever there's time," because that time never quite arrives on its own.
Observability that answers "what broke" in minutes, not hours
By the time you're running in production, you should already have metrics, structured logs, and error tracking wired up from earlier articles in this module. The readiness question here is narrower: when something breaks, can an on-call engineer go from alert to root cause without pulling in three other people?
That means:
- Alerts are tied to symptoms a customer would notice (elevated error rate, rising latency, a queue backing up) rather than to infrastructure internals nobody can act on at 3am.
- Logs need to carry a request ID that ties a customer-facing error back to the exact backend trace that produced it.
- The on-call rotation needs real access to the dashboards and log queries they'd require - tested before the first real incident rather than during it.
import { Injectable , NestMiddleware } from ' @nestjs/common ' ;
import { randomUUID } from ' crypto ' ;
import { Request , Response , NextFunction } from ' express ' ;
@ Injectable ()
export class RequestIdMiddleware implements NestMiddleware {
use ( req : Request , res : Response , next : NextFunction ) {
const requestId = ( req . headers [ ' x-request-id ' ] as string ) ?? randomUUID ();
req . headers [ ' x-request-id ' ] = requestId ;
res . setHeader ( ' x-request-id ' , requestId );
next ();
}
}
A pitfall worth naming directly: alert fatigue. A team that gets paged for every 500 error, including expected ones like a client submitting invalid input, learns to ignore alerts within a month. Reserve pages for things that need a human right now, and route everything else to a dashboard someone checks during business hours.
A rollback path that doesn't require a hero
Launch day confidence usually comes from "we tested it in staging," which is necessary but not sufficient. The question a production readiness review should force is: if this release breaks something for real users an hour after deploy, what's the actual sequence of commands to get back to the last known good state, and has anyone run that sequence before?
For a stateless application, rolling back the deployed image is usually fast if your CI/CD pipeline tags releases by commit SHA and your orchestrator supports a one-command rollback. The harder case is a release that shipped alongside a database migration. If the migration is backward compatible - meaning the old application version still works against the new schema - a rollback is safe. If it isn't, you're stuck rolling forward with a fix instead, which takes longer and is riskier under pressure.
-- Backward compatible: old code ignores the new nullable column
ALTER TABLE organizations ADD COLUMN plan_tier TEXT ;
-- Not backward compatible: old code will fail on the NOT NULL constraint
ALTER TABLE organizations ADD COLUMN plan_tier TEXT NOT NULL ;
The practical rule is to ship schema changes in two steps: add the column nullable, deploy, backfill, then add the constraint in a later deploy once the old code path is gone. It's more deploys for the same feature, and that's the cost. What you get in exchange is a rollback option that actually works, which matters far more the one time you need it than it costs on every other release.
Security basics that are easy to defer and expensive to skip
None of these are exotic:
- Rate limiting on authentication and password reset endpoints, so a credential-stuffing attempt doesn't take down your database.
- Dependency scanning in CI, so a known vulnerability doesn't sit in production for months because nobody was watching.
- HTTPS enforced everywhere, including internal service-to-service calls if they cross a network boundary you don't fully control.
- Least-privilege IAM roles, so a compromised container doesn't have write access to every S3 bucket in the account.
# GitHub Actions: dependency vulnerability scan on every PR
name : security-audit
on : [ pull_request ]
jobs :
audit :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : ' 20'
- run : npm ci
- run : npm audit --audit-level=high
The tradeoff with npm audit --audit-level=high gating a PR is the occasional false positive: a vulnerability in a transitive dependency that doesn't actually apply to how you use the package. That's a legitimate annoyance, and the fix is an explicit, reviewed suppression list, not disabling the check.
Key takeaways
- Config validation that fails fast at boot catches an entire category of "worked in staging, broke in production" incidents before they reach a user.
- Health checks and graceful shutdown remove intermittent errors caused by deploys and autoscaling, not just visible outages.
- A backup nobody has restored is unverified. Schedule restore drills; don't wait for an incident to test them.
- Observability should be built around what a human needs to diagnose an incident in minutes, with alerts tied to customer-visible symptoms.
- A rollback plan is only real if it's been exercised, and backward-compatible migrations are what make rollback possible at all.
- Security basics (rate limiting, dependency scanning, least privilege) are cheap before launch and expensive to retrofit after an incident.
FAQ
What does "production ready" actually mean for a SaaS?
It means the failure modes that turn a small bug into a long outage have been addressed deliberately: validated configuration, health checks, tested backups, actionable alerts, and a working rollback path. It doesn't mean every edge case is handled; it means the team knows which ones aren't, on purpose.
How often should I test my backup restore process?
Quarterly is a reasonable baseline for most SaaS products. The exact cadence matters less than making it a scheduled, repeatable drill rather than something that only happens after an incident forces it.
Should every database migration be backward compatible?
Ideally, yes, especially for anything running multiple instances or doing rolling deploys. Splitting a schema change into an additive step and a later cleanup step costs an extra deploy but keeps rollback available if the release causes problems.
How do I avoid alert fatigue without missing real incidents?
Page only for symptoms a customer would notice: elevated error rates, rising latency, a queue backing up. Route lower-severity signals to a dashboard reviewed during business hours instead of paging for every anomaly.
Do I need a full incident response process before launching?
You need the minimum version: someone is on call, they have access to logs and dashboards, and there's a documented rollback procedure. A formal postmortem template and severity taxonomy can come once you've had a few real incidents to learn from.
Is a production readiness checklist a one-time gate or an ongoing practice?
Treat the first pass as a gate before launch, then revisit the checklist before any major architectural change: a new region, a new data store, a significant traffic increase. The checklist doesn't change much, but what counts as "done" against it does as the system grows.
Further reading
- Previous: Performance Tuning Before Launch
- Start of series: Choosing the Right Tech Stack for Your SaaS
About the Author
Hi, I'm Aman Singh - Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications. I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.
Portfolio: https://amanksingh.com
GitHub: https://github.com/amansingh1501
Product: https://vowerole.com
Email: am********@gmail.com
Comments
No comments yet. Start the discussion.