ObjectStackObjectStack

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 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 (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_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.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.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
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.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.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:

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.

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
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.

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.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.

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.

On this page