# 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. 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](https://deploywithox.com/docs/operate/variables)). For a complete file for one framework, start from a guide: [Django](https://deploywithox.com/docs/guides/django), [Next.js](https://deploywithox.com/docs/guides/nextjs), [FastAPI](https://deploywithox.com/docs/guides/fastapi), [Node](https://deploywithox.com/docs/guides/node), [Laravel](https://deploywithox.com/docs/guides/laravel), [Rails](https://deploywithox.com/docs/guides/rails) or a [static site](https://deploywithox.com/docs/guides/static-site). When a deploy fails on a key, [Troubleshooting](https://deploywithox.com/docs/troubleshooting) explains the message. ox.toml: the whole file at a glance: ```toml 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:`, `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 `_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 ` = { ... }`, 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: ```toml [services.search] run = "meilisearch --db-path $SERVICE_DATA_DIR --http-addr 127.0.0.1:$PORT --master-key $SERVICE_PASSWORD" tool = "github:meilisearch/meilisearch@1.12.0" # 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 `_URL = http://127.0.0.1:`. - 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](https://deploywithox.com/app/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`, `_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 --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: ```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): Next.js SSR: ```toml 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: ```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`): Go API with a React SPA: ```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"] 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: ```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"] [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](https://deploywithox.com/ox-skill.md) as a plain file. Last updated 2026-10-08. The page as HTML: https://deploywithox.com/docs/config