Reference
Self-hosting
Ballast runs anywhere Python and Node run. This page covers deployment topology, the production-hardening checklist, and — honestly — the current limitations and the path past them.
Topology
Two processes and one database. The FastAPI backend is the API and the orchestration engine in a single process — runs execute as async tasks, and the cron scheduler and retention sweeper run as background loops inside it. The Next.js frontend is a stateless web app. Point the frontend at the backend with NEXT_PUBLIC_API_URL and set the backend's CORS_ORIGINSto the frontend's exact origin.
# backend — plain uvicorn behind your reverse proxy, one worker
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1
# frontend — build once (NEXT_PUBLIC_API_URL is inlined at build time), then serve
$ NEXT_PUBLIC_API_URL=https://api.example.com npm run build
$ npm run start
One worker, on purpose
Run a single uvicorn worker. The engine tracks in-flight runs and WebSocket subscribers in process memory, and the cron scheduler (~20s tick) and daily retention sweeper run inside the same process — a second worker would double-fire schedules and sweeps. Scaling past one worker means moving execution to dedicated workers; see the limitations section below.Production env checklist
The default configuration is tuned for local development: no login, SQLite, no rate limit, the SSRF guard off. Before you expose Ballast to a network, set the following. The Docker Compose file already encodes this shape — treat it as the reference.
| Field | Type | Default | Description |
|---|---|---|---|
| AUTH_MODE | required | open | Enforce login. Every /api request must carry a session token or API key; unauthenticated requests get 401 (health, login, signup, and accept-invite stay open). |
| SECRET_KEY | string | per-boot random | A persistent, secret HMAC key — e.g. openssl rand -hex 32. Without it, a random key is generated per boot and every session is invalidated on restart. |
| OWNER_PASSWORD | string | generated | The owner's first-boot password. Set it explicitly; otherwise one is generated and printed to the server log exactly once. |
| CORS_ORIGINS | string | http://localhost:3000 | The exact origin(s) of your console. Comma-separated. Do not leave the localhost default in production. |
| RATE_LIMIT_PER_MINUTE | int | 0 (off) | Per-IP request ceiling. Turn it on — e.g. 300. Exceeded requests get 429. |
| HTTP_TOOL_BLOCK_PRIVATE | 1 | 0 | Set to 1 so the http_request tool refuses loopback/private/link-local targets. Off by default; a non-negotiable in production. |
| SIGNUP_MODE | approval | closed | open | Do not leave self-serve signup open on a public deployment. approval holds new accounts as pending for an admin; closed disables signup entirely (invite-only). |
| DATABASE_URL | string | sqlite:///./agentos.db | Point at Postgres for anything shared or that must survive the host. The psycopg2 driver ships in requirements.txt. |
Set SECRET_KEY and AUTH_MODE first
InAUTH_MODE=open (the default) any request with no credentials is treated as the workspace owner — an open door. Production needs AUTH_MODE=required plus a persistent SECRET_KEY; without a fixed key, session tokens are signed with a random per-boot secret and every user is logged out on each restart or deploy.The http_request tool is an SSRF primitive
WithHTTP_TOOL_BLOCK_PRIVATE off, a workflow can make the server fetch any URL it can reach — including cloud metadata endpoints and internal services. Set it to 1, and prefer to also restrict egress at the network layer. Combined with configured LLM keys, an unhardened deployment is both an open model proxy and an internal-network scanner.How authentication works
Ballast has built-in session auth — you do not need to bolt on an external proxy, though you still can. Credentials are resolved by the auth middleware in this precedence order:
X-API-Keyheader — acts as the key's linked member (their effective permissions) or the key's plain role for unlinked keysAuthorization: Bearer <token>— a session token issued at login- Nothing — in
openmode acts as the workspace owner; inrequiredmode the request is rejected with401
Passwords are hashed with scrypt (per-password salt). Session tokens are compact HMAC-SHA256-signed payloads carrying the member id and a 7-day expiry, verified statelessly against SECRET_KEY — which is exactly why that key must be persistent. Members join by invite code (Settings → Members → invite, then they register at /accept-invite), or via self-serve signup governed by SIGNUP_MODE.
required mode the owner account is given the OWNER_PASSWORD you set (or a generated one, logged once). Sign in at /login with the owner email and that password, then invite your team.Database
- SQLite ships in WAL mode with a 30s busy timeout — solid for one machine, and runs survive restarts because checkpoints are rows, not memory. It is not suitable when the host is ephemeral or when you need external backups
- Postgres is the production choice: set
DATABASE_URL=postgresql://…. The driver is already inrequirements.txtand the schema — portable SQLAlchemy — is created on first boot. There is no separate migration step for a fresh database; use Alembic for column changes to an existing production database - Growth lives in a few tables — runs, run steps, checkpoints — plus the append-only audit log. Archive old checkpoints on your own schedule; nothing in the engine reads checkpoints of finished runs except
/resume
Reverse proxy & TLS
Terminate TLS at a reverse proxy (nginx, Caddy, an ALB) and forward to uvicorn on :8000. One thing to get right: the live run stream is a WebSocket at /api/runs/{id}/stream, so the proxy must pass the upgrade headers or the console's timeline will silently fall back to nothing.
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
# WebSocket upgrade for /api/runs/{id}/stream
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # long-lived run streams
}
- Set
CORS_ORIGINSto the HTTPS origin the console is served from — CORS is the outermost middleware, so even 401/429 responses carry the right headers - Build the frontend with
NEXT_PUBLIC_API_URLset to the public API origin (it is inlined at build time), and let the browser reach the WebSocket overwss://
One-command deployment shape
The bundled Compose file runs both services with the whole checklist applied — required auth, approval-gated signup, rate limiting, the SSRF guard, and a persisted SQLite volume. Supply real secrets on the command line rather than editing the file:
$ SECRET_KEY=$(openssl rand -hex 32) OWNER_PASSWORD=$(openssl rand -base64 18) \
docker compose up --build -d
The backend image pins --workers 1 and includes a healthcheck that polls /api/health; the frontend waits for the backend to be healthy before starting. For a real deployment, swap the SQLite volume for a DATABASE_URL pointing at managed Postgres.
Change the placeholder defaults
The Compose file's fallbackSECRET_KEY and OWNER_PASSWORDare obvious placeholders (“change-me…”). If you run it without exporting real values, you are shipping a known signing key and a known owner password.Cloud deployment
- Frontend — deploy to Vercel (or any Node host). Set
NEXT_PUBLIC_API_URLto your backend's public origin before the build - Backend — deploy
backend/Dockerfileto Railway, Fly, Render, or any container host. Keep--workers 1. Set the full production env checklist - Database — managed Postgres (e.g. Neon) via
DATABASE_URL. Tables auto-create on first boot
Backups
Everything durable is one database. On Postgres, your standard point-in-time-recovery and snapshot story applies unchanged — runs, steps, checkpoints, audit logs, members, and API-key hashes are all ordinary rows. On SQLite, back up the agentos.db file (and its -wal/-shm sidecars) or use sqlite3 .backupfor a consistent copy. Because a paused or failed run's state is a checkpoint row, a restored backup restores resumable runs too.
MVP limitations & the scale path
Ballast is honest about what the current runtime does and does not do. Two limitations are load-bearing:
In-process, single-worker execution
Runs execute inside the API process as async tasks, and the run/cancel registry, scheduler, and sweeper are per-process. This is simple and crash-safe (state is checkpointed to the database), but it means one process handles both serving and executing, and you cannot add worker replicas without double-firing background loops. Scale vertically first. Past that, move execution to dedicated workers — the checkpoint format is the natural handoff point, since a worker only needs the run id and the last checkpoint to continue.
Single implicit workspace
There is one workspace with role-based members (admin/editor/viewer) and API keys, not full multi-tenant isolation. Per-team data partitioning and SSO/SAML are the enterprise-tier upgrade path, not configuration toggles. Deploy one instance per tenant boundary if you need hard isolation today.
What you do not need to harden
Workflow expressions are not an attack surface. Condition and loop expressions run in a whitelisting AST interpreter with no imports, no attribute access, no dunders, and hard size limits — documented in the expression language reference. And model provider keys are bring-your-own — added per workspace as encrypted model connections, never written to the database in plaintext, checkpoints, or logs. The real perimeter is the checklist above: auth, a persistent signing key, egress control, and rate limiting.