| title | Self-Hosted Deployment |
|---|---|
| description | 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. |
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 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.
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://…, mongodb://…, or a mounted file:… path (libsql:// / Turso is not supported by the open framework — that driver ships in ObjectStack Cloud). |
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.
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[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.
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
docker/
— drop the files into your scaffolded project.
# ── 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.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
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"]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.
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, ready for traffic | Readiness probe |
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 and during graceful
shutdown, so rolling restarts drain cleanly. Before setting replicas > 1,
read the multi-node note below.
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.
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.
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.
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.
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](/docs/api#your-app-as-an-mcp-server).- Backup & Restore — what to back up, and the restore drill
- Deployment Modes — the map of local / standalone / Cloud
os startreference — every flag and env var- Environment Variables — the full catalog
- Deploy to Vercel — the serverless alternative
- Troubleshooting & FAQ