sleep_enabled + sleep_idle_minutes, the “Idle sleep” card on a
deployment’s Overview tab, POST /api/deployments/{id}/sleep), and waking
itself back up the moment the next visitor arrives, seeing a brief “waking
up” page in the meantime instead of connection-refused. Modeled on the
serverless-platform “scale to zero” idea, but opt-in per deployment and
simple: stop/start the same container, not a cold rebuild.
Why this needed a different route than Stop
StopDeployment (the user-facing Stop button) deletes the deployment’s
Caddy route entirely — the honest behavior for “I stopped this on purpose,”
since there’s nothing to show a visitor except connection-refused anyway.
Idle sleep needs the opposite: the route has to survive the container being
stopped, pointed at something that can actually serve a visitor and wake the
container back up. That’s the same problem password protection already
solved — make Mangrove itself the thing Caddy routes to, on every request,
instead of the real app directly.
Reusing the gate, not inventing a second handler
proxy.RouteOptions gained a Sleepable flag alongside the existing
PasswordProtected one. Both flags route through the exact same Caddy
handler (gateHandler in internal/proxy/caddy.go): its only job is
“forward to Mangrove’s own loopback API, tagged with
X-Mangrove-Gate-Deployment: <id>” — that’s equally what a sleep-enabled
deployment needs, so there was no reason to build a second indirection
layer. internal/orchestrator/routeopts.go’s routeOptionsFor is the one
place a deployment’s PasswordProtected/SleepEnabled settings turn into
RouteOptions, used by every route-pushing call site (deploy, redeploy,
restart, access-control change, custom domains) so the two flags can’t drift
out of sync across those five places the way five separately-maintained
copies of the same branch would.
internal/api/gate.go’s handleGatedRequest checks the password gate
first (if the deployment has one), then calls proxyOrWake — the single
choke point every already-authorized request passes through on its way to
the real app:
- Deployment awake (
status != "sleeping") -> bumplast_request_at(throttled to once a minute, so a busy awake deployment isn’t writing to the DB on every request) and proxy through for real (gateProxyThrough, the same function the password gate uses). - Deployment asleep -> serve the “waking up” page (
renderWakingPage, a standalonehtml/templatepage like the gate’s own, with a<meta http-equiv="refresh">instead of JS) and kick off a background wake, deduped per-deployment (sync.MutexviawakeLockFor) so several visitors landing in the same moment only trigger one restart. The background wake runs detached from the request’s context (likeorchestrator.WithInflightDeploy’s own deploys) since it must finish even if the visitor who triggered it navigates away immediately.
running, that reload proxies through for real. No separate
status-polling endpoint was needed — the page reload itself re-runs the
same status check.
A password-protected and sleep-enabled deployment checks the password
first: triggering a wake is a real (if mild) cost, and checking the password
first means an unauthenticated visitor can’t repeatedly wake someone else’s
gated deployment just by hitting it.
Putting a deployment to sleep
internal/scheduler/sleeper.go’s Sleeper ticks every minute (far more
often than PreviewReaper’s hourly sweep, since sleep_idle_minutes can be
set as low as a few minutes) and calls Store.ListSleepCandidates — every
running, sleep_enabled deployment whose last_request_at is older than
its own sleep_idle_minutes (a per-row SQL comparison,
datetime('now', '-' || sleep_idle_minutes || ' minutes')). Each candidate
goes through Orchestrator.SleepDeployment: the same best-effort
per-container Exec.Stop loop StopDeployment uses, except it leaves the
Caddy route untouched and sets the deployment’s status to a new sleeping
value (added to the status CHECK constraint via a full-table rebuild,
migration 0016 — SQLite can’t ALTER a CHECK in place, same as
0011_custom_domain_port_mode.sql and 0004_redeploy_trigger.sql before
it) instead of stopped.
WakeDeployment is RestartDeployment under a name that reads correctly at
its call site: starting a sleeping deployment’s containers back up and
starting a deliberately-stopped-then-manually-restarted one are the exact
same operation. A deployment’s own detail page also gets a manual “Wake up”
button when its status is sleeping (next to the usual Restart/Stop pair),
for waking it ahead of the next real visitor without waiting.
Enabling sleep doesn’t mean “idle since whenever”
Store.SetDeploymentSleepConfig resets last_request_at to now whenever
sleep_enabled flips on. Without that, a deployment that’s never actually
been touched by the wake handler (so last_request_at is still NULL, or
stale from before sleep was ever turned on) would either never qualify as a
candidate (a NULL check) or — worse — read as having been idle forever
and get swept on the sleeper’s very next tick, seconds after someone turned
the feature on. TouchDeploymentDeployed (every successful deploy) also
bumps last_request_at for the same reason: a fresh deploy shouldn’t read
as idle just because no visitor has shown up yet.
Known limitations
- Single-service deployments only, matching
SetAccessControl’s own constraint — a compose stack has no one canonical container to stop/start this way without an orchestration layer idle sleep doesn’t have yet. - No live route re-push while actually sleeping or never-deployed.
SetSleepConfigonly re-pushes the Caddy route immediately when the deployment is currentlyrunning(mirrorsSetAccessControl’s own live-reapply pattern) — a sleeping deployment’s route is already correctly pointed at the wake handler (that’s what let it sleep), and one that’s never been deployed has no live route to touch either way. Both pick up the setting next time a route gets pushed. - Not exposed in Simple mode or the CLI/TUI/MCP clients yet — Simple mode’s app detail page explains a sleeping app’s state in plain language, but the toggle itself is technical-mode-only for now.