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 startboots 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:
| Variable | Why it must be set |
|---|---|
OS_DATABASE_URL | Without 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_SECRET | Session 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_KEY | 32-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_PORT | os 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.0Both 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.
| Situation | What the runtime does |
|---|---|
No #sha256= fragment | Boots 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 matches | Boots, and keeps the verified copy under <home>/artifacts. |
#sha256= present, content differs | Refuses to boot, naming the expected and the actual digest. |
#sha256= present, artifact host unreachable | Falls 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 runtime | Refuses 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 change | Safe 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.
# ── 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.jsondocker 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-appPrefer to build the runtime yourself (air-gapped registry, custom base image)? The official image is nothing more than:
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:
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/healthPrefer 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:
| Endpoint | Meaning | Use as |
|---|---|---|
GET /api/v1/health | Process is up and serving HTTP | Liveness probe |
GET /api/v1/ready | Kernel booted and the data drivers answer | Readiness probe |
Wire each to the probe it is named for — they answer deliberately different questions:
/healthchecks 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./readypings 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:
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[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.targetsudo systemctl enable --now my-app
curl -fsS http://localhost:8080/api/v1/healthUpgrades 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 upstreamapp.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.comThen 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 action | Measured outcome |
|---|---|
| A seeded person registers with exactly their declared address | 403 SELF_REGISTRATION_CLOSED |
| Anyone else registers | 403 SELF_REGISTRATION_CLOSED |
| A seeded person signs in | 401 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:
| Column | Value |
|---|---|
email | the address you intend to administer — one the directory does not already hold |
status | pending |
expires_at | any future timestamp |
inviter_id | the 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 refused422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAILand nothing is written — the same answer theinvite_onlydefault already gives for an address the directory holds, so the widened posture buys that person no login and their sign-in stays401. A fresh address does get an account, but every posture other thaninvite_onlyforces email verification on (unless anopendeployment has itself declared it off — see Email verification under each posture), so its first sign-in is refused403 EMAIL_NOT_VERIFIEDuntil a mail transport delivers the link. - A hand-written credential row authenticates nothing. The
sys_accountrow shape is public; the format of the secret stored in itspasswordcolumn is the platform's own. A row carrying a plaintext password is refused401 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:
| Posture | What 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_domain | Only 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. |
open | Whether 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_onlyfollowsauth.require_email_verification(OS_AUTH_REQUIRE_EMAIL_VERIFICATION), off by default.email_domainforces verification on, and an explicitfalseis refused: the domain allowlist is the only gate there, so an unverified sign-up could claim a colleague's address.openforces verification on too, unless the deployment turns it off: setOS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, oremailAndPassword.requireEmailVerification: falsein 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/configreportsrequireEmailVerification: 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
falsestored only through the settings console is refused underopen, 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.
Related
- Backup & Restore — what to back up, and the restore drill
- Deployment Overview — the two lifecycles this section is organised around, and which pages belong to each
os startreference — every flag and env var- Environment Variables — the full catalog
- Troubleshooting & FAQ