Docs menuConfig reference

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.

ox.toml: the whole file at a glance
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

Top-level keys sit above every section. Domains are the names Caddy serves, and packages are Ubuntu libraries; a toolchain there is refused.
  • 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 archive of the commit.

[app]

A request for one of your domains reaches Caddy, which passes it to the app on its port. The app takes traffic only once its health path answers.

The one web process, behind Caddy.

  • start: the command that serves HTTP on $PORT. Default: detected from the repo. Commands run with bash -euo pipefail -c in the release directory, with the tools on PATH; $PORT and 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] memory fails validation.
  • sandbox: the only value is "relaxed"; the default is strict.

[static]

Caddy serves files from the built folders. A path with no file gets 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 serve index.html.
  • api: URL prefixes that stay proxied to [app]. Requires [app].
  • paths: more built directories, each served at its URL path (a Django collectstatic output next to an SPA). dir may be omitted when only paths is set.

[build]

Each deploy builds a new release beside the live one: install, your build commands, then the migration. Traffic switches only after the new release passes its health check.
  • 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 after install, 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]

Every worker runs as its own process next to the app. With 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}$; app is reserved.
  • port = true allocates a port, exposed as that worker's $PORT and as a <NAME>_URL variable (name upper-cased, - becomes _). This is how processes of one project call each other.
  • health needs port = true: the health check calls the worker's $PORT.

[cron]

Each entry becomes a timer on your server that runs its command on schedule, in UTC.
  • name = { schedule = "...", run = "..." }, rendered as a systemd timer.
  • schedule is a strict 5-field cron expression or @hourly/@daily/@weekly/@monthly, in UTC.

[services]

Declare a service and ox installs it on your server, then hands its connection string to your app on every deploy.

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. Its type defaults to the name, so postgres = {} is a postgres and analytics = { 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. Provides DATABASE_URL. Extensions, like vector, come from the service: postgres = { extensions = ["vector"] }.
  • redis: private by default (shared allowed, one database index per project). Version 8. Provides REDIS_URL.
  • neo4j: private by default (shared allowed). Version 5. Provides NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD.
  • qdrant: private only. Version 1. Provides QDRANT_URL.
  • mysql: private only, served by MariaDB 11.8 (MySQL-compatible). Provides MYSQL_URL, with the user app and the database app.
  • only_for_this_project = true runs 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 the port pin); false shares the host's instance. port on a shared service fails validation.
  • version pins 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.toml and set the URL in Variables.

Custom services

An entry with run is a service your repo defines itself, always private:

A custom service, defined by the repo
[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, daily
  • run is required. It runs with bash -euo pipefail -c in its data dir, as the project user, in the strict sandbox, with $PORT, $SERVICE_DATA_DIR, $SERVICE_PASSWORD, and the tools on PATH. It gets none of the app's variables.
  • provides values 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, and only_for_this_project = false are refused on it.
  • A Meilisearch, Typesense, Elasticsearch or OpenSearch, Redis, Valkey, ClickHouse, Chroma, or Memcached gets an Explore page, known by its tool, packages, or run; explore = "meilisearch" names it when none of those does. Explore sends $SERVICE_PASSWORD only when run or provides use it.

[tools], [storage], [limits], [models]

Pinned tools are on the PATH of your build and every process. The exact versions are recorded on the first deploy, so they never drift.
  • [tools]: a mise tool name = version (node = "24", uv = "0.11", or a download-only backend like aqua:, 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 from mise.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]: memory caps the project's whole slice (processes and builds); cpu is 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 gains HF_HOME. Gated models need the Hugging Face token from Settings.

Detection

Detection reads your repo's files to fill the plan. A value you declare in ox.toml always wins over a detected one.

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.json engines, 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.py fills Django migrate, collectstatic, and a gunicorn/uvicorn start. go.mod fills go build and the start binary. Cargo.toml fills cargo build --release. A Vite build with no start script fills [static] dist with spa = 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 an ox.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

Values live in the dashboard, not in ox.toml. Your processes get them as environment variables, and run logs hide them.

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>_URL for each port = true worker, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, the service keys above, and HF_HOME with [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 field host, port, user, password, database, or url, like CELERY_BROKER_URL=${REDIS_URL}. $${ is a literal ${; a bare $NAME is never expanded. Unknown references and cycles are refused at save. PORT and OX_RELEASE cannot 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

Promote deploys the exact commit staging runs to production. Production keeps its own variables and data, and the old release serves until the new one is healthy.

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.

  1. 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).
  2. Confirm. Your confirmation names the commit you saw. If staging moved meanwhile, ox refuses and asks you to look again.
  3. Deploy. Your server fetches that commit from your repository and builds it, and runs its migrations, with production's variables against production's databases.
  4. 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.
  5. 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):

A built SPA with no backend
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):

Next.js SSR
domains = ["app.example.com"]

[app]
health = "/"

Python with postgres, redis, a worker, cron, a migration, and persistent uploads:

Python with services, a worker, and cron
[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):

Go API with a React SPA
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:

A bot with no public URL
[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] port when 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 $PORT from the shell.
  • A worker with health but no port = true. Fails: the health check calls the worker's $PORT.
  • 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 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.