Skip to content
Chapter 98Lesson 8

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.

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.

Live is a subset of launched. The left card is everything 'the homepage renders' gives you; the right card adds the nine verified safety nets that make the URL defensible. The checklist is what turns the left into the right.

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.

Env validation passed in the production build; SKIP_ENV_VALIDATION is not set.
untested
Error monitoring receives a deliberate test exception within seconds, with a readable stack trace.
untested
Auth endpoints return 429 after their rate-limit threshold.
untested
A privileged action writes a fresh row to audit_logs.
untested
curl -sI on the production URL returns all six security headers.
untested
Drizzle uses Neon’s pooled connection string; the function region matches the DB region.
untested
Restore history is set to an adequate window, and one test restore has been performed.
untested
An external uptime monitor pings /api/health and pages a real human on failure.
untested
docs/runbooks/ holds a one-pager for rollback, restore, and credential rotation.
untested

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.

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:

src/app/api/debug-sentry/route.ts
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:

Terminal window
# 50 requests, 5 concurrent, POSTed at the sign-in endpoint
oha -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/email

The 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.

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.

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:

Terminal window
curl -sI https://app.example.com

Look 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.

Uptime monitor External ping that pages a human
Error monitoring Catches exceptions the app reports
Rate limit Caps requests per client
Audit log Records who did what, when
A deploy fails to boot and every user gets nothing at 3 AM — nobody notices for two hours.
The app process is hung: the database is up, but requests never return.
A null-pointer bug throws on the checkout page for 2% of users; the page still loads for everyone else.
A third-party API your app calls starts returning 500s, and your code surfaces a generic error.
An attacker scripts 10,000 sign-in attempts against one account overnight.
A customer disputes that anyone changed their plan, and you need to prove who did and when.
You can’t tell which admin removed a user from an organization last month.

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.

1 / 1

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.

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.

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.