Browse docs All docs
Docs / Site protection reference (basic auth & organization auth)
Guidedeploymill://guides/site-protection

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 inAnyone holding the username/passwordOnly users signed in to DeployMill who are members of the app's organization
Credential to shareOne password (returned once)Nothing needed; teammates use their existing DeployMill account
Narrow to specific peopleNo (one shared account)Yes, via access: { scope: "selected", emails: [...] }
Revoking a personRotate the password for everyoneRemove them from the organization (or the allowlist); locks out within ~a minute
Automation / CI accessThe shared passwordThe per-app bypassToken header
Best forQuick "hide this from crawlers" gates, sharing with non-membersStaging 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.scopeWho 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 called mode: "workspace". "workspace" is still accepted as a backward-compatible alias on set_app_protection and 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 the org.protection permission (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 appliedProtection summary ({ 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 every reconcile_project, and new apps are gated automatically when start_project / import_repo provisions them. A declared protection: { enabled: false } in project.json is overridden. A declared access allowlist and publicPaths carve-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.json declares protection: { enabled: false, mode: "public" } is asserting it is intentionally public and is exempt from the mandate. reconcile_project will not force-gate it (it plans action: "none", or disable if a stale gate from a prior mandate run is still live, and surfaces protection.exemptPublic: true so 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 with enabled: 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_protection again with the same arguments (a fresh enable mints a fresh token). Note: a no-op reconcile_project never 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 /About and /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 GET and POST. If a public landing posts to /subscribe, make /subscribe public 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:

EntryerrorCode
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 entriestoo_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 by create_preview / a preview deploy (same x-deploymill-protection-bypass header). 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 idempotent create_preview re-call (alreadyExisted: true) returns the protection block without the token. It is shown exactly once, at creation. Lost it? Rotate with set_app_protection on the preview's applicationId.
  • 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 bypassToken header.
  • 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-Id and X-DM-User-Email headers, 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).