Nakodo has a maintenance mode. The whole of it is this file, src/lib/maintenance.ts, 15 lines:
// On while the database is down for maintenance; off again, and deployed, once
// it's back. While on:
// - the app and the sign-in pages show /maintenance instead (src/proxy.ts), so
// nobody can sign up, sign in or change anything;
// - the crons skip their runs (src/lib/cron.ts); the first run after it's off
// picks the work up where it was left;
// - Stripe's and Resend's webhooks answer 503, so they send the event again
// later, and the other endpoints that need the database answer 503 too;
// - creators' opt-out and unsubscribe links say to come back later.
// The public pages stay up: they never touch the database.
export const MAINTENANCE = false;
export function maintenanceResponse(): Response {
return new Response("Down for maintenance", { status: 503, headers: { "Retry-After": "600" } });
}
No feature flag service, no environment variable, no row in a table. A constant, read in ten places, and changing it is a commit and a deploy.
It has been true once, on 12 October 2026, for 23 minutes. Both flips are in the history:
8efb633 2026-10-12 15:05:09 Add maintenance mode for database downtime
5bd22d3 2026-10-12 15:28:33 Disable maintenance mode
The ten call sites
Writing a kill switch is easy. Deciding what it means is the work, and it means something slightly different in each of the ten places that read it.
The proxy, first, because it covers everything a browser asks for:
if (MAINTENANCE) {
const path = request.nextUrl.pathname;
if (isPublicPath(path) && !isSignInPath(path)) return NextResponse.next();
if (path.startsWith("/api/")) return maintenanceResponse();
return NextResponse.rewrite(new URL("/maintenance", request.url));
}
Three answers in three lines: public pages carry on, API routes get the 503, everything else is rewritten to a page that says we will be back. A rewrite rather than a redirect, so the URL the visitor came for stays in the address bar and works when they reload it later.
The cron jobs, through one function:
export async function cronSkipReason(): Promise<string | null> {
if (MAINTENANCE) return "maintenance";
try {
await withTimeout(db.execute(sql`select 1`), 10_000, () => new Error("timed out"));
return null;
} catch (e) {
console.error("cron: the database isn't answering, skipping this run", e);
return "database unreachable";
}
}
Two reasons to skip, and the flag is the cheap one: it costs no round trip. The important property is that a skipped run starts nothing, so nothing is left half done, and the queue is picked up by the next run as if the gap had not happened. Two of the four crons run every five minutes, so a 23-minute window is about nine skipped runs between them.
The two webhook endpoints, which get the 503 for a specific reason. Both Stripe and our mail provider retry a failed delivery with backoff, so a 503 means "ask again" and the event is not lost. A 200 with the body thrown away would be a lost subscription change or a lost bounce. This is the one place where answering with an error is the careful behaviour and answering successfully is the destructive one.
The two public pages behind email links, the opt-out page and the unsubscribe page, which render a different component:
if (MAINTENANCE) return <BackSoon />;
These are the ones I care most about, because the person clicking them did not choose a moment. They are 20 lines of "this link cannot do anything right now, open it again in a little while", with no form and no database read, and they are the reason the one-click endpoints answer 503 rather than pretending: a mail client that gets a 503 from a one-click unsubscribe will show the user that it failed, which is correct, instead of telling them they are unsubscribed when nothing was written.
The refactor it forced
The commit that added all this also changed a file that has nothing to do with maintenance, and that change is the most durable thing to come out of the feature:
const PUBLIC_PREFIXES = [
...
- "/login",
- "/forgot-password",
- "/reset-password",
- "/auth/",
];
+const SIGN_IN_PREFIXES = ["/login", "/forgot-password", "/reset-password", "/auth/"];
+
export function isPublicPath(path: string): boolean {
- return PUBLIC_EXACT.has(path) || PUBLIC_PREFIXES.some((p) => path.startsWith(p));
+ return PUBLIC_EXACT.has(path) || [...PUBLIC_PREFIXES, ...SIGN_IN_PREFIXES].some((p) => path.startsWith(p));
+}
+
+// The sign-in flow: public, but it needs the database.
+export function isSignInPath(path: string): boolean {
+ return SIGN_IN_PREFIXES.some((p) => path.startsWith(p));
}
Before that commit, "public" meant one thing in this codebase: no session required. The auth boundary only ever asked that question, so one list was enough.
Maintenance mode needs a different question: does this page need the database. Those two sets overlap but are not the same, and the login page is exactly where they come apart. It is public, anybody can open it, and it is useless while the database is down, because signing in writes a session and reads a profile.
So now there are two predicates. isPublicPath answers the auth question and still includes the sign-in pages. isSignInPath carves out the four prefixes that are public but database-dependent. The proxy's maintenance branch is the only caller that needs both, and the line reads exactly like the sentence that justifies it: public, and not sign-in.
I would not have found that distinction by thinking about it. I found it by needing the app to be half up.
The page that refuses to exist while the app is up
export default function MaintenancePage() {
if (!MAINTENANCE) redirect("/login");
...
The maintenance page guards itself, which means the flag is checked twice on the same request: once in the proxy to decide to rewrite there, once in the page to decide whether it is allowed to render.
That is not belt-and-braces for its own sake. Without the guard, /maintenance would be a real, reachable, indexable page claiming the app is down, at all times, for anybody who guessed the URL. With it, the URL only means anything during a window.
You can check the current state from outside:
$ curl -sI https://nakodo.app/maintenance | grep -i location
location: /login?next=%2Fmaintenance
That redirect is actually the other layer talking. /maintenance is not in the public path list, so a signed-out request to it is sent to the login page by the normal auth check before the page runs at all. A signed-in visitor gets there and the page's own guard sends them to the same place. Two different mechanisms, one destination, and either way the answer is "there is nothing here right now", which is the truth.
Why a constant and not a flag service
The obvious objection to a committed boolean is that turning it on requires a build. On this project that is a deploy of a couple of minutes, and it has a property I have come to want: the switch leaves a commit, with a timestamp and a message, in the same history as everything else. The two lines above are a complete incident record.
The alternatives I did not take, and why:
- an environment variable would also need a redeploy on our host for a running deployment to see it, so it costs the same couple of minutes and leaves no record;
- a flag in Redis would be instant, and would put the switch in a second service that can have its own bad day, while the thing we are defending against is the database being unavailable;
- a flag in the database is obviously circular.
What a constant cannot do is be flipped by somebody who cannot deploy, or be flipped automatically when the database goes down on its own. The second one is a real gap, and it is partly covered by the select 1 in cronSkipReason above: the background work stops by itself when the database stops answering, whether or not anybody set the flag. The pages behind the login do not, and a visitor during an unplanned outage gets an error boundary rather than the polite page. If I were to extend this, that is the piece I would build next, and it would not be a nicer switch: it would be the proxy noticing.
What stays up
The 76 pages in the sitemap are unaffected by any of this, and the schema comment that promises it is checkable:
$ grep -rl "@/db" "src/app/(marketing)" src/content | wc -l
0
Not one file under the marketing routes or the content directory imports the database. The guides, the pricing page, how it works and every niche page are built from arrays in the repository at deploy time, so they are not "up because the database is up", they are up because they have never needed it. That is what the proxy's first branch is protecting: during the 23 minutes in October the public half of the site was serving exactly what it serves now.
That is the real shape of a maintenance mode, and it is mostly not the switch. It is knowing, per route, which of the two questions you are asking: does this need a user, or does this need the data.
Top comments (0)