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", provider: "deploymill" }(the house default, withprovider: "neon"andprovider: "supabase"running the same Postgres engine, only the operator differs). 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.
- Secure cookies need HTTPS, and the default
sslip.iodomain is HTTP-only. When the server runs withoutWILDCARD_DOMAIN_BASE,attach_domainmints ansslip.iohost served over plain HTTP. Cookies markedSecure(and most auth libraries default toSecurein production) will be dropped by the browser over HTTP. Login appears to succeed then immediately "forgets" you. Either run the app on a real HTTPS domain (server configured withWILDCARD_DOMAIN_BASE, givinghttps://<app>-<org>.<base>with Let's Encrypt), or, only for a throwaway HTTP host, relax the cookie'sSecure/sameSitesettings knowingly. Prefer HTTPS. Don't ship a real login flow on an HTTP host.
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", provider: "deploymill" }, the house default, orprovider: "neon"/"supabase", same engine), 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", provider: "deploymill" }(the house default, orprovider: "neon"/"supabase") 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: a Neon branch, an internal-postgres clone, or a SQLite file copy, so preview users are isolated from prod users. A supabase-backed parent shares its database with previews). 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 DB branch 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", provider: "deploymill" }, the house default, orprovider: "neon"/"supabase") 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. - [ ] App served over HTTPS (real domain), or
Securecookies knowingly relaxed for a throwaway HTTP host. - [ ] 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 ship
Secure-cookie login on an HTTPsslip.iohost and wonder why sessions vanish. Use HTTPS. - Don't expect an env or secret change to apply without a
deploy.