The launch checklist
A nine-row pre-launch checklist that verifies each safety net you shipped is live in production, plus the health endpoint an uptime monitor pings.
Your production URL went live earlier in this chapter: the homepage renders, the custom domain resolves over HTTPS, the function sits next to the database, and a git push to main deploys. So here is the harder question: it renders, but is the product launched?
“Live” means the server answers. “Launched” means the URL is defensible: when something breaks, the app degrades instead of falling over. It doesn’t leak data while it fails, doesn’t collapse under a load spike, and has a human who finds out the failure happened. A live URL promises none of that; a launched one promises all of it.
You have already built almost every one of those nets: environment validation, error monitoring, rate limits, audit logs, security headers, connection pooling, and database restore. What’s missing is confirming each is actually live in production, because a net you wired in development and never verified there is, for safety purposes, a net you don’t have.
So this lesson’s deliverable is a checklist: nine rows, each a sixty-second check against your own deploy. Eight verify machinery you already shipped; one is new code, a health endpoint you’ll write here for an uptime monitor to ping. Run the list, and you’ll know whether your URL is merely live or launched.
The mindset behind the checklist
Section titled “The mindset behind the checklist”A new building gets an occupancy inspection before it opens: someone confirms the smoke detectors fire, the exits open from the inside, and the locks lock. Your URL going live turns the lights on; the checklist is the inspection. “It renders” tells you nothing about what happens when there’s smoke.
Two principles run through every row. The first: a safety net nobody reads is not a safety net. An error monitor no human watches, an audit log nobody queries, an uptime check that pages a dead phone, each is wired and each is useless. Three of the nine rows are inert without a person on the other end, so the human side gets its own section later.
The second: the checklist is structural, not ceremonial. Every row maps to one concrete, observable check, and an unchecked row means the app is not launched, however good the homepage looks.
The nine-row launch checklist
Section titled “The nine-row launch checklist”Tick each row as you verify it against your own production deploy; the ticks persist across reloads. Eight rows are hard requirements; the ninth is soft.
SKIP_ENV_VALIDATION is not set.429 after their rate-limit threshold.audit_logs.curl -sI on the production URL returns all six security headers./api/health and pages a real human on failure.docs/runbooks/ holds a one-pager for rollback, restore, and credential rotation.Each row holds a single observable outcome; the teaching is below. Every row follows the same shape: what it protects, how you verify it, where it was wired in (so you can jump back if it’s failing), and what you lose if you skip it. Verification is usually a curl, a SQL query, a dashboard glance, or one deliberate test failure. Apart from the health endpoint, you build none of it here.
Row 1: Env validation green in production
Section titled “Row 1: Env validation green in production”Protects: the app booting with every required secret present. Verify: open the production build log, confirm the env validator ran and passed, and confirm SKIP_ENV_VALIDATION is not set in production. That flag is an escape hatch for local tooling, never for a real build. Wired in: the env-var lesson earlier in this chapter set the production scope; the validator (@t3-oss/env-nextjs plus a Zod schema in env.ts) has been failing the build on a missing required var since you set up the database. Skip it and: a missing variable stops being a caught build failure and becomes an opaque runtime crash on the first real request.
Row 2: Error monitoring wired and receiving
Section titled “Row 2: Error monitoring wired and receiving”Protects: the team seeing the exceptions the app reports. Verify: confirm your error monitor (Sentry, from the observability unit) is initialized in instrumentation.ts, throw a deliberate test error in production, and watch it land in the dashboard within seconds. Confirm the source maps uploaded too, so the trace points at your TypeScript rather than minified code.
The smallest way to fire that error is a throwaway route handler you deploy, hit once, and delete:
export const GET = async () => { throw new Error('Sentry test error');};Hit the URL once in the browser, confirm the event lands, then remove the route; a deliberate-crash endpoint is never something you leave live.
Wired in: the observability unit. instrumentation.ts is Next.js 16’s server-startup hook, where the monitor gets initialized; this row only confirms it fires. Skip it and: exceptions don’t stop happening, they stop being seen, compounding silently until a customer emails you about one.
Row 3: Rate limits live on the abuse surface
Section titled “Row 3: Rate limits live on the abuse surface”Protects: your auth endpoints from credential stuffing on day one. Verify: confirm sign-in, sign-up, password-reset, and magic-link all run through the Upstash safeLimit wrapper, then send a burst of requests at one and watch the 429s start after the threshold. The cleanest way to generate that load is oha, a Rust load generator with a live terminal dashboard:
# 50 requests, 5 concurrent, POSTed at the sign-in endpointoha -m POST \ -H 'content-type: application/json' \ -d '{"email":"x@example.com","password":"wrong-on-purpose"}' \ -n 50 -c 5 https://app.example.com/api/auth/sign-in/emailThe method and body matter. /api/auth/sign-in/email is a POST-only handler, so a bare oha <URL> would fire GETs and get back nothing but 405s, never reaching the limiter. In the oha summary, watch the status codes flip from 400/401 (wrong-password rejections) to 429 once you cross the threshold. That flip is the proof the limit is live, not merely configured in code. Without oha, hey -m POST or a plain curl loop generate the same load.
Wired in: the rate-limiting unit, where safeLimit and the dual-key Upstash limiters were built. Skip it and: an auth endpoint with no rate limiting becomes a target the moment it’s public. Credential stuffing is automated and indiscriminate, and it finds new domains within hours.
Row 4: Audit logs writing
Section titled “Row 4: Audit logs writing”Protects: your ability to answer “who did this, and when?” for privileged actions, the question compliance asks in an audit and the one you ask yourself during an incident. Verify: confirm every privileged action (membership and role changes, billing changes, data exports) writes a row, then query production directly:
select * from audit_logs order by created_at desc limit 10;Perform one privileged action, such as flipping a teammate’s role, and re-run the query; a fresh row should appear at the top. Wired in: the organizations-and-RBAC unit, where the audit_logs table and the logAudit writer were built. logAudit runs inside the same transaction as the action it records, so the log row and the change commit together or not at all. Skip it and: the day someone asks who changed a customer’s plan last month, “we don’t log that” is not an answer you want to give.
Row 5: Security headers set
Section titled “Row 5: Security headers set”Protects: the browser refusing a whole class of attacks (clickjacking, MIME-sniffing, protocol downgrade, script injection) before your code runs. Verify: curl the production URL with headers only and confirm all six are present:
curl -sI https://app.example.comLook for Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, and X-Frame-Options. Optionally, paste the URL into securityheaders.com for a letter grade. Wired in: the security-baseline unit owns these, both the five static headers in next.config.ts and the per-request nonce CSP in proxy.ts; this row only verifies they’re present. Skip it and: a response that looks correct in the browser is still frameable, sniffable, and downgradable. Vercel adds none of these by default, and an empty next.config.ts ships zero.
Row 6: Pooled DB connection with matching region
Section titled “Row 6: Pooled DB connection with matching region”Protects: the database surviving real load, and your queries not paying a cross-country tax on every call. Verify: confirm Drizzle connects through Neon’s pooled string (spot the -pooler segment in the hostname), and confirm the production function region matches the Neon database region. Wired in: the region match is the one knob you set deliberately in the region-and-runtime lesson; the pooled connection is the default export of the db client from the Postgres-and-Drizzle unit. Skip it and: two failures, both invisible in local dev. Unpooled connections exhaust Postgres’s connection limit under load, and the app starts refusing queries; a region mismatch adds roughly 80 ms to every query, which your average might hide but your p95 won’t.
Row 7: Restore history on and a test restore performed
Section titled “Row 7: Restore history on and a test restore performed”Protects: your ability to recover from data loss: a bad migration, a fat-fingered delete, a corrupted batch job. Verify: confirm Neon’s instant-restore history window is set to an adequate retention (the default is one day on paid plans; for production, raise it toward seven days or more), then perform at least one test restore to a Neon branch and confirm the data is intact. The test restore is the part that matters.
Wired in: the Postgres-and-Drizzle unit provisioned the database on Neon, and the test restore uses the same branching as the preview-branch lesson earlier in this chapter. Neon’s recovery model is instant restore (also called point-in-time restore), not a nightly dump you reload. Skip it and: when data loss happens, usually from your own code rather than Neon’s failure, you’ll discover whether restore works at the worst possible moment.
Row 8: External uptime monitor that pages a human
Section titled “Row 8: External uptime monitor that pages a human”Protects: catching the app being down entirely, the one failure your error monitor structurally cannot report, because the app has to be running to report anything. Verify: confirm an external monitor pings /api/health (built in the next section) every minute or so and pages on failure, then confirm that page reaches a real human. The current default is Better Stack, which bundles uptime checks, on-call scheduling, and escalation, so “page a human” is built in. Pingdom, UptimeRobot, and OnlineOrNot are reasonable alternatives.
Wired in: partly here, since /api/health is the one piece of new code in this lesson, and partly an external SaaS you sign up for. Skip it and: your app can be hard-down, returning nothing to every customer, with zero alerts firing, because nothing inside it is alive to notice. You find out when someone tweets at you.
Row 9: Runbooks for the top three incidents
Section titled “Row 9: Runbooks for the top three incidents”The one soft row. Protects: the person responding to an incident at 2 AM, who needs a checklist to follow rather than a memory test under stress. Verify: confirm docs/runbooks/ holds a short markdown file, under one page each, for the three most likely incidents: production rollback, database restore, and credential rotation. Wired in: the rollback runbook was named in the rollback lesson earlier in this chapter, and credential rotation in the security-baseline unit. Proper runbook templates are the documentation unit’s job, which is why this row is soft: it checks that the files exist, not that they’re polished. Skip it and: every incident becomes improvisation by a stressed human at the worst hour, reconstructing steps from memory. A half-page runbook written calmly beats perfect recall under pressure.
The next drill makes the mapping stick. The reflex this checklist builds is symptom to net: when something goes wrong, which row would have caught or prevented it? Sort each failure scenario into the safety net that addresses it.
Each scenario below is a production failure. Drag it into the safety net that would have caught or prevented it. Drag each item into the bucket it belongs to, then press Check.
The health endpoint the monitor pings
Section titled “The health endpoint the monitor pings”Row 8’s uptime monitor needs something to ping. The obvious candidate, “does the homepage return 200?”, is a weak signal. A Next.js page can render perfectly while the database behind it is unreachable: the static shell streams, the 200 goes out, and your monitor stays green while every data-driven action quietly fails. A homepage 200 proves the web server is alive, not that the app can do its job.
So you ship a dedicated endpoint that checks the one dependency the app can’t function without: the database. It runs a trivial query and returns 200 if the query succeeds, 503 if it throws. About ten lines, no authentication.
import { sql } from 'drizzle-orm';import { NextResponse } from 'next/server';import { db } from '@/db';
export const GET = async () => { try { await db.execute(sql`select 1`); return NextResponse.json({ status: 'ok' }); } catch { return NextResponse.json({ status: 'degraded' }, { status: 503 }); }};A route handler, not a Server Action, because the caller is a non-browser client. An uptime monitor pinging an endpoint is exactly the case our conventions reach past a Server Action for. The handler is a named GET export.
import { sql } from 'drizzle-orm';import { NextResponse } from 'next/server';import { db } from '@/db';
export const GET = async () => { try { await db.execute(sql`select 1`); return NextResponse.json({ status: 'ok' }); } catch { return NextResponse.json({ status: 'degraded' }, { status: 503 }); }};The liveness probe. A select 1 is the cheapest “is Postgres answering?” query: it touches no tables and returns instantly. The try/catch does the real work: an unreachable database throws, and the catch turns that throw into a reported failure instead of letting it escape.
import { sql } from 'drizzle-orm';import { NextResponse } from 'next/server';import { db } from '@/db';
export const GET = async () => { try { await db.execute(sql`select 1`); return NextResponse.json({ status: 'ok' }); } catch { return NextResponse.json({ status: 'degraded' }, { status: 503 }); }};The status split. A healthy database returns 200 { status: 'ok' }; a caught failure returns 503 { status: 'degraded' }. An always-200 check confirms only that the process is alive; this one also confirms its critical dependency. The 503 is what trips the monitor.
Two deliberate restraints. The response body says nothing specific: degraded, not the connection string or the error message, because a public, unauthenticated endpoint must never leak how it’s wired. And it stays cheap, because the monitor hits it every minute forever; a select 1 is free where five real queries would be a self-inflicted load.
The human side of monitoring
Section titled “The human side of monitoring”Three of the nine rows (error monitoring, audit logs, and uptime) are inert without a human on the other end. Wiring the alert is half the job; the alert reaching someone who acts on it is the other half, and that half shows up in no curl.
Start with routing. An alert has to land where a human looks, which means two destinations: a Slack channel someone reads within the hour during business hours, and an on-call page that wakes someone outside them. This is why uptime tools like Better Stack earn their price, since they make “page a human” a configured rotation rather than a hope that the right person is watching Slack at 3 AM.
Then comes escalation, which has to be explicit. Name who is on-call right now, and name what happens when they don’t acknowledge a page: it goes to the next person, then to the whole team. An alert with no escalation path dies silently when the one person it targets is asleep with their phone face-down.
Finally, the first week is different. Most launch problems surface in the first seventy-two hours, when real traffic hits paths your tests never exercised. For those three days, spend a few minutes daily actively watching the dashboards rather than waiting for an alert: the new-error count, the audit log growing as expected, the rate-limit dashboard, and the function error rate. After that, the alerts you tuned take over.
Re-run the checklist, don’t frame it
Section titled “Re-run the checklist, don’t frame it”A green run is not a launch-day trophy; it’s a recurring inspection. Re-run the whole list quarterly, and watch the dashboards daily through the first week. Treat any row that was green but isn’t anymore as a regression: a security header dropped by a config change, a rate limit that stopped firing after a refactor. A net that quietly came down is more dangerous than one you knew was never up.
One thing this checklist treats as a black box is the database schema. It verifies that your database is pooled, region-matched, and restorable, but says nothing about how you change its shape once real customer data is in it. Adding, renaming, or dropping a column against a live database without an outage is its own discipline, the expand-migrate-contract cadence of the next chapter.