ObjectStackObjectStack

Self-hosting — run your own deployment

Run a compiled ObjectStack app on your own servers with the official Docker image, Compose and Postgres, or Kubernetes — health checks and secrets too.

This guide takes the artifact produced by os build / os compile and runs it on infrastructure you operate. Docker is the standard path — the platform publishes an official runtime image on every release, and Compose and Kubernetes are shapes of that same path rather than alternatives to it. Bare Node.js under systemd is the minority path, kept for hosts where a container runtime is not available or not permitted. It assumes you have read Deployment Overview.

The deployment model is deliberately simple:

objectstack.config.ts ──(os build, CI)──▶ dist/objectstack.json ──(os start, server)──▶ running app
  • The artifact (dist/objectstack.json) is a portable, self-describing JSON file — your entire app. Build it once in CI; the host needs no TypeScript and no build step.
  • os start boots a production server directly from that artifact (reference).
  • Deployment config stays outside the artifact. Database URL, secrets, and environment identity are injected via OS_* environment variables or flags.

The minimum viable production environment

Four values every self-hosted deployment must pin — everything else has a workable default:

VariableWhy it must be set
OS_DATABASE_URLWithout it, data lands in a SQLite file under the ObjectStack home directory (~/.objectstack, or <cwd>/.objectstack next to a project config) — fine for one box, wrong for containers. Use postgres://…, mysql://…, mongodb://…, libsql://…, or a mounted file:… path (postgres://…'s driver is an optional package too — npm install pg, or the boot fails loudly rather than degrading to SQLite; libsql:// / Turso is inferred, but its driver is an optional package — npm install @objectstack/driver-turso, or the boot fails loudly rather than degrading to SQLite; see Drivers). mysql://… is a supported deployment target that carries three dialect caveats — two of them integrity guarantees MySQL cannot enforce at the database — see Drivers → MySQL dialect caveats before choosing it; its driver is likewise an optional package — npm install mysql2, or the boot fails loudly rather than degrading to SQLite. mongodb://… is single-tenant only: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is single — see Drivers → Multi-tenancy.
OS_AUTH_SECRETSession secret for the auth plugin (AUTH_SECRET is the legacy alias). Without it, /api/v1/auth/* is silently skipped — the server runs unauthenticated.
OS_SECRET_KEY32-byte master key encrypting every stored secret (openssl rand -hex 32). On a container's ephemeral filesystem the auto-minted key is lost on restart, making previously-encrypted secrets undecryptable.
OS_PORTos start fails loudly if the port is busy (it never auto-shifts like os dev). Pin it and keep your reverse-proxy upstream in sync.

Generate strong values once and store them in your secret manager:

OS_AUTH_SECRET=$(openssl rand -hex 32)
OS_SECRET_KEY=$(openssl rand -hex 32)

The full catalog is in Environment Variables.

Docker (official image) — the standard path

The artifact model maps cleanly onto containers, and this is how the platform itself ships: ObjectStack builds and publishes an official runtime image on every framework release — ghcr.io/objectstack-ai/objectstack, Node 22 + @objectstack/cli + os start, running as a non-root user with a built-in health check and OS_ARTIFACT_PATH / OS_PORT=8080 preset. Image tags mirror @objectstack/cli versions: a stable publish pushes that exact version as X.Y.Z and moves latest and the matching X.Y / X tags onto it, while a prerelease gets only its exact tag. The image is published multi-arch (amd64/arm64). Only the exact tag is immutable — a rolling tag stops moving as soon as no later publish matches it, so X.Y freezes when the next minor ships and X when the next major does. Pin the exact version in production, matching the CLI version in your package.json.

The fastest path needs no image build at all — hand the official image your compiled artifact:

os build   # → dist/objectstack.json (or in CI)

docker run -p 8080:8080 \
  -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \
  -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
  -e OS_AUTH_SECRET \
  -e OS_SECRET_KEY \
  ghcr.io/objectstack-ai/objectstack:17.7.0

(OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can come straight from release storage instead of a mount.)

Artifact-pinned boot (OS_ARTIFACT_URL)

The image above carries no app. OS_ARTIFACT_URL names one by reference, so a fixed runtime image plus one environment variable is a running app — and upgrading the app is an env change plus a restart, never an image rebuild. The runtime image and the app artifact become two independent release axes.

docker run -p 8080:8080 \
  -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
  -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
  -e OS_AUTH_SECRET -e OS_SECRET_KEY \
  ghcr.io/objectstack-ai/objectstack:17.7.0

Both schemes work: https://… is fetched at boot, file:///… is read directly (the volume-mount workflow above, spelled as a URL). The variable overrides the image's preset OS_ARTIFACT_PATH and any objectstack.config.ts in the working directory.

The integrity pin lives in the URL fragment. #sha256=<64 hex chars> is SRI-style and there is deliberately no companion OS_ARTIFACT_SHA256: a fragment is client-side by standard and is never sent to the server, so the pin travels with the reference as a single value to copy and a single value to rotate. Two variables would make "URL updated, hash not" a state you can reach.

SituationWhat the runtime does
No #sha256= fragmentBoots without verification. A fetch or read failure fails the boot so your orchestrator retries — there is no cache fallback, because there is nothing to authenticate a cached copy with.
#sha256= present, content matchesBoots, and keeps the verified copy under <home>/artifacts.
#sha256= present, content differsRefuses to boot, naming the expected and the actual digest.
#sha256= present, artifact host unreachableFalls back to the cached copy only if it still hashes to the pin, with a loud warning that the instance is running on cached content.
Artifact's engines.protocol excludes this runtimeRefuses to boot — the safety belt of the two-axis split. Repoint the reference, or run a matching image version.
The artifact needs a destructive schema changeSafe migrations run at boot; a destructive one refuses to boot and names each change. Run os migrate apply --allow-destructive deliberately, then restart. Never skipped in silence.

Recommended production discipline (convention, not enforced by the runtime): publish immutable, version-named objects; give only CI write access to the artifact host; and pin the digest in the fragment. Together these make "which bytes is this instance running?" a question with one answer.

Pre-signed URLs are safe to use. The reference may carry auth material — a signature query parameter, or user:token@host — and it is never echoed into logs or HTTP responses. Userinfo is sent as an Authorization: Basic header rather than in the request line (so it does not land in your artifact host's access log), and remote bytes are materialised to a local file before the boot continues, so the URL does not reach any downstream surface at all.

For a self-contained deployable image, extend it. The Dockerfile below (plus the compose stack in the next section and a .dockerignore) ships ready-made in the project scaffold — create-objectstack (npm create objectstack) writes all three into your new project's root.

Dockerfile
# ── Build stage: compile TypeScript metadata to the artifact ─────────
FROM node:22-slim AS build
WORKDIR /app
# Installs from the lockfile this project carries, so commit it: scaffolding
# writes pnpm-lock.yaml when it installs with pnpm, package-lock.json with npm.
# pnpm runs through Corepack at the major .github/workflows/ci.yml pins; keep
# the two in step (unpinned, Corepack takes the registry's newest major).
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* package-lock.json* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack pnpm@10 install --frozen-lockfile; \
    elif [ -f package-lock.json ]; then npm ci; \
    else echo "No lockfile: run pnpm install or npm install, then commit it." >&2; exit 1; fi
COPY . .
RUN npx os build              # → dist/objectstack.json

# ── Runtime: the official ObjectStack runtime image ──────────────────
FROM ghcr.io/objectstack-ai/objectstack:17.7.0
COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json
docker build -t my-app .
docker run -p 8080:8080 \
  -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
  -e OS_AUTH_SECRET \
  -e OS_SECRET_KEY \
  my-app

Prefer to build the runtime yourself (air-gapped registry, custom base image)? The official image is nothing more than:

Dockerfile (self-built runtime, equivalent)
FROM node:22-slim
RUN npm install -g @objectstack/cli@17.7.0

WORKDIR /srv/app
RUN chown node:node /srv/app
USER node
COPY --chown=node:node dist/objectstack.json ./objectstack.json

ENV NODE_ENV=production \
    OS_ARTIFACT_PATH=/srv/app/objectstack.json \
    OS_PORT=8080
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
  CMD node -e "fetch('http://localhost:'+(process.env.OS_PORT||8080)+'/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"

CMD ["os", "start"]

Never bake OS_AUTH_SECRET / OS_SECRET_KEY into the image. Pass them at runtime from your orchestrator's secret store. And never rely on the auto-minted dev crypto key inside a container — it lives on the ephemeral filesystem and dies with it.

Docker Compose with Postgres

The same path with a database attached — build: . is the Dockerfile from the section above, so the app container is still the official runtime image with your artifact on top. A complete single-host production stack:

docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      OS_DATABASE_URL: postgres://objectstack:${POSTGRES_PASSWORD}@db:5432/myapp
      OS_AUTH_SECRET: ${OS_AUTH_SECRET}
      OS_SECRET_KEY: ${OS_SECRET_KEY}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: objectstack
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U objectstack -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

volumes:
  pgdata:
# .env next to docker-compose.yml (never committed)
POSTGRES_PASSWORD=…
OS_AUTH_SECRET=…
OS_SECRET_KEY=…

docker compose up -d
curl -fsS http://localhost:8080/api/v1/health

Prefer SQLite on a single small host? Skip the db service, mount a volume, and point OS_DATABASE_URL at it: file:/srv/data/app.db (with - appdata:/srv/data on the app service). See Drivers for when to reach for which database.

Health checks & orchestration

Every runtime exposes two probe endpoints — wire them into Docker HEALTHCHECK, Kubernetes probes, or your load balancer:

EndpointMeaningUse as
GET /api/v1/healthProcess is up and serving HTTPLiveness probe
GET /api/v1/readyKernel booted and the data drivers answerReadiness probe

Wire each to the probe it is named for — they answer deliberately different questions:

  • /health checks nothing but the process. It never touches the database, on purpose: a failing liveness probe makes the orchestrator restart the pod, which cannot fix an unreachable database but would put every replica into a restart storm for the length of the outage.
  • /ready pings the data drivers (bounded, and cached ~1s so frequent polling costs no extra round-trips). A replica whose driver is down fails 100% of its requests, so it returns 503 with the failing driver names and leaves the load-balancer rotation until the database comes back. If the check is inconclusive — no data engine at all, or the probe itself errors — the replica stays ready rather than black-holing a working deployment.

A configuration fault never restarts the pod. The split above is enforced one step earlier than the two handlers: /health is served before the request pipeline resolves an identity, so it evaluates nothing that depends on configuration or credentials — a misconfigured or unbuildable security service leaves it answering 200 for as long as the process can serve HTTP. /ready keeps the whole pipeline and answers 503 for exactly that class of fault, with the body it has always returned. So wire path: /api/v1/health to livenessProbe and path: /api/v1/ready to readinessProbe — never the other way round, and never the same path to both. Crossed over, a configuration fault makes the orchestrator kill and recreate the pod; the new one reads the same configuration and fails the same way, and the operator sees CrashLoopBackOff instead of the fault. Withholding traffic is the response that helps; restarting is not.

Kubernetes

The same image works unchanged. A minimal reference Deployment — secrets from a Secret, probes on the two endpoints above:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 1            # >1 requires OS_CLUSTER_DRIVER — see below
  selector:
    matchLabels: { app: my-app }
  template:
    metadata:
      labels: { app: my-app }
    spec:
      containers:
        - name: app
          image: registry.example.com/my-app:latest
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: my-app-secrets   # OS_DATABASE_URL, OS_AUTH_SECRET, OS_SECRET_KEY
          livenessProbe:
            httpGet: { path: /api/v1/health, port: 8080 }
            periodSeconds: 30
          readinessProbe:
            httpGet: { path: /api/v1/ready, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  selector: { app: my-app }
  ports:
    - port: 80
      targetPort: 8080

/api/v1/ready returns 503 while the kernel is booting, during graceful shutdown, and whenever a data driver stops answering — so rolling restarts drain cleanly and a replica that lost its database stops receiving traffic instead of serving 500s. Before setting replicas > 1, read the multi-node note below.

A replica does recover on its own once the database returns — client libraries re-establish connections without help (the MongoDB driver's topology monitor; knex/pg opening a fresh connection per acquire, verified in #3759). So a transient outage — a failover, a maintenance window — needs no restart: the readiness probe drains the replica while the database is away and re-admits it afterwards. Restart only when the process is genuinely stuck, and note that a replica which booted without its database never re-runs its schema sync.

Bare Node.js (systemd) — without containers

The minority path, and a deliberate one: reach for it when the host has no container runtime available or permitted, when an existing systemd / configuration-management estate already owns process supervision, or when you are debugging directly on the box. It needs Node 22+ and the CLI on a Linux host, and nothing else.

# On the host — no repo clone, just the CLI and your artifact
npm install -g @objectstack/cli
npm install -g pg   # Postgres driver is an optional peer of @objectstack/driver-sql —
                    # install it next to the CLI, or the boot fails loudly (use mysql2 for OS_DATABASE_URL=mysql://…)
scp dist/objectstack.json server:/opt/my-app/objectstack.json
/etc/systemd/system/my-app.service
[Unit]
Description=My ObjectStack App
After=network.target postgresql.service

[Service]
Type=simple
User=objectstack
WorkingDirectory=/opt/my-app
Environment=NODE_ENV=production
Environment=OS_ARTIFACT_PATH=/opt/my-app/objectstack.json
Environment=OS_PORT=8080
EnvironmentFile=/opt/my-app/secrets.env   # OS_DATABASE_URL, OS_AUTH_SECRET, OS_SECRET_KEY
ExecStart=/usr/bin/os start
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now my-app
curl -fsS http://localhost:8080/api/v1/health

Upgrades are atomic: replace the artifact file and restart the service. Roll back by restoring the previous artifact.

Reverse proxy & TLS

Terminate TLS in front of the app (Caddy, nginx, Traefik, or your cloud LB) and keep three things in sync with the public origin:

OS_AUTH_URL=https://app.example.com          # auth callbacks / cookie origin
OS_TRUSTED_ORIGINS=https://app.example.com   # CORS allow-list
OS_PORT=8080                                 # must match the proxy upstream
Caddyfile
app.example.com {
    reverse_proxy localhost:8080
}

A drifted port or origin is the classic self-hosting failure: the app runs, but logins bounce and browsers block API calls. Enable HSTS and tune security headers only after TLS is confirmed — see Production Readiness.

Scaling beyond one node

The default in-process coordination (locks, queues, schedules) is single-node. Before running replicas, set OS_CLUSTER_DRIVER — the runtime then treats the deployment as multi-node and refuses to boot without an explicit OS_SECRET_KEY rather than minting per-node keys that can't decrypt each other's secrets. All replicas must share the same OS_SECRET_KEY, OS_AUTH_SECRET, and database. See Cluster.

Scheduled jobs' leader election has a window, not a deadline. cron / interval / once schedules dedupe across replicas by holding a lock for the duration of each fire — the mutual-exclusion window is the handler's runtime, not the scheduling deadline. Exactly-once per deadline holds only when replica clocks agree to within that window, which an NTP-synced deployment gives you. A replica whose clock lags past the window finds the lock already released and reruns the job; once schedules are the sharpest case, since a one-shot has no later tick to self-correct on.

First boot: create the admin

How the first administrator is created depends on the deployment's tenancy posture, and the two paths are not interchangeable.

single (the default) — the first account wins. On a fresh production database there are no users yet — fresh meaning zero human sys_user rows, not merely zero logins (why that distinction decides this). Open the deployment's root URL and sign up — the very first account to register becomes the bootstrap admin (this works even with OS_DISABLE_SIGNUP=true, which only blocks sign-ups after that first account exists). Do this immediately after the first deploy, before sharing the URL; then create your real user accounts and lock sign-up down via OS_AUTH_SIGNUP_ENABLED / SSO as policy dictates.

Walled postures (group / isolated) — you name the administrators in configuration. First-registrant promotion is removed there: with self-registration reachable, whoever posts to the sign-up endpoint first would otherwise receive cross-tenant access. No grant row is written on those postures, ever. Declare the administrators before first boot instead:

# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com

Then have each of those people register with exactly that address and verify their email. Standing is recomputed on every request from the configured list and the account's own stored record, so it appears the moment verification completes — there is nothing to grant and nothing to click. That registration is admitted by the first-account carve-out, so it works only while the deployment's user directory is still empty; on a database that already carries human rows it is refused, and the recovery is out of band.

The rules below are enforced by the runtime, not advisory:

  • Declare more than one address. A single mailbox is a single point of human failure; losing it leaves the deployment with no administrator and no in-product recovery.
  • Verified only. An account that holds a declared address but whose email is not verified is not an administrator. A record that predates the verification column, or that arrived through an import without it, reads unverified rather than verified.
  • Case and spacing do not matter. Entries are trimmed and lower-cased on both sides of the comparison, duplicates collapse, and a trailing separator or a blank entry is ignored.
  • One bad entry refuses the whole variable. If any entry is not an email address, the deployment gets zero configured administrators — not the remaining good ones. Dropping just the bad entry would leave a narrower administrator set than you declared with nothing anywhere to notice, which is the more dangerous failure. The runtime names the offending entry in the log; fix or remove it, then reload.
  • Empty is fail-closed, and a walled boot refuses it. Unset or blank under a walled posture aborts startup, naming the variable, rather than silently reverting to promoting the first registrant. A value that is set but refused for a bad entry gets past that startup check — it is not blank — so the process starts and then has no administrators. Confirm from the boot log, not from the fact that the process came up.
  • Revocation is a configuration change plus a reload. No endpoint, UI action or API call adds or removes a platform administrator; that surface deliberately does not exist. Edit the variable and roll the process — the next resolution reads the new value.
  • You cannot quietly remove the last one through the user record, either. Changing the last remaining administrator's email address, or clearing their verified flag, is refused, and the refusal points at the configuration as the remedy — the same guard that refuses deleting or banning the last administrator.

On a walled boot the runtime logs the resolved list: per declared address, whether an account exists, whether it is verified, and which account holds standing. That line is the fastest check that the configuration and the real accounts agree. The same answer is available read-only to Setup, discovery and health surfaces; see who holds admin_full_access.

Note the production server seeds no dev credentials — the admin@objectos.ai / admin123 account you may know from os dev exists only on empty development databases. That seed is hard-gated to NODE_ENV=development: it is a development convenience, never a production recovery path.

A seeded directory opens no bootstrap window

Both paths above assume the deployment's first login is still ahead of it. An app that declares people in defineStack({ data }) — or any provisioning job that writes sys_user rows — creates a directory, not logins: those rows carry no sys_account, and the platform counts them as a populated deployment. Measured on a real kernel with three human sys_user rows, zero sys_account rows, outside development, under the default invite_only audience posture:

Operator actionMeasured outcome
A seeded person registers with exactly their declared address403 SELF_REGISTRATION_CLOSED
Anyone else registers403 SELF_REGISTRATION_CLOSED
A seeded person signs in401 INVALID_EMAIL_OR_PASSWORD

The first-account carve-out counts humans, not logins — humans already exist, so it does not fire, and the door stays shut by decision rather than by oversight. No administrator exists to send an invitation. The deployment cannot be recovered from inside, and it keeps looking healthy: its only symptom is a 401 on credentials nobody holds.

The runtime says so at boot. When human sys_user rows exist and no sys_account row does, kernel:ready reports at error level under the grep token no_sign_in_account_at_boot, naming both the consequence and the remedy. If you are handed a deployment nobody can sign in to, that line is the first thing to look for — and note that it asks only whether an account row exists, so a row you wrote yourself silences it whether or not anyone can actually authenticate with it.

The same shape is logged at debug instead, under the same token and naming the reason, when the deployment has a sign-in path that needs no sys_account row of its own: SSO-only mode (OS_AUTH_SSO_ONLY or ssoOnlyMode), a configured social or OIDC provider, enterprise SSO with at least one registered identity provider, or a host that constructs AuthPlugin with hostSignInHandoff: true because it signs people in through a handoff route of its own. A deployment with none of these still reports at error. hostSignInHandoff is a statement by the code that serves such a route, not a way to quiet the report: set on a deployment without one, it hides exactly the dead end this section describes.

The recovery is out of band, and it is one row. Write a pending invitation directly against the store the deployment reads, then have that person register through the ordinary sign-up endpoint — the invitation carve-out admits that one creation under every posture, so the invite_only default stays where it is. One sys_invitation row:

ColumnValue
emailthe address you intend to administer — one the directory does not already hold
statuspending
expires_atany future timestamp
inviter_idthe id of any existing sys_user row

Measured on that population: sign-up 200 and sign-in 200 for the invited address, with no mail transport wired. On the single posture the next boot then promotes that account — measured adminPromoted: true, with the grant landing on the invited account holder and never on a credential-less directory row. On a walled posture the invited address must also be declared in OS_PLATFORM_OWNER_EMAIL and read verified: the operator-provisioning verified stamp fires only for a bootstrap or admin/SCIM creation, never for an invitation-admitted one, so that address needs a mailbox it can receive the verification at.

Invite an address the directory does not already hold. A sign-up for an address that already carries a sys_user row is refused 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, invitation or not — the seeded people get their logins from the recovered administrator afterwards, not from this step.

Two remedies that look like they work, measured:

  • Opening the audience posture is not enough on its own. With the posture widened to email_domain, a seeded person's own registration is refused 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL and nothing is written — the same answer the invite_only default already gives for an address the directory holds, so the widened posture buys that person no login and their sign-in stays 401. A fresh address does get an account, but every posture other than invite_only forces email verification on (unless an open deployment has itself declared it off — see Email verification under each posture), so its first sign-in is refused 403 EMAIL_NOT_VERIFIED until a mail transport delivers the link.
  • A hand-written credential row authenticates nothing. The sys_account row shape is public; the format of the secret stored in its password column is the platform's own. A row carrying a plaintext password is refused 401 INVALID_EMAIL_OR_PASSWORD — while silencing the boot diagnostic above.

The cheap prevention: have whatever seeds the people directory seed their logins too, or leave the directory empty until the first administrator exists. A deployment that boots with zero human rows is the case both paths above describe, and it needs none of this.

Who may sign up — and what each posture tells a stranger

auth.audience.posture is the one declaration answering who may become a user of this deployment: invite_only (the default), email_domain, or open (schema: Auth Config). It also decides what the sign-up endpoint tells someone who is not a user yet. Measured on a populated deployment, posting to /api/v1/auth/sign-up/email:

PostureWhat a stranger learns
invite_only (default)Nothing. A registered address and an unknown one both answer 403 SELF_REGISTRATION_CLOSED — same body bytes, same headers.
email_domainOnly inside a domain you allowlisted. Any address outside the list answers 403 EMAIL_DOMAIN_NOT_ALLOWED, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything.
openWhether any address is already registered. A registered address answers 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL; an unregistered one answers 200.

On open, that disclosure is by design. Anyone who can reach the endpoint can test an address and learn whether it belongs to a user of this deployment, one address per request, holding no account of their own. On an internet-facing deployment that is an address-enumeration oracle. It is a cost of the posture rather than an oversight: the endpoint refuses honestly, and what that refusal replaced was worse for everyone except the enumerator. A duplicate sign-up used to answer 200 carrying a freshly minted user id for a row that was never written — no account, no verification mail, and the person's next sign-in 401 with nothing anywhere explaining it.

That silence was never a real secret, either. The auth engine hashed the password on both lanes to level the timing and stopped there: a genuine registration also writes rows and dispatches a verification mail, so the mail that never arrives — and the account that can never sign in — already told a patient caller what the 422 now states outright. The explicit refusal made an existing disclosure legible; it did not create it. invite_only and email_domain are untouched by it: the uniqueness refusal is reached only for a caller the posture has already admitted, so a stranger on the default still gets 403 SELF_REGISTRATION_CLOSED and learns nothing.

If you need open without the disclosure, ask for it. better-auth, the auth engine underneath, ships the designed alternative: onExistingUserSignUp, a hook that pairs a generic sign-up response with a notification to the address's real holder — "someone tried to register with your email". It is deliberately not wired here, because turning it on trades the honest refusal above for a silent success, which is the shape that behaviour exists to remove. If you run a real internet-facing open deployment and want that trade, open an issue saying so: it is a per-deployment decision, waiting on a deployment that needs it.

Email verification under each posture

  • invite_only follows auth.require_email_verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION), off by default.
  • email_domain forces verification on, and an explicit false is refused: the domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's address.
  • open forces verification on too, unless the deployment turns it off: set OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, or emailAndPassword.requireEmailVerification: false in the stack config. A sign-up is then signed in at once and no verification mail is sent.

The open opt-out is for a deployment with no mail transport that trusts whoever can reach it, such as a pre-production environment. It is loud and it has a cost:

  • The boot logs one warning naming the posture and the consequence, and /api/v1/auth/config reports requireEmailVerification: false — the value actually wired.
  • Anyone can register an address they do not control. An organization invitation sent to that address can then be accepted by that account.
  • It is the deployment's to declare. A false stored only through the settings console is refused under open, and verification stays on.

Go-live

Before pointing real users at the deployment, walk the Production Readiness checklist — security headers, rate limits, metrics, error reporting, backup/restore drills, and data-retention windows.

Your self-hosted app is AI-operable out of the box: every deployment serves an MCP server at /api/v1/mcp under the same permissions and RLS. Disable with OS_MCP_SERVER_ENABLED=false. See Your app as an MCP server.

On this page