Site protection
Gate all HTTP traffic to a deployed app with one tool call: set_app_protection. Two modes, one tool. Protection is enforced on the app's ingress route(s), applied across every attached domain, and survives redeploys, reconciles, and platform restarts.
Choosing a mode
mode: "basic" (default) | mode: "organization" | |
|---|---|---|
| Who gets in | Anyone holding the username/password | Only users signed in to DeployMill who are members of the app's organization |
| Credential to share | One password (returned once) | Nothing needed; teammates use their existing DeployMill account |
| Narrow to specific people | No (one shared account) | Yes, via access: { scope: "selected", emails: [...] } |
| Revoking a person | Rotate the password for everyone | Remove them from the organization (or the allowlist); locks out within ~a minute |
| Automation / CI access | The shared password | The per-app bypassToken header |
| Best for | Quick "hide this from crawlers" gates, sharing with non-members | Staging sites, internal tools, previews for the team |
Rule of thumb: organization mode is the better default for team-internal sites. No shared password to leak, per-person revocation, and health probes keep working automatically. Use basic when the viewers aren't (and shouldn't become) organization members.
Organization mode admits one of three audiences, picked by access.scope:
access.scope | Who gets in |
|---|---|
"org" (default) | Every member of the owning organization |
"any-authenticated" | Any signed-in DeployMill user, a login gate that does not require org membership |
"selected" | Only the members listed in emails |
"any-authenticated" is the "I want some auth in front of a quick site but don't want to build my own login, and I don't want to invite every viewer into my org" case. Lean on DeployMill's identity, gate out anonymous visitors, but let anyone with a DeployMill account through.
Naming note:
mode: "organization"is the current name for what was previously calledmode: "workspace"."workspace"is still accepted as a backward-compatible alias onset_app_protectionand in.deploymill/project.json, and the stored mode value is unchanged, so existing configs keep working.
Preconditions
- The app must have at least one attached domain. Protection is enforced on the ingress route, so with no route the tool returns
{ ok: false, errorCode: "no_ingress_route" }. Attach a domain first. - Organization mode additionally needs the platform's sign-in gate to be dialable from the ingress. On the hosted platform this is automatic. On a self-host the operator sets
SITE_AUTH_UPSTREAM. When it can't be resolved the tool returns{ ok: false, errorCode: "gate_unreachable" }and changes nothing. Surface that to the user as an operator-configuration issue.
Enable organization protection
set_app_protection({ applicationId, enabled: true, mode: "organization" })
Everyone in the owning organization can now view the site. Everyone else is bounced to DeployMill sign-in. To admit only specific members:
set_app_protection({
applicationId,
enabled: true,
mode: "organization",
access: { scope: "selected", emails: ["[email protected]", "[email protected]"] }
})
- Emails are matched case-insensitively against the organization's members and stored as stable user ids (an email change later doesn't break access).
- Any address that is not a member fails the whole call with
{ ok: false, errorCode: "not_a_member", unknownEmails: [...] }. Nothing is changed. Have the user invite them (organization members are managed in the dashboard at/account/org), or fix the address and retry. - A member removed from the organization loses access automatically. Membership is re-checked on every request (through a ~60s cache), regardless of allowlist.
To admit any signed-in DeployMill user (no org membership required):
set_app_protection({
applicationId,
enabled: true,
mode: "organization",
access: { scope: "any-authenticated" }
})
Anonymous visitors are still bounced to DeployMill sign-in. Once signed in, anyone with a DeployMill account is let through. They do not have to be a member of your organization, and you don't have to invite them. Use this for low-stakes "keep crawlers and the public out, but don't make me manage a member list" gating. (For sensitive internal tools, prefer "org" or "selected". "any-authenticated" admits accounts you don't control.)
The success response is { ok, enabled, mode: "workspace", hosts, access, bypassToken, note } (the stored mode value stays "workspace").
Require organization protection across every app
An organization can mandate organization protection on all of its apps. With it on, every app is force-gated behind organization sign-in. There is no public app in the org.
- Turn it on in the dashboard at Organization → Require organization protection (or
PATCH /api/ui/org/settings { "requireOrgProtection": true }). Toggling it requires theorg.protectionpermission (owner/admin by default; grantable per-member). - Turning it on gates every existing app immediately. The toggle force-enables organization protection across all of the org's current apps right then (no redeploy or manual reconcile needed). The PATCH response echoes an
appliedProtectionsummary ({ protected, protectedApps, skipped, failures }). An app that can't be gated yet (e.g. no domain attached) is reported so it can be retried. The mandate is then re-asserted on everyreconcile_project, and new apps are gated automatically whenstart_project/import_repoprovisions them. A declaredprotection: { enabled: false }inproject.jsonis overridden. A declaredaccessallowlist andpublicPathscarve-out are preserved. - While it's on,
set_app_protection({ enabled: false })is refused with{ ok: false, errorCode: "org_protection_required" }. Turn the org requirement off first to remove protection from an app. - The one deliberate carve-out: a designated public app. An app whose
.deploymill/project.jsondeclaresprotection: { enabled: false, mode: "public" }is asserting it is intentionally public and is exempt from the mandate.reconcile_projectwill not force-gate it (it plansaction: "none", ordisableif a stale gate from a prior mandate run is still live, and surfacesprotection.exemptPublic: trueso the exemption is visible in the plan). This is the only opt-out, it's auditable (it lives in the committed config), and it's fail-safe. An app that declares nothing still gets the mandate. Use it for a public marketing site / public API that legitimately must not sit behind a login.mode: "public"is incompatible withenabled: true(you can't be both intentionally-public and gated). That combination is a config error.
The bypass token: save it immediately
bypassToken (32 hex chars) is returned exactly once and never retrievable later. It lets non-browser automation through the gate:
curl -H "x-deploymill-protection-bypass: <bypassToken>" https://myapp-acme.example.app/healthz
- DeployMill's own deploy health probes and auto-rollback gate use it automatically. You don't need to wire anything for deploys to keep working.
- Hand it to the user for their CI / e2e suites against protected previews and staging.
- To rotate it, call
set_app_protectionagain with the same arguments (a fresh enable mints a fresh token). Note: a no-opreconcile_projectnever rotates it. Only an actual protection change does.
Enable basic auth
set_app_protection({ applicationId, enabled: true }) // mode defaults to "basic"
set_app_protection({ applicationId, enabled: true, username: "admin", password: "..." })
The password is auto-generated when omitted and returned once. Only a bcrypt hash is stored. Re-call to rotate.
Public paths: a public landing in front of a gated app
By default protection is whole-host: every path is gated. To serve some paths publicly while keeping the rest behind the gate (e.g. a public marketing page at / in front of a gated app at /app), pass publicPaths, a list of path globs that bypass the gate. It works in both modes.
set_app_protection({
applicationId,
enabled: true,
mode: "organization",
publicPaths: ["/", "/about", "/pricing", "/assets/*", "/favicon.ico"]
})
The model is default-deny: an omitted or empty publicPaths is exactly today's all-gated behavior, and a misconfiguration over-protects rather than exposes. The carve-out is applied to every host (auto subdomain + custom domains) and is inherited by previews.
Matching semantics (read these, a wrong mental model leaks paths)
Entries are standard path-glob matchers:
- Exact unless it ends with
*."/"matches only the root (a public landing at/does not expose/app),"/about"matches exactly/about,"/assets/*"matches everything under/assets/. - Trailing slash is significant.
"/about"does not match/about/. List both if you need both. - Matching is case-INSENSITIVE.
"/about"also matches/Aboutand/ABOUT. (If you need case-sensitive matching, this feature can't express it: it's path globs only.) - All methods. A public path is public for
GETandPOST. If a public landing posts to/subscribe, make/subscribepublic too. - Order matters (first-match-wins), and the reserved auth path is always evaluated first, so a public glob can never shadow the gate's callback.
Rejected entries (typed error codes)
set_app_protection validates publicPaths up front and changes nothing on a bad entry:
| Entry | errorCode |
|---|---|
Doesn't start with / (e.g. "about") | invalid_public_path |
A catch-all ("*", "/*") | public_path_too_broad (that's enabled:false, not a public path) |
Under /.deploymill/ | public_path_reserved |
| More than 50 entries | too_many_public_paths |
The failure carries the offending entry so you can fix exactly the one that's wrong.
Note: a public path runs no auth, so the app receives no
X-DM-User-*identity headers on those routes, even when the visitor happens to be signed in.
Disable
set_app_protection({ applicationId, enabled: false })
Removes protection from every attached host and deletes the stored policy. Works even when all domains have since been detached (so a stale policy can't resurrect later).
Persisting in .deploymill/project.json
To make protection part of the project's declared state (it then survives reconcile_project runs and is re-asserted from config):
"protection": { "enabled": true, "mode": "organization", "access": { "scope": "selected", "emails": ["[email protected]"] }, "publicPaths": ["/", "/assets/*"] }
Reconcile semantics differ from the tool in one way: it's a sweep, not interactive. An unknown email (or an invalid publicPaths glob) produces a warning and is skipped rather than failing the run (and an allowlist that resolves to nobody applies nothing). A reconcile whose config matches the live policy plans action: "none" and does not mint a new bypass token. An actual enable surfaces the fresh token once in applied.protection.bypassToken. Changing only publicPaths on an already-protected app re-applies the carve-out via action: "update-public-paths" without rotating the password / bypass token. Omitting the protection block entirely leaves existing protection untouched.
Previews
Previews of a protected app inherit its protection by default. A protected parent never spawns a publicly reachable preview. create_preview copies the parent's policy onto the new preview, and deploying the preview (deploy, addressed by (parentApplicationId, ref) or the preview's applicationId) backfills it for a preview created before the parent was protected. The result's protection block tells you what happened:
- Organization mode: the preview gets its own
bypassToken, returned once bycreate_preview/ a previewdeploy(samex-deploymill-protection-bypassheader). The parent's token is never reused, so rotating one never affects the other. Re-calling/resuming never rotates an inherited preview token, and an idempotentcreate_previewre-call (alreadyExisted: true) returns theprotectionblock without the token. It is shown exactly once, at creation. Lost it? Rotate withset_app_protectionon the preview'sapplicationId. - Basic mode: the preview shares the parent's username and password. No new credential is minted, and the response echoes only the
username.
The parent's publicPaths carve-out is inherited too, so a "public landing + gated app" parent splits the same way on its previews.
To opt previews out (e.g. for public review links), add the knob to the parent's protection block in .deploymill/project.json:
"protection": { "enabled": true, "mode": "organization", "previews": false }
No protection block in the create/deploy result means the preview is public. Either the parent is unprotected or it opted out. One nuance: an alreadyExisted re-call of create_preview does not backfill protection onto a preview created before the parent was protected. It only reports what the preview already has. Deploying the preview (deploy) is the healer. It backfills the parent's protection onto a row-less preview before deploying.
Caveats an agent should know
- Reserved path:
/.deploymill/auth/*is reserved on organization-protected hosts (the gate's sign-in callback/signout live there). Don't deploy app routes under it. - Programmatic / cross-origin access: CORS preflights don't carry cookies, so cross-origin XHR/fetch against an organization-protected site fails for browser apps on other origins. Organization protection is for human-facing sites. Programmatic clients use the
bypassTokenheader. - Fail-closed by design: if the DeployMill control plane is unreachable, organization-protected sites refuse requests rather than serving openly (correct for an auth gate, but it does make DeployMill a serving dependency for those hosts only).
- Identity headers: on admitted requests the app receives
X-DM-User-IdandX-DM-User-Emailheaders, useful for lightweight "who is viewing" personalization without an app-side login. - Health probes: organization-protected apps are probed with the bypass header, so a 302/401 at the health path reads unhealthy (it means the gate rejected the bypass). Basic-protected apps keep the auth-gated tolerance (401 at the health path proves the edge routes).