Authentication & users
You are an agent about to add user accounts / login to a DeployMill-managed app (not to DeployMill itself, whose own sign-in is a separate, server-level concern). This guide is the paved road for doing that on this platform: which auth approach fits, and, more importantly, the four platform facts that make auth on DeployMill different from auth on a laptop. Get those four right and almost any auth library works. Get them wrong and login "works locally" but breaks the moment it's deployed or previewed.
Read it alongside:
deploymill://guides/secrets: secret values (auth signing key, OAuth client secret) never cross the agent. They go through the vault.deploymill://guides/env-vars: host-pinned URLs (the auth base URL) are non-secret config set per-environment.deploymill://guides/previews: every preview is a distinct origin, so auth needs per-preview wiring.deploymill://guides/database/{node|python}: the auth tables live in the managed Postgres you provision. Migrations run on container start.
The four platform facts (get these right)
Everything else is detail. These are the constraints DeployMill imposes that change how auth is wired:
- Sessions must live in Postgres, not on the container filesystem or in memory. The container filesystem is ephemeral (wiped every deploy) and there is no shared in-process memory across restarts. A file-backed or memory-backed session store loses every session on each deploy and silently logs everyone out. Use a SQL-backed session/user store on the managed database you provision with
database: { engine: "postgres" }(DeployMill runs the Postgres itself — no provider to pick, no external account). This is the single biggest reason to provision a database before wiring auth.
- The auth base URL is host-pinned and differs per environment. Auth libraries bake an absolute base URL into cookies, OAuth redirect URIs, and email links (
BETTER_AUTH_URL,NEXTAUTH_URL,AUTH_URL,PUBLIC_URL, …). It is not a secret. Set it withset_env_varsto the app's real prod host (e.g.https://my-app-acme.detz.dev), and on each preview pass it viacreate_preview'senvOverridesusing the literal token${PREVIEW_URL}(substituted with the preview's full URL at creation). If this is wrong, cookies are scoped to the wrong domain and OAuth round-trips fail with redirect-mismatch.
- The signing secret and any OAuth client secret go through the vault, never
set_env_vars, never the chat. The session signing key (BETTER_AUTH_SECRET,AUTH_SECRET,NEXTAUTH_SECRET, …) and OAuth client secrets are secrets. Mint a strong random value or have the user supply it viarequest_secret({ name })→ hand them the link → pollsecret_request_status→bind_secret({ applicationId, name })(or declare undersecretsin.deploymill/project.jsonand reconcile). The value is resolved server-side. It never appears in a transcript. Seedeploymill://guides/secrets.
- Every app host is HTTPS, so ship
Securecookies — don't relax them. The platform mints managed hosts ashttps://<app>-<orgSlug>.<base>(Let's Encrypt via the ingress), and custom domains get a certificate too. There is no HTTP-only auto-host fallback: if the server has noWILDCARD_DOMAIN_BASEconfigured,attach_domainfails with a coded error telling the operator to configure it — it does not mint a plain-HTTP host. So the "mySecurecookie is being dropped over HTTP" case does not arise on a working deployment, and relaxingSecure/sameSiteis never the right fix here. If cookies are vanishing, the cause is fact #2 (a base URL pinned to the wrong host) or a cross-sitesameSitemismatch on an OAuth round-trip, not the scheme.
Recommended approach
Node: Better Auth (paved road)
For Node apps, Better Auth is the default recommendation, for platform reasons, not taste:
- It's SQL-backed, so it drops straight onto the managed Postgres (
database: { engine: "postgres" }), satisfying fact #1 with no extra infra. - Its config takes an explicit
baseURL/ readsBETTER_AUTH_URL, the host-pinned-URL knob fact #2 needs. - It's exactly what DeployMill itself runs on, so the platform is known to support its session-cookie and OAuth-callback shapes.
Wiring sketch (read deploymill://guides/conventions/node for the surrounding app conventions):
- Provision the database first. Add
database: { engine: "postgres" }to.deploymill/project.json,reconcile_project, follow the returneddeploymill://guides/database/nodeto addpg+ a pooled module + migrate-on-start. - Add Better Auth, pointed at the same pool. Generate its tables as a migration so they're created by the same migrate-on-start step (don't rely on a separate manual schema push, since the container start is the only reliable migration point).
- Signing secret via the vault:
request_secret({ name: "BETTER_AUTH_SECRET" }), have the user fill it, thenbind_secret({ applicationId, name: "BETTER_AUTH_SECRET" }), or declare it undersecretsinproject.json. - Base URL via env:
set_env_vars({ applicationId, vars: { BETTER_AUTH_URL: "https://<app>-<org>.<base>" } }). (The prod host is whateverlist_domainsreports.) deploy.
OAuth providers (Google, GitHub, …): the client id can go in set_env_vars (it's not secret), the client secret goes through the vault. Register the provider's redirect URI as <BETTER_AUTH_URL>/api/auth/callback/<provider>. The end-to-end Google example is in deploymill://guides/secrets ("Wiring Google OAuth into an app").
Python: a SQL-backed library over the managed Postgres
Python has no single DeployMill default, but the same four facts decide it. Pick a library that stores sessions/users in Postgres (e.g. FastAPI with Authlib for OAuth + a sessions table on the managed DB, or fastapi-users with its SQLAlchemy backend). Avoid signed-cookie-only or in-memory session stores for anything beyond a demo. They fall foul of fact #1 the moment you scale or redeploy. Provision the database first (deploymill://guides/database/python), keep the auth tables in your Alembic migrations so they run on container start, set the host-pinned base URL via set_env_vars, and push the signing/OAuth secrets through the vault.
Hosted IdP (Clerk, Auth0, WorkOS, …)
Fine to use if the user wants it. It sidesteps fact #1 (the IdP holds the sessions). Facts #2–#4 still apply: the IdP's allowed callback/redirect URLs must include the prod host and every preview host, the IdP API secret goes through the vault, and the publishable/client key (non-secret) can go in set_env_vars. Per-preview callback URLs are the usual friction point (see below).
Previews and auth
Each preview is a separate origin with its own hostname (and, by default, its own isolated database copy, so preview users are isolated from prod users). Two things break if you forget them:
- Base URL. Pass the auth base URL in
create_preview'senvOverrideswith the${PREVIEW_URL}token, e.g.envOverrides: { BETTER_AUTH_URL: "${PREVIEW_URL}" }. Without this the preview inherits the parent's prod URL and every cookie/redirect targets the wrong host. - OAuth redirect URIs. A provider only redirects back to URIs you pre-registered. Preview hostnames are deterministic but per-branch, so social login on a brand-new preview host fails until that callback URL is registered with the provider. For provider-backed login on previews, prefer either (a) email/password or magic-link auth (no external redirect allow-list), or (b) registering a wildcard/extra redirect URI with the provider if it supports one. This is the most common "preview deploy is up but login fails" cause (see
deploymill://guides/previews).
Because previews get their own isolated database copy by default, a user you create on a preview does not exist on prod (and vice versa). If you set previews: { shareDatabase: true }, previews share the prod user table. That's convenient for testing against real accounts, but preview code then runs against prod auth data, so treat it like prod.
Checklist
- [ ] Database provisioned (
database: { engine: "postgres" }) and sessions/users stored there, not on disk, not in memory. - [ ] Auth tables created via the migrate-on-start migration, not a manual one-off.
- [ ] Signing secret + OAuth client secret(s) in the vault (
request_secret→bind_secret/secretsarray). Never inset_env_vars, never in chat. - [ ] Host-pinned base URL set via
set_env_varsfor prod, and via${PREVIEW_URL}increate_previewenvOverridesfor previews. - [ ] Cookies left
Secure— every app host is HTTPS, so there is no reason to relax them. - [ ] OAuth redirect URIs registered for the prod host (and previews, if social login is needed there).
- [ ]
deployafter any env/secret change. Changes don't take effect until the next deploy.
What NOT to do
- Don't store sessions on the container filesystem or in process memory. Ephemeral + no shared state = everyone logged out on every deploy.
- Don't put the signing secret or an OAuth client secret in
set_env_vars. Use the vault. - Don't hardcode the base URL to one environment's host. It differs between prod and each preview. Drive it from env.
- Don't relax
Secure/sameSiteto "fix" a vanishing session. App hosts are HTTPS; a dropped cookie is a wrong-host base URL or a cross-site OAuth round-trip, and weakening the cookie hides the real bug while making the app less safe. - Don't expect an env or secret change to apply without a
deploy.