DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our maintenance switch is 14 lines, and the interesting part is the three API routes it refuses to turn off

Notifio needed a database move, which meant a window where Postgres would not answer. The site sells a desktop app, the desktop app checks its licence against our server on a schedule, and Stripe sends us webhooks whenever somebody pays. All three of those were going to hit a dead database within minutes of the window opening.

What we built is a boolean and a middleware. The whole switch is this file:

/**
 * Maintenance switch. Flip to `true` and deploy to take everything that needs
 * the database offline cleanly: the API routes behind the desktop app and
 * checkout answer 503 with a Retry-After header (see src/proxy.ts), and the
 * checkout form on /pricing is replaced with the message below. Marketing pages,
 * downloads and the contact form stay up. Flip back to `false` and deploy to
 * restore, at which point /maintenance redirects home.
 */
export const MAINTENANCE = false;

export const MAINTENANCE_TITLE = "Down for maintenance";

export const MAINTENANCE_MESSAGE =
  "Notifio is down for maintenance. Please try again in a little while.";
Enter fullscreen mode Exit fullscreen mode

Three exports, fourteen lines including the comment. The decisions worth writing about are all in what it does not switch off.

A committed constant, not an environment variable

An env var would let us flip maintenance on without a deploy, which sounds strictly better and is the main argument against it. A constant means the state of the site is in the commit log, the diff is reviewable, and the mode cannot be left on by somebody poking a dashboard at midnight and forgetting.

It also means the compiler participates. MAINTENANCE is a const of literal type false, so the pricing page's maintenance branch and the message strings are all type-checked against something known at build time, and turning the switch on is a code change that fails the build if it breaks anything.

The cost is real: turning it on takes a deploy, which takes a minute or two. For a planned database move, a minute is nothing. If we ever need maintenance mode as an incident response tool, where the cause is unknown and the site is already on fire, this is the wrong shape and I would move it to an env var that day.

The matcher is two paths, so the marketing site never sees it

export const config = {
  matcher: ["/api/:path*", "/maintenance"],
};
Enter fullscreen mode Exit fullscreen mode

Everything else on the site, the home page, the fifteen per-portal alert pages, the guides, the comparison pages, is not matched at all. No middleware invocation, no edge function execution, no behaviour change.

That matters because the marketing pages are the ones that must survive a database outage. Somebody who arrives from a search result during the window should read the page, learn what the app does, and download it. None of that needs Postgres, so none of it should know the database is down.

Three routes stay up, and the list is a denial by default

/**
 * API routes that never touch the database, so they stay up during
 * maintenance: the download page's links, the help page's contact form, and
 * inbound email forwarding. Every other API route is refused, so a route added
 * later is safe by default.
 */
const NO_DATABASE_ROUTES = ["/api/download", "/api/contact", "/api/webhooks/resend"];

function needsDatabase(pathname: string): boolean {
  return !NO_DATABASE_ROUTES.some(
    (route) => pathname === route || pathname.startsWith(`${route}/`)
  );
}
Enter fullscreen mode Exit fullscreen mode

The polarity is the point. The list is of routes that stay up, and everything not on it is refused. Add a route next month that reads a table, forget this file exists, and it is already handled correctly. Had I written the list the other way round, as routes to refuse, the new route would quietly try to query a database that was not there and return a 500 to somebody mid-purchase.

The three exemptions each earn their place:

  • /api/download signs a short-lived URL and returns a 302 to object storage. No database involved, and it is the one action a visitor during the window can still usefully complete. The download page works throughout.
  • /api/contact rate limits against Redis and sends through Resend. The moment customers most want to tell us something is the moment the site is visibly broken, so the form on the help page has to work then above all other times. I wrote about its four defences in our contact form has no captcha.
  • /api/webhooks/resend is inbound email forwarding, and refusing it would mean bouncing or losing customer mail that arrived during the window. Mail is the one thing you cannot ask to be sent again.

The prefix half of the comparison, startsWith on the route plus a slash, matters for the first one, because the real path is /api/download/mac-arm64, not /api/download. Exact match plus prefix match, so a path cannot slip through by having a parameter on it.

The refusal is a 503 with Retry-After, and three clients read it

if (MAINTENANCE && needsDatabase(pathname)) {
  return NextResponse.json(
    { error: MAINTENANCE_MESSAGE },
    { status: 503, headers: { "Retry-After": String(RETRY_AFTER_SECONDS) } }
  );
}
Enter fullscreen mode Exit fullscreen mode

RETRY_AFTER_SECONDS is 600. The status code is the entire interface here, and three very different clients have to agree about what it means.

Stripe retries webhook deliveries on 5xx and treats 4xx as "received, do not send again". A 400 or a 410 during the window would mean a payment whose webhook we acknowledged and never processed, which is a customer who paid and has no licence. A 503 gets redelivered after the window closes.

The desktop app asks our server whether a licence is valid. The thing that mattered was that a 503 must not be read as "no":

if (!res.ok) {
  // Only a considered "no" from the server counts as one.
  if (res.status >= 500 || res.status === 408 || res.status === 429) return null;
  return { valid: false, autoReply: false };
}
Enter fullscreen mode Exit fullscreen mode

null means "we do not know", and the layer above it falls back to the cached entitlement flagged as stale, because a purchase already made does not lapse because we could not phone home. We learned that distinction the hard way from our own rate limiter, which I wrote up in our own rate limiter answered "valid: false". Maintenance mode is the same lesson arriving from a different direction: the server declining to answer is not the server answering.

Browsers never see the 503 on the checkout path, because the pricing page handles the state in the UI before anybody can click:

{MAINTENANCE ? (
  <div>
    <p>{MAINTENANCE_TITLE}</p>
    <p>{MAINTENANCE_MESSAGE}</p>
  </div>
) : (
  /* the checkout button */
)}
Enter fullscreen mode Exit fullscreen mode

A visible "Buy now" button that fails after the click is worse than no button, because the failure arrives after the decision. So the pricing page swaps the checkout card for the message, and the prices stay on screen, since the price is a constant in the repository and does not come from the database either.

The one route that exists only when it is needed

// The site has no login page, so outside maintenance this goes home.
if (pathname === "/maintenance") {
  return MAINTENANCE ? NextResponse.next() : NextResponse.redirect(new URL("/", request.url));
}
Enter fullscreen mode Exit fullscreen mode

The page exists as a real route, so it can be linked and shown. Outside the window it redirects:

$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://notifio.app/maintenance
307 https://notifio.app/
Enter fullscreen mode Exit fullscreen mode

The alternative, leaving a reachable "Down for maintenance" page sitting on a healthy site, is a page that one day ends up in search results or in a support reply, telling somebody the site is down while it is up. It also carries robots: { index: false, follow: false } in its metadata, which is belt and braces: the 307 means a crawler rarely gets as far as the meta tag, and the tag means that if it ever does, the page cannot be indexed.

One thing I would change: there is no way to see the maintenance page while the switch is off, so the only way to check it still renders is to turn maintenance on. A query parameter escape hatch would fix that, and it would also be a way for anybody to make our site claim to be down, so for now the page gets eyeballed on a preview deploy instead.

Top comments (0)