Skip to main content
A password-protected deployment (the “Access control” card’s password toggle, POST /api/deployments/{id}/access) no longer sits behind Caddy’s native HTTP Basic Auth popup. Instead, a visitor sees a styled “This deployment is protected” page offering two ways in: the deployment’s own password, or signing in with a Mangrove account. Modeled on the same idea as Vercel/Cloudflare Access-style deployment protection pages.

Why not just Caddy’s http_basic auth

The old mechanism worked but couldn’t offer a “continue with your account” option or any custom styling — it’s a browser-native credentials prompt, full stop. Getting a real page in front of visitors means Caddy can no longer gate the route by itself; something has to decide, per request, whether to render a page or let traffic through.

The flow

internal/proxy/caddy.go’s gateHandler replaces the normal reverse_proxy/file_server handler on a password-protected route (port-based and custom-domain routes — see below) with an unconditional reverse proxy to Mangrove’s own 127.0.0.1:<APIPort>, tagged with X-Mangrove-Gate-Deployment: <id> (Caddy’s headers.set always overwrites any client-supplied value of the same name, and the API port is loopback-only, so this header can be trusted). internal/api/gate.go’s gateIntercept middleware, mounted ahead of all other routing, catches every request carrying that header and handles it entirely on its own — it never reaches /api, /healthz, or the dashboard SPA:
  • No valid mangrove_gate cookie, but the request path matches one of the deployment’s public paths (below) -> proxy straight through.
  • No valid mangrove_gate cookie yet -> render the gate page.
  • POST /__mangrove_gate__/password -> check the deployment’s stored bcrypt hash (Store.GetDeploymentPasswordHash) directly; on success, set the cookie and redirect back.
  • GET /__mangrove_gate__/callback?token=... -> the far side of the account handoff below.
  • A valid cookie -> Orchestrator.GateUpstreams resolves where the real app actually is (a running container’s address, or a static build’s output directory) and Mangrove reverse-proxies the request through itself (gateProxyThrough).

Unprotected pages (public paths)

An owner can leave chosen pages open on an otherwise-protected deployment (a landing page, a pricing page, a health endpoint). The Access control card’s “Unprotected pages” box (or public_paths in POST /api/deployments/{id}/access) takes one pattern per line, stored newline-separated in deployments.public_paths (migration 0015):
  • /pricing — exactly that path (so /pricing/ is not covered).
  • /docs/* — a prefix: everything under /docs/. /assets* is a prefix without the slash boundary.
Rules live in internal/gatepaths (Normalize validates on save, Match runs per request in handleGatedRequest, after the cookie check so a signed-in visitor is never affected). A request path that isn’t already canonical (.., //, /./, backslashes) never matches, so /docs/../admin can’t ride a /docs/* rule past the gate. Query strings are ignored for matching. Only the path is checked — a public page’s own CSS/JS/API calls need their own entries (/assets/*), or they’ll hit the gate. The list is cleared when password protection is turned off. Re-saving while already protected with a blank password keeps the existing password, so the list can be edited alone.

The account handoff

The protected deployment and the dashboard are different origins, so there’s no session cookie to just check — this is a small OAuth-shaped redirect dance, all three legs backed by signed, tamper-evident tokens (internal/gateauth, sealed under the same master key as secrets at rest; no new database table):
  1. The gate page’s “Continue with Mangrove account” button links to <PublicURL>/gate-auth?token=<handoff token> — minted on the deployment’s own domain, TTL 5 minutes. The return URL travels inside the token; /gate-auth never trusts a raw query parameter for it, since that endpoint is a public, unauthenticated GET anyone could link to.
  2. /gate-auth, on the dashboard’s own domain, checks the visitor’s existing mangrove_session cookie (or shows a small standalone login form if there isn’t one — any role, owner or member, can complete this; see multi-user.md, there’s no finer-grained per-project ACL to check). On success it mints a callback token (TTL 2 minutes) and redirects back to the deployment’s own domain.
  3. GET /__mangrove_gate__/callback redeems that token, sets the mangrove_gate cookie, and redirects to the page the visitor originally asked for.
If MANGROVE_PUBLIC_URL isn’t set, there’s no safe absolute origin to send a visitor back to, so the account button is simply omitted — the password field still works.

Custom domains

internal/orchestrator/domains.go’s pushCustomDomainRoute passes the same RouteOptions (built from the deployment’s own PasswordProtected flag) that the port-based route gets, and SetAccessControl re-pushes a deployment’s verified custom domain route on every access-control change — previously password protection only actually took effect on the port-based route, never on a custom domain.

Known limitations

  • No caching on the resolve-upstream path. GateUpstreams does a live lookup (container address resolution, or a store read) on every request that passes the gate. Fine for what this is designed for — a low-traffic staging gate, matching the existing “flip a staging build to password-protected without touching app code” framing — not meant for a high-throughput production path.
  • Two protected deployments on the same bare hostname, different ports, no custom domain, will fight over the same cookie. Cookie scoping ignores port, so a mangrove_gate cookie set for host:31000 is also sent to host:31001 — since the cookie’s payload is itself deployment- bound (via AAD), the wrong deployment just rejects it and re-shows its gate page, but only the most recently authenticated one stays “logged in” per hostname. A verified custom domain doesn’t have this problem.