ObjectStackObjectStack

Self-Hosted Deployment

Run a compiled ObjectStack app on your own infrastructure — bare Node.js, systemd, Docker, and Docker Compose with Postgres, including health checks, reverse-proxy wiring, and the secrets you must pin.

Self-Hosted Deployment

This guide takes the artifact produced by os build / os compile and runs it on infrastructure you operate: a Linux host, a Docker container, or a compose stack with Postgres. It complements the platform-specific Vercel guide and assumes you have read Deployment Modes.

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://…, libsql://…, or a mounted file:… path.
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.

Option 1 — Bare Node.js (systemd)

The simplest deployment: Node 18+ and the CLI on a Linux host.

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

Option 2 — Docker (official image)

The artifact model maps cleanly onto containers, and ObjectStack ships an official runtime image for exactly this: 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 (14.8.0, 14.8, 14, latest) and the image is published multi-arch (amd64/arm64) on every framework release — 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:14.8.0

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

For a self-contained deployable image, extend it. The Dockerfile below (plus the compose stack in the next section and a .dockerignore) ships ready-to-copy in examples/docker — drop the files into your scaffolded project.

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:14.8.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
WORKDIR /srv/app
RUN npm install -g @objectstack/cli@14.8.0
COPY 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: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.

Option 3 — Docker Compose with Postgres

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, ready for trafficReadiness probe

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 and during graceful shutdown, so rolling restarts drain cleanly. Before setting replicas > 1, read the multi-node note below.

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.

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