ox.toml reference: every key, default and example
The ox.toml reference: every key with its default, what ox detects from your repo, services, variables, and working examples for Next.js, Python, Go, SPAs.
Last updated 2026-10-08
The deploy config is a file named ox.toml at your repo root. It names the web process, workers, cron jobs, services, and build. Most repos need none at first, because ox detects these from your files; write one for what detection got wrong. Unknown keys, wrong types, and bad values are errors, and every problem is listed at once. Secrets never live in this file; they go in the dashboard's Variables tab (see Variables).
For a complete file for one framework, start from a guide: Django, Next.js, FastAPI, Node, Laravel, Rails or a static site. When a deploy fails on a key, Troubleshooting explains the message.
domains = ["example.com"] # served by [app], or by [static] when there is no [app]
packages = ["ffmpeg"] # apt system libraries only (no toolchains)
[app] # the one web process; gets $PORT
start = "serve" # default: detected
health = "/healthz" # HTTP 2xx/3xx on this path; default: TCP connect
port = 9034 # pin; default: allocated and recorded
memory = "512M" # this process's cap
sandbox = "relaxed" # the only value; default is strict
[static] # files served by Caddy
dir = "dist" # relative to the repo root
spa = true # unknown paths serve index.html
api = ["/api", "/admin"] # these prefixes go to [app]; requires [app]
paths = { "/static" = "backend/static" } # more built dirs, each at its URL path
[build] # runs as the project user, variables available
install = "npm ci" # runs first; default: the lockfile's install
commands = ["make build"] # runs after install; default: detected
migrate = "make migrate" # after build, before the switch; default: detected
[workers]
worker = "celery -A app worker" # short form
bot = { run = "python bot.py", memory = "512M", sandbox = "relaxed", port = true, health = "/health" }
[cron] # 5-field cron or @hourly/@daily/@weekly/@monthly, UTC
digest = { schedule = "0 7 * * *", run = "python manage.py send_digest" }
[services] # postgres, redis, neo4j, qdrant, mysql, or your own (run = ...)
postgres = { version = "18", extensions = ["vector"] }
redis = {}
qdrant = { only_for_this_project = true, port = 9200 }
[tools] # a mise tool = version (no npm:/cargo:/pipx:-style package backends); default: from repo files
node = "24"
bun = "1.3"
[storage]
keep = ["media"] # repo paths, linked to the data dir, survive releases
[limits] # the project's whole slice (processes + builds)
memory = "1G"
cpu = 1.5 # cores
[models]
huggingface = ["org/repo", "org/repo@revision"]Validate locally
Run ox check [dir] (default .) on your machine before pushing. It is offline and reads nothing but the checkout. It prints the resolved plan with each value's source (declared, detected:<file>, default), the variables to set on the dashboard, and every problem at once. Fix them all until it prints Ready to deploy. ox check --json gives the same data as JSON.
These are the same checks the deploy runs. A bad manifest or a missing variable stops the deploy before anything on your server changes, with the exact reason on the run.
Top-level keys
domains: the names Caddy serves, served by[app], or by[static]when there is no app. Domains can also be added per project in the dashboard and apply on the next deploy.packages: Ubuntu system libraries (ffmpeg,libmagic1). Toolchains (nodejs,npm,python3-pip,golang*,rustc,cargo) are refused; declare them in[tools].- Git submodules are refused: releases are built from
git archiveof the commit.
[app]
The one web process, behind Caddy.
start: the command that serves HTTP on$PORT. Default: detected from the repo. Commands run withbash -euo pipefail -cin the release directory, with the tools onPATH;$PORTand other variables expand in the shell.health: a path that must return HTTP 2xx/3xx. Default: a TCP connect on the port.port: leave it out. ox allocates and records a port, and the zero-downtime switch runs two sides at once. A pin (1024–65535, unique on the host) runs one side and restarts in place, with a moment of downtime per deploy. Pin only for an app that hardcodes its port.memory: this process's cap (K/M/G). A value larger than[limits] memoryfails validation.sandbox: the only value is"relaxed"; the default is strict.
[static]
index.html when spa is on, and the api prefixes go to your app.dir: the built files Caddy serves, relative to the repo root. It must exist after the build. Without[app], the project is a static site.spa: unknown paths serveindex.html.api: URL prefixes that stay proxied to[app]. Requires[app].paths: more built directories, each served at its URL path (a Djangocollectstaticoutput next to an SPA).dirmay be omitted when onlypathsis set.
[build]
install: installs the packages, first, as the project user in the new release. Default: the lockfile's install. Declare it only when the install is not the root lockfile's.commands: run afterinstall, in order. Default: detected from the package scripts. Declaring them replaces only the detected build steps; the install still runs.migrate: runs after the build, before traffic switches. A snapshot of the database is taken first when it runs against a database with tables. Default: detected (Django, Prisma).
[workers]
port = true a worker gets a port, the app reaches it at $BOT_URL, and its health check calls that port.Background processes, each its own systemd unit. A worker is a command string (short form) or a table: { run, memory, sandbox, port, health }.
- Names match
^[a-z][a-z0-9-]{0,27}$;appis reserved. port = trueallocates a port, exposed as that worker's$PORTand as a<NAME>_URLvariable (name upper-cased,-becomes_). This is how processes of one project call each other.healthneedsport = true: the health check calls the worker's$PORT.
[cron]
name = { schedule = "...", run = "..." }, rendered as a systemd timer.scheduleis a strict 5-field cron expression or@hourly/@daily/@weekly/@monthly, in UTC.
[services]
Five types are built in, and an entry with run is a service of your own. Declaring one is all you do: ox installs it, creates the database or instance, and writes the connection keys on every deploy.
- Each entry is
<name> = { ... }, named like a worker. Itstypedefaults to the name, sopostgres = {}is a postgres andanalytics = { type = "postgres" }is a second postgres database for the same project. An entry not named like its type gets its keys prefixed:ANALYTICS_DATABASE_URL. postgres: shared by default, one database and role per project. Version 18. ProvidesDATABASE_URL. Extensions, likevector, come from the service:postgres = { extensions = ["vector"] }.redis: private by default (shared allowed, one database index per project). Version 8. ProvidesREDIS_URL.neo4j: private by default (shared allowed). Version 5. ProvidesNEO4J_URI,NEO4J_USER,NEO4J_PASSWORD.qdrant: private only. Version 1. ProvidesQDRANT_URL.mysql: private only, served by MariaDB 11.8 (MySQL-compatible). ProvidesMYSQL_URL, with the userappand the databaseapp.
only_for_this_project = trueruns a private instance with its own unit, its own data under the project's data dir, its own password, and its own port (allocated, or theportpin);falseshares the host's instance.porton a shared service fails validation.versionpins are enforced: a version ox cannot install on this OS fails with the available list.- Every entry with data is backed up daily (a shared redis or neo4j is not) after 03:00 UTC (7 kept on the server, or 2 once the S3 bucket set in Settings has them; 30 kept in the bucket), and on demand with Back up now on the Services tab. Postgres is dumped online; a private instance stops for the moment its data dir is archived. Restore takes a kept backup, an object in the bucket, or an uploaded file.
- Removing a service never drops data by itself. The Services tab shows it as unused, with its size and a Delete data button.
- To use your own external database, remove the service from
ox.tomland set the URL in Variables.
Custom services
An entry with run is a service your repo defines itself, always private:
[services.search]
run = "meilisearch --db-path $SERVICE_DATA_DIR --http-addr 127.0.0.1:$PORT --master-key $SERVICE_PASSWORD"
tool = "github:meilisearch/[email protected]" # a mise tool; or packages = [...] (apt)
health = "/health" # HTTP 2xx/3xx; default: TCP connect
memory = "256M" # floor and cap; default 128M
port = 9300 # pin; default: allocated
provides = { MEILI_URL = "http://${host}:${port}", MEILI_MASTER_KEY = "${password}" }
backup = false # default true: its data dir, dailyrunis required. It runs withbash -euo pipefail -cin its data dir, as the project user, in the strict sandbox, with$PORT,$SERVICE_DATA_DIR,$SERVICE_PASSWORD, and the tools onPATH. It gets none of the app's variables.providesvalues are templates over${host},${port}, and${password}; keys are upper snake case and may not collide with another provided key. Without it, the entry provides<NAME>_URL = http://127.0.0.1:<port>.- It starts, and passes health, before the workers and the app on every deploy, and restarts only when
run,tool,packages,memory, or its port changes. type,version,extensions, andonly_for_this_project = falseare refused on it.- A Meilisearch, Typesense, Elasticsearch or OpenSearch, Redis, Valkey, ClickHouse, Chroma, or Memcached gets an Explore page, known by its
tool,packages, orrun;explore = "meilisearch"names it when none of those does. Explore sends$SERVICE_PASSWORDonly whenrunorprovidesuse it.
[tools], [storage], [limits], [models]
[tools]: a mise tool name = version (node = "24",uv = "0.11", or a download-only backend likeaqua:,github:,ubi:). Backends that run a package's own code while installing (npm:,cargo:,pipx:,gem:,go:,asdf:,vfox:) are refused, because every project's tools share one tree; install those in your build instead. Default: read frommise.toml,.nvmrc, lockfiles,go.mod, and friends, or ox's default table. The first deploy records the resolved versions; a new ox release never silently changes them.[storage] keep: folders in the repo the app writes to, like["media", "backend/logs"]. ox links each to the project data dir (OX_DATA_DIR), so it is writable and kept across releases.[limits]:memorycaps the project's whole slice (processes and builds);cpuis a number of cores.[models] huggingface: model repos pulled into the project's cache on every deploy ("org/repo"or"org/repo@revision"). The environment gainsHF_HOME. Gated models need the Hugging Face token from Settings.
Detection
Detection runs on the checked-out commit and only fills keys the manifest leaves empty. It never overrides a declared value.
- Version files (
mise.toml,.tool-versions,.nvmrc,.python-version,package.jsonengines,go.mod,rust-toolchain.toml) fill[tools]; a detected language with no version file gets ox's default table. - Lockfiles fill the package manager, install, build, and start commands:
bun.lock,pnpm-lock.yaml,yarn.lock,package-lock.json,uv.lock,poetry.lock,requirements.txt. manage.pyfills Django migrate, collectstatic, and a gunicorn/uvicorn start.go.modfillsgo buildand the start binary.Cargo.tomlfillscargo build --release. A Vite build with no start script fills[static] distwithspa = true.- Only when the repo has no
ox.toml, dependencies also fill[services]: python or node postgres drivers imply postgres;redis,ioredis,bullmq,celery[redis]imply redis. With anox.toml,[services]is exactly what is declared; an implied but undeclared service shows as a hint on the review screen. - A repo with nothing to run and no detectable start command is asked for one on the review screen before the first deploy.
Variables
Every variable is a key, a value, and a source, managed in the dashboard's Variables tab or with ox vars. None of them live in ox.toml.
- Provided by ox, on every deploy:
PORT(per process),HOST,PUBLIC_URL,PUBLIC_HOST,<WORKER>_URLfor eachport = trueworker,OX_ENV,OX_PROJECT,OX_RELEASE,OX_DATA_DIR, the service keys above, andHF_HOMEwith[models]. Locked in the UI. Saving your own value under a provided key is refused; remove the service instead to use your own. - Yours: API keys and feature config, typed by you. Values are hidden until you reveal one, and values 6 characters or longer are redacted in run logs. Save & deploy deploys the current production commit, because build-time variables can change the build; Save, deploy later only saves. On a staging copy or preview, a key still equal to production's reads inherited, and Sync from production takes production's values.
- Needed: key names in the repo's
.env.example(or.env.sample/.env.template), at the root or one directory deep. The deploy is refused with the list, and the project page shows it with fields, until each one is set. A provided key or a key of yours satisfies one, even empty.
- A value may reference another:
${NAME}for any variable, or${service.field}with fieldhost,port,user,password,database, orurl, likeCELERY_BROKER_URL=${REDIS_URL}.$${is a literal${; a bare$NAMEis never expanded. Unknown references and cycles are refused at save.PORTandOX_RELEASEcannot be referenced. - ox never injects framework defaults (
DEBUG,SECRET_KEY,ALLOWED_HOSTS,CORS_*,NODE_ENV), never reads the repo's.env, and never edits a value of yours. Set framework variables as yours when the app needs them. - Every save is kept as a version on your server, never on ox's: History lists the last 20 and restores one through the same diff, and a rollback can also restore the variables its release ran with.
Staging & Promote
A staging copy is the same repository on the same server with its own address, variables, and databases. Promote to production deploys production at the exact commit staging runs now, as a normal production deploy.
- Review. ox asks your server which commit staging and production run, and lists the commits production does not have yet (newest first, up to 100; it says so when more may differ).
- Confirm. Your confirmation names the commit you saw. If staging moved meanwhile, ox refuses and asks you to look again.
- Deploy. Your server fetches that commit from your repository and builds it, and runs its migrations, with production's variables against production's databases.
- Switch. The new release starts beside the live one and takes traffic only after its health check passes; a failed build, migration, or health check leaves the live release serving.
- Auto-deploy. When the commit is not the newest on production's branch, production's auto-deploy pauses so the next push does not replace it; the review says so, and Settings resumes it.
Never moved: variables (production keeps its own; set a key the new code needs on production first), data (the migrations run against production's database), domains, password protection, and previews. To undo, Roll back to this on production's previous release; migrations are not reverted. From a terminal or an agent: ox promote <project> --yes --wait prints the commits, deploys, and exits with the deploy's result.
Examples
A built SPA with no backend (Caddy serves dist/, the repo's own build produces it):
domains = ["www.example.com"]
[static]
dir = "dist"
spa = trueNext.js SSR (start, build, and the node version come from package.json and the lockfile):
domains = ["app.example.com"]
[app]
health = "/"Python with postgres, redis, a worker, cron, a migration, and persistent uploads:
[app]
start = "uv run python app.py"
health = "/health"
[build]
migrate = "uv run python migrate.py"
[workers]
worker = "uv run python worker.py"
[cron]
tick = { schedule = "* * * * *", run = "uv run python cron.py" }
[services]
postgres = {}
redis = {}
[storage]
keep = ["uploads"]Go API with postgres and redis, a migration command, and a React SPA (Rust is the same shape: cargo build --release --locked and start ./target/release/server):
domains = ["api.example.com"]
[app]
start = "./server -port $PORT"
health = "/health"
[static]
dir = "dist"
spa = true
api = ["/api", "/health"]
[build]
commands = ["go build -o server ./cmd/server", "npm run build"]
migrate = "go run ./cmd/migrate"
[services]
postgres = {}
redis = {}
[tools]
node = "24"A bot with no public URL: a private neo4j, a private qdrant, and Hugging Face models. The worker binds its own port so its health check can call it:
[build]
install = "uv venv --python 3.12 .venv && uv pip install --python .venv -r requirements.txt"
[workers]
bot = { run = "exec .venv/bin/python bot.py", port = true, health = "/health", memory = "1G" }
[services]
neo4j = {}
qdrant = {}
[models]
huggingface = ["KanariKanaru/nsfw-image-detection-384-onnx"]
[tools]
uv = "0.11"
[limits]
memory = "1500M"Common mistakes
- Pinning
[app] portwhen the app reads$PORT. Leave it out; a pin costs a short restart per deploy. - Setting a provided key (
DATABASE_URL,REDIS_URL,PORT) as your own. Refused at save. Use an external database by removing the service and setting the URL as yours. - Git submodules. Refused; releases come from
git archive. Vendor the code. - Expecting framework env vars (
DEBUG,SECRET_KEY,ALLOWED_HOSTS,CORS_*,NODE_ENV). ox never injects them. - Toolchains in
packages. Refused; declare[tools]instead. - Legacy keys from the old engine (
runtime,package_manager,[deploy],[frontend],writable_paths, and the old array-of-table blocks). Each fails with the ox1 key that replaces it. {port}placeholders. Commands read$PORTfrom the shell.- A worker with
healthbut noport = true. Fails: the health check calls the worker's$PORT. - Cron in local time. Schedules run UTC.
static.apiwithout[app], ordomainswith neither[app]nor[static]. Both fail validation.- Absolute host paths under
/srv,/var,/etc,/opt, or/home. Refused; use paths relative to the repo root.
Agent instructions
The Copy skill for AI agent button at the top copies the bundled ox.toml skill for your coding agent. It is the full authoring contract: schema, detection, services, variables, examples, and mistakes. The same text is at /ox-skill.md as a plain file.
---
name: ox-manifest
description: Author and review ox deploy manifests (ox.toml at the repo root) for the ox1 engine and validate them with `ox check` before pushing. Use when generating or editing ox.toml, fixing a validation error, or preparing a repo for a deploy from the ox dashboard.
---
# ox manifest authoring
`ox.toml` sits at the repo root and is the whole deploy contract for the ox1 engine: the web process, workers, cron, services, build. No LLM runs at deploy time. ox deploys one repo onto one Ubuntu 26.04 host with systemd, Caddy, PostgreSQL 18, Redis, neo4j, and qdrant from apt or mise, and mise toolchains. There is no Docker. Releases are built from `git archive` of the commit, so git submodules are refused.
Use this skill whenever you generate or edit an `ox.toml`, fix a validation error, or prepare a repo for a deploy from the ox dashboard.
## Workflow
1. **Inspect the repo first.** Read the files that drive detection: lockfiles, `package.json`, `pyproject.toml` / `requirements.txt` / `manage.py`, `go.mod`, `Cargo.toml`, `mise.toml` / `.nvmrc` / `.python-version`, `prisma/schema.prisma`. The detection table below says what ox fills from each.
2. **Write the smallest ox.toml.** Declare only what detection cannot know: `domains`, the `[services]` the app needs, `[workers]`, `[cron]`, and any command detection gets wrong. Leave `[tools]` versions, `[build] install`, `[build] commands`, `[app] start`, and `[static]` out when the repo names them. Detection never overrides a declared value. A build runs `[build] install` (the lockfile's install, detected) and then `[build] commands`; declaring `commands` replaces only the detected build steps, never the install. Declare `install` only when the packages are not installed from the root lockfile (a monorepo, a venv on a specific Python). A repo with no `ox.toml` at all deploys zero-config, and dependencies even imply `[services]`; once an `ox.toml` exists, `[services]` is exactly what it declares.
3. **Run `ox check`** from the repo root. It is offline and reads nothing but the checkout. It prints the resolved plan with each value's source (`declared`, `detected:<file>`, `default`) and every problem at once. Read the `build.install` and `build.commands` rows: they are exactly what runs, in order. An app with dependencies and no `build.install` row ships a release with no packages, and it crashes at start. Fix every problem and rerun until it prints `Ready to deploy.` `ox check --json` gives the same data as JSON. Build the binary from the ox repo with `go build -o ox ./cmd/ox` when it is not installed. These are the same checks preflight runs: a bad manifest or a missing variable stops the deploy before the host is touched.
4. **List the variables the operator must set.** Read `.env.example` (or `.env.sample` / `.env.template`) at the root or one directory deep. Every key there that ox does not provide must be set in the dashboard's Variables tab, and the deploy is refused until each one is set. Hand the operator the list with a one-line note per key. Commit the example file with placeholder values only; real secrets never go in the repo.
5. **Push.** A push to the default branch deploys at once (GitHub's webhook), with a poll every minute as the fallback (or the operator presses Deploy). A failed commit is not retried until the next push or a manual Deploy.
## Schema (TOML; every key is optional, unknown keys fail)
```toml
domains = ["example.com"] # served by [app], or by [static] when there is no [app]
packages = ["ffmpeg"] # apt system libraries only (no toolchains)
internal = true # serve nothing public: no preview host, no domain, no PUBLIC_URL; the app runs for its workers on loopback. Refused with `domains`
[app] # the one web process; gets $PORT
start = "serve" # default: detected (§4.2)
health = "/healthz" # HTTP 2xx/3xx on this path; default: TCP connect
port = 9034 # pin; default: allocated and recorded
memory = "512M" # this process's cap
sandbox = "relaxed" # the only value; default is strict
[static] # files served by Caddy
dir = "dist" # relative to the repo root
spa = true # unknown paths serve index.html
api = ["/api", "/admin"] # these prefixes go to [app]; requires [app]
paths = { "/static" = "backend/static" } # more built directories, each served at its URL path
[build] # runs as the project user, variables available
install = "npm ci" # runs first; default: the lockfile's install
commands = ["make build"] # runs after install; default: detected
migrate = "make migrate" # runs after build, before the switch; default: detected
[workers]
worker = "celery -A app worker" # short form
bot = { run = "python bot.py", memory = "512M", sandbox = "relaxed", port = true, health = "/health" }
[cron] # 5-field cron or @hourly/@daily/@weekly/@monthly, UTC
digest = { schedule = "0 7 * * *", run = "python manage.py send_digest" }
[services] # built-in types: postgres, redis, neo4j, qdrant, mysql
postgres = { version = "18", extensions = ["vector"] }
analytics = { type = "postgres" } # a second database: ANALYTICS_DATABASE_URL
redis = {} # private by default
qdrant = { port = 9200 } # private only; pin its port
[services.search] # a custom service: an entry with run
run = "export MEILI_MASTER_KEY=\"$SERVICE_PASSWORD\"; exec meilisearch --db-path \"$SERVICE_DATA_DIR/db\" --http-addr 127.0.0.1:$PORT --env production"
tool = "github:meilisearch/[email protected]" # a mise tool; or packages = [...] (apt)
health = "/health" # HTTP 2xx/3xx; default: TCP connect
memory = "512M" # cap and capacity floor; default 128M
provides = { MEILI_URL = "http://${host}:${port}", MEILI_MASTER_KEY = "${password}" }
backup = true # default: its data dir is backed up daily
[tools] # a mise tool = version (no npm:/cargo:/pipx:-style package backends); default: from repo files (§4.2)
node = "24"
bun = "1.3"
[storage]
keep = ["media"] # repo paths, linked to the project data dir: writable, survive releases
[limits] # the project's whole slice (processes + builds)
memory = "1G"
cpu = 1.5 # cores
[models]
huggingface = ["org/repo", "org/repo@revision"]
```
Rules behind the fields:
- **Names.** Worker and cron names match `^[a-z][a-z0-9-]{0,27}$`, and `app` is reserved.
- **Sizes and CPU.** `memory` takes `K`/`M`/`G` suffixes, `cpu` a positive number of cores. A per-process `memory` larger than `[limits] memory` fails validation.
- **Ports.** Leave `[app] port` out: ox allocates and records one, and both sides of the zero-downtime switch need their own. A pin (1024-65535, unique on the host) runs one side only and restarts in place, with a moment of downtime per deploy. `port = true` on a worker allocates a port and exposes it as that worker's `$PORT`; a worker with `health` must have `port = true`.
- **Commands** run with `bash -euo pipefail -c` in the release directory, with the tools on `PATH`. `$PORT` and other variables expand in the shell. There are no `{port}` placeholders.
- **`packages`** are Ubuntu system libraries (`ffmpeg`, `libmagic1`). Toolchains (`nodejs`, `npm`, `python3-pip`, `golang*`, `rustc`, `cargo`) are refused with the `[tools]` line that replaces them.
- **No host paths.** No value may name an absolute path under `/srv`, `/var`, `/etc`, `/opt`, or `/home`. `keep` and `dir` entries are relative and may not contain `..`.
- **`[static]`.** Without `[app]`, the project is a static site. `api` requires `[app]`. `dir` must exist after the build. `[static] dir` may be omitted when only `paths` is set.
## Detection (defaults only)
Detection runs on the checked-out commit, only fills keys the manifest leaves empty, and never overrides a declared value.
| Signal | Default filled |
|---|---|
| `mise.toml`, `.tool-versions`, `.nvmrc`, `.node-version`, `.python-version`, `package.json` `engines.node`/`packageManager`, `go.mod` `go`/`toolchain`, `rust-toolchain.toml` | `[tools]` versions |
| none of the above for a detected language | the ox release's default version table (`ox check` prints it) |
| `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json` | package manager, `[build] install` (frozen lockfile), `build` script, `start` script |
| `uv.lock`; `poetry.lock`; `requirements.txt` | `[build] install`: `uv sync --frozen --no-dev`; `poetry install --only main`; `uv venv` + `uv pip install -r requirements.txt` |
| `manage.py` | Django: `migrate --noinput`, `collectstatic --noinput`; start with gunicorn/uvicorn when it is a dependency |
| `go.mod` | `go build -o .ox/bin/app .`, start `.ox/bin/app` |
| `Cargo.toml` | `cargo build --release`, start the package's binary |
| a Vite/SPA build with no start script | `[static] dir = "dist"`, `spa = true` |
| `index.html` at the root and nothing else | `[static] dir = "."` |
| `prisma/schema.prisma` | migrate `prisma migrate deploy` |
- Only when the repo has **no** `ox.toml`, detection also fills `[services]` from dependencies: `psycopg`/`psycopg2`/`asyncpg`/`pg`/`postgres`/Prisma with the `postgresql` provider imply postgres; `redis`/`ioredis`/`bullmq`/`celery[redis]` imply redis. With an `ox.toml`, `[services]` is exactly what is declared, and an implied but undeclared service shows as a hint on the review screen.
- The first successful plan records every resolved value with its source. Later plans reuse recorded values until the repo declares the key or the detection signal changes (a new `.nvmrc` re-resolves node; a new ox release never silently changes a recorded version).
## Services
Each `[services]` entry is `<name> = { ... }` (names like workers'). Its type defaults to its name: `postgres = {}` is a postgres, `analytics = { type = "postgres" }` a second postgres for the same project. An entry with `run` is a custom service. ox installs each one, creates its database or instance, generates its password, and writes the connection keys.
| Type | Default mode | Version | Keys ox provides | Backup |
|---|---|---|---|---|
| `postgres` | shared: a database and role per entry | 18 | `DATABASE_URL` | `pg_dump` |
| `redis` | private (shared: an index per entry, no password) | 8 | `REDIS_URL` (private: carries its password) | private: files |
| `neo4j` | private (shared: one instance, one admin for all) | 5 | `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` | private: files |
| `qdrant` | private only | 1 | `QDRANT_URL` | files |
| `mysql` | private only (MariaDB, MySQL-compatible) | 11 | `MYSQL_URL` | files |
| custom (`run`) | private only | its `tool` | its `provides`, or `<NAME>_URL` | files unless `backup = false` |
- `only_for_this_project = true` runs a private instance: the unit `ox-<project>-svc-<entry>.service` in the project's slice, as the project user, in the strict sandbox, with data under `$OX_DATA_DIR/services/<entry>/` and its own port (allocated, or the `port` pin). `only_for_this_project = false` uses the shared instance; `port` on a shared entry fails validation. The default mode is recorded, so a project whose redis resolved shared before keeps shared.
- An entry named like its type provides its keys as they are; any other entry prefixes them with its name upper-cased (`job-queue = { type = "redis" }` provides `JOB_QUEUE_REDIS_URL`). Two entries providing the same key fail validation.
- A custom service's `run` runs with `bash -euo pipefail -c` in its data dir with `PORT`, `SERVICE_DATA_DIR`, `SERVICE_PASSWORD`, and the tools' `PATH`, and none of the app's variables. `tool` is `name@version` (a mise tool); `packages` are apt names; `provides` values are templates over `${host}`, `${port}`, `${password}`, with upper snake case keys. `type`, `version`, `extensions`, and `only_for_this_project = false` are refused on it. Keep secrets off its command line: read `$SERVICE_PASSWORD` into the service's own env var. A Meilisearch, Typesense, Elasticsearch or OpenSearch, Redis, Valkey, ClickHouse, Chroma, or Memcached gets an Explore page (bind it to 127.0.0.1 on `$PORT`), known by its `tool`, `packages`, or `run`; set `explore = "<kind>"` only when none of those names it.
- Private instances start, and pass health, before migrate and the app, and restart only when their definition changes.
- `version` pins are enforced: a version ox cannot install on this OS fails preflight with the available list. postgres extensions: `postgres = { extensions = ["vector"] }`.
- Preflight checks the whole host has room: each shared instance once, every private instance of every project, every `[limits] memory`.
- Removing an entry from `ox.toml` stops it and keeps its data; the Services tab lists it as unused with its size and a Delete data button. Deleting the project keeps a 7-day trash with a backup of every entry that has a backup method.
## Variables
Three kinds, all managed in the dashboard's Variables tab:
- **Provided by ox**, on every deploy: `PORT` (per process), `HOST`, `PUBLIC_URL`, `PUBLIC_HOST`, `<WORKER>_URL` for each `port = true` worker (`http://127.0.0.1:<port>`, name upper-cased, `-` becomes `_`), `OX_ENV` (`production`/`staging`), `OX_PROJECT`, `OX_RELEASE`, `OX_DATA_DIR`, the service keys above, and `HF_HOME` with `[models]`. Locked in the UI; copy them out when a command needs one.
- **Yours**: API keys and feature config, typed by the operator. Values 6 characters or longer are redacted in run logs.
- **Required**: key names in the repo's `.env.example` / `.env.sample` / `.env.template`, at the root or one directory deep. A required key is satisfied by a provided key or a key of yours, even an empty one. Until then the deploy is refused with the list.
References: a value may contain `${NAME}` (any provided key or key of yours) or `${entry.field}` (any `[services]` entry) with field `host|port|user|password|database|url`, for example `CELERY_BROKER_URL=${REDIS_URL}`. `$${` is a literal `${`. A bare `$NAME` is never expanded. An unknown reference or a cycle is refused at save. `PORT` and `OX_RELEASE` cannot be referenced. Saving variables deploys the current production commit, because build-time variables such as `VITE_*` can change the build.
ox never injects framework defaults (`DEBUG`, `SECRET_KEY`, `ALLOWED_HOSTS`, `CORS_*`, `FRONTEND_URL`, `NODE_ENV`), never reads the repo's `.env`, and never edits a value of yours. Set framework variables as yours when the app needs them.
## Worked examples
A built SPA with no backend (Caddy serves `dist/`, the repo's own build produces it):
```toml
domains = ["www.example.com"]
[static]
dir = "dist"
spa = true
```
Next.js SSR (start, build, and the node version come from `package.json` and the lockfile):
```toml
domains = ["app.example.com"]
[app]
health = "/"
```
Python with postgres, redis, a worker, cron, a migration, and persistent uploads:
```toml
[app]
start = "uv run python app.py"
health = "/health"
[build]
migrate = "uv run python migrate.py"
[workers]
worker = "uv run python worker.py"
[cron]
tick = { schedule = "* * * * *", run = "uv run python cron.py" }
[services]
postgres = {}
redis = {}
[storage]
keep = ["uploads"]
```
Go API with postgres and redis, a migration command, and a React SPA (Rust is the same shape: `cargo build --release --locked` and start `./target/release/server`):
```toml
domains = ["api.example.com"]
[app]
start = "./server -port $PORT"
health = "/health"
[static]
dir = "dist"
spa = true
api = ["/api", "/health"]
[build]
commands = ["go build -o server ./cmd/server", "npm run build"] # npm ci is detected from package-lock.json and runs first
migrate = "go run ./cmd/migrate"
[services]
postgres = {}
redis = {}
[tools]
node = "24"
```
A bot with no public URL: a private neo4j, a private qdrant, and Hugging Face models. The worker binds its own port so its health check can call it:
```toml
[build]
install = "uv venv --python 3.12 .venv && uv pip install --python .venv -r requirements.txt"
[workers]
bot = { run = "exec .venv/bin/python bot.py", port = true, health = "/health", memory = "1G" }
[services]
neo4j = {}
qdrant = {}
[models]
huggingface = ["KanariKanaru/nsfw-image-detection-384-onnx", "navodPeiris/minilm-toxic-spam-classifier"]
[tools]
uv = "0.11"
[limits]
memory = "1500M"
```
## Common mistakes
- **Pinning `[app] port`** when the app reads `$PORT`. Leave it out: ox allocates and records one, and zero downtime needs both sides. Pin only for an app that hardcodes its port, and accept the short restart each deploy.
- **Setting a provided key yourself** (`DATABASE_URL`, `REDIS_URL`, `PORT`, ...) as a variable or in `.env.example` values. Refused at save. To use your own external database, remove the service from `ox.toml` and set the URL as yours.
- **Git submodules.** Releases come from `git archive`; submodules are refused. Vendor the code or drop the submodule.
- **Expecting framework env vars** (`DEBUG`, `SECRET_KEY`, `ALLOWED_HOSTS`, `CORS_*`, `NODE_ENV`). ox never injects them; set them as yours.
- **Toolchains in `packages`** (`nodejs`, `npm`, `python3-pip`, `golang`, `rustc`, `cargo`). Refused; declare `[tools]` instead.
- **Legacy keys.** Keys of the old engine (`runtime`, `package_manager`, `[deploy]`, `[frontend]`, `writable_paths`, and the old array-of-table blocks for processes, domains, services, models, cron jobs, and apt sources) all fail, each with the ox1 key that replaces it. ox1 spells services `[services] postgres = {}`, domains `domains = ["example.com"]`, and cron `[cron] name = { schedule = "...", run = "..." }`.
- **`{port}` placeholders** from the old engine. Commands read `$PORT` from the shell.
- **A worker with `health` but no `port = true`.** Fails: the health check calls the worker's `$PORT`.
- **Writing files next to the code at run time.** The release directory is read-only to the app (strict sandbox): a SQLite file, uploads, or logs at a relative path like `./data` fail with `EROFS`. List the folder in `[storage] keep` (a repo path like `backend/logs`): ox links it to a writable folder under `$OX_DATA_DIR` that survives releases, so the app keeps its relative path. A file at the repo root needs a folder of its own.
- **A `keep` path that is not where the app writes.** `keep` is the path from the repo root to the folder the app writes, not a label: a Django app writing `backend/logs/app.log` needs `keep = ["backend/logs"]`, and `keep = ["logs"]` makes a folder the app never sees. Check every relative path the app writes at run time (`MEDIA_ROOT`, log files, SQLite, upload dirs) against the list.
- **Repeating the root lockfile's install in `[build] commands`.** The detected install already runs first; repeating it installs twice. Leave it to detection, or move it to `[build] install` when it is not the root lockfile's.
- **`bun --cwd web run build`.** bun reads `--cwd` before `run` as its own flag, prints its help, and exits 0, so the build "passes" and builds nothing. Write `bun run --cwd web build`.
- **Cron in local time.** Schedules run UTC.
- **`static.api` without `[app]`, or `domains` with neither `[app]` nor `[static]`.** Both fail validation.
- **Absolute host paths** under `/srv`, `/var`, `/etc`, `/opt`, or `/home`. Refused; use names relative to the repo root.
## Deploying with the ox CLI (for AI agents)
The `ox` binary is also the client of ox Cloud (`https://deploywithox.com`, compiled in: no flag points it elsewhere). Every command takes `--json` (stable snake_case fields) and exits non-zero on failure, so read JSON and check the exit code: 1 a failed run or a refused request, 2 a usage error, 3 not signed in (run `ox login`), 4 the plane could not be reached. With `--json` a failure also writes `{"error": "...", "code": "<kind>"}` to stderr.
The CLI does everything the console does, so an agent never needs the browser except for `ox login` and Connect GitHub (a GitHub page: `ox repos` prints its link when nothing is connected). A project is `name` or `server/name` (or `--server`). `ox <command> -h` says more.
- `ox login` prints a code and a URL on stderr; a person approves it in the browser. The token lands in `~/.config/ox/credentials` (0600) and lasts 90 days. Never ask for or print the token.
- **Create, review, first deploy.**
- `ox servers --json` lists servers; `ox servers add` prints the command a person runs on a fresh Ubuntu LTS VPS with sudo (it works once).
- `ox repos --json` lists the repositories ox can read. `ox new owner/repo [--server S] --json` makes the project (a public https or an ssh URL works too) and prints its `name`.
- `ox review <project> --json` says `ready`, or what is missing: `ask_start` (answer with `--start 'CMD'`, a command that serves HTTP on `$PORT`), `missing` variables (each with an `example` and `can_generate`), and `issues` to fix in `ox.toml`. An ssh repository shows a `deploy_key` a person adds to GitHub, read only.
- Answer with `ox review <project> --start 'CMD' --generate SECRET_KEY --skip OPTIONAL_KEY --from-file .env.values --wait`: `--generate K` makes a random value, `--skip K` marks a key not needed, `--from-file F` (or `-` for stdin) carries the other values. It saves them and runs the first deploy. Then `ox review` again until `ready` is true, or deploy.
- **Deploy and watch.**
- `ox deploy <project> --wait --json`: the run's log streams to stderr, `{"run": {...}}` goes to stdout, and the exit code is the run's result. On a failure, read `run.detail`, then `ox logs <project> --run <id>`.
- `ox status <project> --json`: live commit and release, URL, processes, last run. `ox runs <project>` lists runs. `ox promote <project> --yes --wait` deploys production at the commit staging runs (printing the commits first): production keeps its own variables, data, and domains, and a key the new code needs must be set on production before. `ox crons <project> [--json]` lists the `[cron]` jobs with their next run and newest runs (start, end, exit code); `ox crons <project> run NAME` runs one now. `ox celery <project> <worker> [--json]` reads a Celery worker's app: workers online, running, held, and scheduled tasks, queue depths. `ox logs <project> [-n N] [--follow]` prints the processes' logs, redacted. To look back, `ox logs <project> --since 2h [--until T] [--grep TEXT | --regex RE] [--process P] [--severity warn] [-n N]` searches the server's journal, and `-o FILE` saves everything a range and search match (up to 100 MB). `ox metrics <project> [--range 1h|24h|7d|30d] [--json]` is the usage history: CPU and memory as min, average, and max, with the deploys and alerts in the range (what happened overnight).
- `ox rollback <project> [--release ID] [--restore-variables] --wait` goes back to a kept release; `--restore-variables` also brings back the variables it ran with. `ox stop <project>` and `ox start <project>` switch it off and on.
- **Variables and services.** A variable is a key, a value, and a source: `yours`, `provided` (ox's, locked), `inherited` (a copy's value still equal to production's), or `needed` (the repo's `.env.example` asks and nothing sets it). `ox vars <project> --json` lists them without values; `--reveal` shows values and is written to the audit log, so use it only when a person asks.
- Change them with the verb first: `ox vars set <project> KEY [KEY...]` reads each value from standard input (one key: the value itself; several: `KEY=VALUE` lines), `ox vars set <project> --from-file F` (or `-`) reads a `.env` file, `ox vars unset <project> KEY...`, `ox vars import <project> F --yes` merges a `.env` file. Never put `KEY=VALUE` on the command line: it is refused, because it would keep the secret in the shell history. Saving deploys; add `--no-deploy` to save only (the table says "not live" until the next deploy). A provided key is refused.
- `ox vars pull <project> -o F` writes your values to a new 0600 file (never over one without `--force`) and is audited like `--reveal`. `ox vars history <project>` lists saved versions, and `ox vars restore <project> VERSION --yes` brings one back. A staging copy or preview is a project of its own (`ox vars <project>-staging`); `ox vars sync <copy> [KEY...] --yes` takes production's values.
- When `ox deploy` or a push's deploy is refused for missing variables, `ox vars <project> --json` lists them in `needed`; set them, then deploy.
- `ox services <project> --json` gives each entry's type, mode, version, state, provided key names, and backups.
- **Domains and copies.** `ox domains <project>` lists domains with their DNS state (`waiting` means the A record is not pointing at the server yet); `ox domains <project> add shop.example.com --wait` and `remove`. `ox staging <project> --wait` makes the staging copy (`ox delete <project>-staging --confirm <project>-staging` removes it). `ox previews|autodeploy <project> [on|off]` show or set branch previews and deploy on every push. `ox notifications [--project P] [on|off EVENT...]` chooses which events (`deploy_failed`, `site_down`, `cpu_high`, ...) email the owner and reach the webhook. `ox rename <project> <new>` works only before the first deploy.
- **Backups and data.** `ox backups <project> --json` lists each entry's backups (`source` is what a restore takes); `ox backups <project> now <entry> --wait`; `ox backups <project> download <entry> <source> --output F`. `ox restore <project> <entry> --from <source> --confirm <project> --wait`, or `--file dump.sql` to upload one (up to 95 MB). `ox data <project>` lists data of removed services and `ox data <project> drop <entry> --confirm <entry>` deletes it.
- **Look into a service.** `ox explore <project> <entry> --json` gives the overview of a postgres, redis, neo4j, or qdrant entry; `tables` or `keys [PATTERN]` list what it holds, `table NAME` or `key KEY` open one item, `activity` shows sessions or slow commands. `ox explore <project> <entry> query` reads one read-only query from stdin or `--from-file` (SQL, a redis read command, Cypher, or for qdrant `search|scroll|count COLLECTION` and a JSON body). Results can be partial: `truncated` and `next` (continue with `--cursor`) say so. Changes (`allow-changes`, then `change cancel|end-session|delete-key|set-ttl ... --confirm <entry>`) alter data: only when a person asked.
- **Account.** `ox tokens` lists and `ox tokens create --name N` prints a new token once (keep it secret); `ox settings --json` shows what is set, never a secret; `ox settings s3 ... --from-file F`, `ox settings huggingface set --from-file F`, and `ox settings preview-domain D` change it. `ox servers forget <server> --confirm <server>` and `ox servers update <server>` need a server with no projects and a connected agent. `ox account export [-o FILE]` downloads everything the plane holds about the account (no secret in it); `ox account delete --confirm <github-login>` deletes the account for good and is refused while any server is left, so run it only when the owner asked.
- **Destructive commands need the name typed again.** `delete`, `restore`, `servers forget`, `webhooks remove`, `data drop`, `explore ... change`, and `account delete` refuse (exit 2) without `--confirm <name>`: the project's name, the server's, the endpoint's id, the entry's, or your GitHub login. Pass it only when a person asked for that action on that name; never to get past the refusal on your own.
- **Webhooks.** `ox webhooks` lists endpoints; `echo URL | ox webhooks add - --events deploy.failed,site.*` adds one (the signing secret is printed once); `test`, `deliveries`, `redeliver`, `rotate`, `enable`, `disable`, `remove ID --confirm ID`, `event-types`. `ox notifications` is email only.
- **Secrets never go in a flag.** The S3 secret key, the Hugging Face token, and a webhook endpoint's address and header value are read from `--from-file F` or stdin (`-`); a flag with the value is refused. Do not echo them, and do not paste a printed token or webhook secret anywhere else.
A typical loop: edit `ox.toml`, run `ox check`, push (a push deploys on its own), or run `ox deploy <project> --wait --json`, and fix what the review or the run log says.
## Install this skill
Download it from ox Cloud into the agent's skills folder:
```bash
# Grok Build (all projects)
mkdir -p ~/.grok/skills/ox-manifest && curl -fsSL https://deploywithox.com/ox-skill.md -o ~/.grok/skills/ox-manifest/SKILL.md
# Claude Code (all projects)
mkdir -p ~/.claude/skills/ox-manifest && curl -fsSL https://deploywithox.com/ox-skill.md -o ~/.claude/skills/ox-manifest/SKILL.md
```
For a single project, `.grok/skills/ox-manifest/` or `.claude/skills/ox-manifest/` under the repo root works too; commit it so every agent working in that repo loads it.