Authentication & users reference (deploymill)
You are an agent about to add user accounts / login to a deploymill-managed app (not to deploymill itself — deploymill's 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 Neon Postgres you provision with
database: { provider: "neon" }. 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 Neon Postgres (
database: { provider: "neon" }) — satisfies 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: { provider: "neon" }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 — 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 forked Neon database branch — 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 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 — convenient for testing against real accounts, but preview code then runs against prod auth data; treat it like prod.
Checklist
- [ ] Database provisioned (
database: { provider: "neon" }) 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 — they 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.