Maintenance mode (maintenance)
Take the API down for maintenance, or make it read-only, without a deploy. Admins switch modes at runtime; the state lives in MongoDB and every instance follows it within a few seconds. Blocked requests get 503 with Retry-After and a message your frontend can show, admins and allowlisted IPs keep working, health checks and devtools are never affected, and maintenance windows can be scheduled ahead with a public status route for banners.
npx calm add maintenanceAdds#
| Files | app/blocks/maintenance/index.js |
| Environment | MAINTENANCE_MODE (optional): maintenance or read-only forces that mode without the database. |
| Routes | GET /api/maintenance, GET /api/admin/maintenance, PATCH /api/admin/maintenance, POST /api/admin/maintenance/windows, DELETE /api/admin/maintenance/windows/:windowId |
| Collections | calm_maintenance (one document) |
Modes#
| Mode | What happens |
|---|---|
off | Nothing. |
maintenance | Every API route answers 503 MAINTENANCE. |
read-only | GET, HEAD and OPTIONS work; every other method answers 503 READ_ONLY. |
503 Retry-After: 300
{ "error": { "code": "MAINTENANCE", "message": "We are doing some maintenance and will be back shortly.", "requestId": "..." } }Retry-After is the seconds until the window (or until) ends, otherwise retryAfterSeconds (300).
Always let through:
- users with a role in
bypassRoles(admin), in both modes; - IPs in
allowIps(options) or stored by admins, exact addresses or IPv4 ranges such as10.0.0.0/8; GET /api/maintenance,POST /api/auth/loginandPOST /api/auth/logout(so admins can sign in), plus the routes inallow;/health/live,/health/readyand the devtools: they are mounted before the API and never see this block.
Switching#
PATCH /api/admin/maintenance
{ "mode": "maintenance", "message": "Upgrading the database, back at 14:00 UTC", "until": "2026-10-13T14:00:00Z" }| Field | |
|---|---|
mode | off, maintenance or read-only |
message | Shown in the 503. null goes back to the default message of the mode. |
retryAfterSeconds | Retry-After when there is no end time (1 to 86400). |
until | The switch ends on its own at this time (must be in the future). Switching off clears it. |
allowIps | IPs or IPv4 ranges let through, e.g. your office. |
The answer (and GET /api/admin/maintenance) is the full state:
{
"data": {
"mode": "maintenance", "source": "manual", "message": "Upgrading the database, back at 14:00 UTC",
"until": "2026-10-13T14:00:00.000Z", "retryAfterSeconds": 1740,
"stored": { "mode": "maintenance", "message": "...", "retryAfterSeconds": null, "until": "...", "allowIps": [], "updatedAt": "...", "updatedBy": "66f0..." },
"windows": [], "cacheMs": 5000
}
}source says why the mode applies: manual, window or env.
Scheduled windows#
POST /api/admin/maintenance/windows
{ "start": "2026-10-20T02:00:00Z", "end": "2026-10-20T03:00:00Z", "mode": "read-only", "message": "Planned database upgrade" }Windows start and end on time (they are checked against the clock on every request). A manual mode takes precedence over windows. Cancel one with DELETE /api/admin/maintenance/windows/:windowId; ended windows are cleaned up on the next change.
Banners#
GET /api/maintenance is public and never cached:
{ "data": { "mode": "off", "message": null, "until": null, "retryAfterSeconds": null, "upcoming": [{ "start": "...", "end": "...", "mode": "read-only", "message": "Planned database upgrade" }] } }const { data } = await (await fetch('/api/maintenance')).json();
if (data.mode !== 'off') showBanner(data.message);
else if (data.upcoming[0]) showBanner(`Planned maintenance on ${new Date(data.upcoming[0].start).toLocaleString()}`);
// and wherever you call the API
if (res.status === 503 && ['MAINTENANCE', 'READ_ONLY'].includes(body.error?.code)) showBanner(body.error.message, { retryIn: Number(res.headers.get('retry-after')) });Options#
export default defineConfig({
blocks: { maintenance: { cacheMs: 2000, allowIps: ['203.0.113.7'], allow: ['POST /webhooks/stripe'] } },
});| Option | Default | What it does |
|---|---|---|
cacheMs | 5000 | How long an instance trusts its copy of the state. All instances agree within this delay; the instance that made the change applies it at once. |
retryAfterSeconds | 300 | Retry-After when no end time is known. |
message | a short default | The 503 MAINTENANCE message when admins set none. |
readOnlyMessage | a short default | The 503 READ_ONLY message when admins set none. |
allowIps | [] | IPs and IPv4 ranges always let through. |
bypassRoles | ['admin'] | Roles that pass. [] makes admins wait too (they can still use the admin routes). |
allow | [] | Routes that stay open, 'METHOD /path' as declared (without /api), e.g. incoming webhooks. |
Deploys and migrations#
Set MAINTENANCE_MODE=maintenance (or read-only) in the environment to force a mode without reading MongoDB, for example while the database is migrated. It overrides the stored state until removed, and calm doctor reports it. If MongoDB cannot be read, each instance keeps the last state it knew.
From code (a job, a migration script):
await app.ext.maintenance.set({ mode: 'read-only', until: new Date(Date.now() + 10 * 60_000) });
const { mode, until, source } = await app.ext.maintenance.status();Security#
- Only admins change the mode; the public route shows the mode, its message and upcoming windows, nothing else (no allowlist, no who changed it).
- IP rules compare
req.ip, which followsconfig.trustProxy. Behind a load balancer setTRUST_PROXY, otherwise every request has the proxy's address (and an allowlisted proxy address would let everyone in). Never trust a forwarded header the proxy does not overwrite. - The guard runs before every other guard (
order: -100), so a refused request does no other work. - Every change emits
maintenance.changedwith the new state and the admin's id: hookaudit-logor your alerts to it.
Events#
| Event | Payload |
|---|---|
maintenance.changed | { state, by } |
Official documentation#
Read these before changing the block; they are also in app/blocks/maintenance/AGENTS.md and in the devtools Blocks panel.
- RFC 9110: 503 Service Unavailable
- RFC 9110: Retry-After
- MDN: 503 Service Unavailable
- Express: behind proxies (req.ip and trust proxy)
- MongoDB update operators
For AI agents#
Coding agents read these notes in app/blocks/maintenance/AGENTS.md:
- Read
app/blocks/maintenance/index.jsfirst: the header lists routes, what is let through and how instances agree. - Options live in
calm.config.jsunderblocks.maintenance; the runtime state lives in thecalm_maintenancecollection (one document,_id: 'state'). - Enforcement is one
calm.guardwithorder: -100, so it runs before every other guard. Keep it first, and keep it cheap: it reads the cached state, never MongoDB per request. - Windows are evaluated against the clock on every request (
effective()); only the stored document is cached (cacheMs). Do not cache the computed mode. - Read-only mode lets GET, HEAD and OPTIONS through and refuses everything else; routes that write on GET are your bug, not the block's.
- Always allowed:
GET /maintenance,POST /auth/login,POST /auth/logout, plusoptions.allow('METHOD /path' without /api). Admins pass bybypassRoles. - IP allowlists compare
req.ip, which depends onconfig.trustProxy(TRUST_PROXY). Behind a load balancer set it, or every client shares the proxy's address. /health/*and devtools are mounted before the API router and are never blocked; do not move them behind it.MAINTENANCE_MODE(env) overrides the stored state and needs no database; doctor reports it while set.- From code:
app.ext.maintenance.set({ mode: 'read-only', until })andawait app.ext.maintenance.status(). - Events:
maintenance.changedwith the new state and who changed it.