Browse docs All docs
Docs / Authentication & users reference
Guidedeploymill://guides/auth

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:

  1. 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, with provider: "neon" and provider: "supabase" running the same Postgres engine, only the operator differs). This is the single biggest reason to provision a database before wiring auth.
  1. 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 with set_env_vars to the app's real prod host (e.g. https://my-app-acme.detz.dev), and on each preview pass it via create_preview's envOverrides using 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.
  1. 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 via request_secret({ name }) → hand them the link → poll secret_request_statusbind_secret({ applicationId, name }) (or declare under secrets in .deploymill/project.json and reconcile). The value is resolved server-side. It never appears in a transcript. See deploymill://guides/secrets.
  1. Secure cookies need HTTPS, and the default sslip.io domain is HTTP-only. When the server runs without WILDCARD_DOMAIN_BASE, attach_domain mints an sslip.io host served over plain HTTP. Cookies marked Secure (and most auth libraries default to Secure in 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 with WILDCARD_DOMAIN_BASE, giving https://<app>-<org>.<base> with Let's Encrypt), or, only for a throwaway HTTP host, relax the cookie's Secure/sameSite settings knowingly. Prefer HTTPS. Don't ship a real login flow on an HTTP host.

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, or provider: "neon"/"supabase", same engine), satisfying fact #1 with no extra infra.
  • Its config takes an explicit baseURL / reads BETTER_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):

  1. Provision the database first. Add database: { engine: "postgres", provider: "deploymill" } (the house default, or provider: "neon"/"supabase") to .deploymill/project.json, reconcile_project, follow the returned deploymill://guides/database/node to add pg + a pooled module + migrate-on-start.
  2. 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).
  3. Signing secret via the vault: request_secret({ name: "BETTER_AUTH_SECRET" }), have the user fill it, then bind_secret({ applicationId, name: "BETTER_AUTH_SECRET" }), or declare it under secrets in project.json.
  4. Base URL via env: set_env_vars({ applicationId, vars: { BETTER_AUTH_URL: "https://<app>-<org>.<base>" } }). (The prod host is whatever list_domains reports.)
  5. 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's envOverrides with 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, or provider: "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_secretbind_secret / secrets array). Never in set_env_vars, never in chat.
  • [ ] Host-pinned base URL set via set_env_vars for prod, and via ${PREVIEW_URL} in create_preview envOverrides for previews.
  • [ ] App served over HTTPS (real domain), or Secure cookies knowingly relaxed for a throwaway HTTP host.
  • [ ] OAuth redirect URIs registered for the prod host (and previews, if social login is needed there).
  • [ ] deploy after 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 HTTP sslip.io host and wonder why sessions vanish. Use HTTPS.
  • Don't expect an env or secret change to apply without a deploy.