Self-Hosted Deployment
Run a compiled ObjectStack app on your own infrastructure with the official Docker image — plus Compose with Postgres, Kubernetes, and the bare Node.js fallback, including health checks, reverse-proxy wiring, and the secrets you must pin.
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 (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. 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.2.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.2.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
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx os build # → dist/objectstack.json
# ── Runtime: the official ObjectStack runtime image ──────────────────
FROM ghcr.io/objectstack-ai/objectstack:17.2.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.2.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.
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
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.
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. 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.
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.
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