Skip to content

Standalone service

Standalone service mode runs zeroth-core as a production single-node deploy fronted by a reverse proxy. Use it when you want real Postgres, TLS, and a process manager (systemd, supervisord, or a pod) — but without the overhead of Docker Compose or Kubernetes.

Use case

  • Production single-node deploy on a VM or bare-metal host
  • Fronted by nginx or Caddy for TLS termination
  • Managed by systemd (or another process supervisor)
  • Postgres for durable state, optional Redis for arq dispatch

Prerequisites

  • Python 3.12+
  • Postgres 14+ with a database and role provisioned
  • (Optional) Redis for ZEROTH_DISPATCH__ARQ_ENABLED=true
  • A reverse proxy (nginx, Caddy, or Traefik) terminating TLS
  • A secret-management story for ZEROTH_* env vars

Install

python3.12 -m venv /opt/zeroth/venv
/opt/zeroth/venv/bin/pip install "zeroth-core[memory-pg,dispatch]"

Configure

Create /etc/zeroth/zeroth.env with at least the following (excerpt — see the full Configuration Reference):

ZEROTH_DATABASE__BACKEND=postgres
ZEROTH_DATABASE__POSTGRES_DSN=postgresql://zeroth:secret@db:5432/zeroth
ZEROTH_DATABASE__ENCRYPTION_KEY=<base64-32-bytes>
ZEROTH_SERVICE_API_KEYS_JSON='[{"credential_id":"ops","secret":"<opaque-token>","subject":"ops","roles":["admin"]}]'
ZEROTH_DISPATCH__ARQ_ENABLED=true
ZEROTH_REDIS__HOST=127.0.0.1
ZEROTH_REDIS__PORT=6379
OPENAI_API_KEY=sk-...

Run Alembic before the first start:

/opt/zeroth/venv/bin/alembic upgrade head

Run

The zeroth-core serve console script runs migrations, bootstraps the service (including agent runners built from the deployment's graph), and serves uvicorn with TLS from zeroth.service.entrypoint — bootstrap and server share one event loop, which the durable run worker requires. Use it directly under systemd (Type=simple):

/opt/zeroth/venv/bin/zeroth-core serve --port 8000

Do not serve zeroth.service.entrypoint:app_factory through uvicorn --factory: uvicorn calls the factory inside its running event loop, where the bootstrap's asyncio.run() raises RuntimeError. For horizontal scale, run one zeroth-core serve process per port behind your reverse proxy (each instance polls the shared run queue; Postgres SKIP LOCKED leasing keeps them from double-claiming runs).

systemd unit

[Unit]
Description=Zeroth Core API
After=network.target postgresql.service

[Service]
Type=simple
User=zeroth
EnvironmentFile=/etc/zeroth/zeroth.env
ExecStart=/opt/zeroth/venv/bin/zeroth-core serve --port 8000
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

nginx front

server {
  listen 443 ssl http2;
  server_name api.example.com;

  ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Verify

curl -I https://api.example.com/health/ready
systemctl status zeroth

See the HTTP API Reference for the full surface you can now exercise through the proxy.

Common gotchas

  • Serving app_factory via uvicorn --factory: the factory calls asyncio.run() and uvicorn invokes it inside a running loop — RuntimeError: asyncio.run() cannot be called from a running event loop. Use zeroth-core serve.
  • Migrations not run: the zeroth-core serve console script runs alembic upgrade head automatically; raw uvicorn does not. Run it once before first start, and after every upgrade.
  • Encryption key: ZEROTH_DATABASE__ENCRYPTION_KEY must be stable across restarts — losing it makes previously stored secrets unrecoverable.
  • Worker count: start with --workers $(nproc) and tune based on observed latency; agent runs are async-heavy so CPU is rarely the bottle.