# 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://console.t74.sorv.dev/docs/operate/variables)). For a complete file for one framework, start from a guide: [Django](https://console.t74.sorv.dev/docs/guides/django), [Next.js](https://console.t74.sorv.dev/docs/guides/nextjs), [FastAPI](https://console.t74.sorv.dev/docs/guides/fastapi), [Node](https://console.t74.sorv.dev/docs/guides/node), [Laravel](https://console.t74.sorv.dev/docs/guides/laravel), [Rails](https://console.t74.sorv.dev/docs/guides/rails) or a [static site](https://console.t74.sorv.dev/docs/guides/static-site). When a deploy fails on a key, [Troubleshooting](https://console.t74.sorv.dev/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 enabled = false # no web process and no detected static site (workers only); default: the detected app start = "serve" # default: detected health = "/healthz" # HTTP 2xx/3xx on this path; default: any answer to GET / but 502-504 port = 9034 # pin; default: allocated and recorded memory = "512M" # this process's cap sandbox = "relaxed" # the only value; default is strict max_upload = "2G" # a PHP app's largest upload; default: 512M stop_before_start = true # an app that cannot run two copies at once: stop the running copy first (a short gap per deploy); default: false [static] # files served by Caddy dir = "dist" # relative to the repo root spa = true # a path with no file or page serves 200.html, else index.html; a missing file (.js, .ico, ...) is a 404 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 --include=dev" # 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, clickhouse, meilisearch, rabbitmq, 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. - `required_variables`: keys the app cannot start without that no `.env.example` lists (`["JWT_SECRET"]`). They are asked before the first deploy like an example file's keys, and Generate makes one only for a secret the app makes for itself (`JWT_SECRET`, `SESSION_SECRET`, a salt), never for a provider's key or token. Names only: set the values on the Variables tab. - `packages`: Ubuntu system libraries (`ffmpeg`, `libmagic1t64`). Toolchains (`nodejs`, `npm`, `python3-pip`, `golang*`, `rustc`, `cargo`) are refused; declare them in `[tools]`. A package that rewrites sign-in, boot or the firewall (`openssh-server`, `ufw`, `sudo`, `libpam-*` and a few more) is refused: take it out of `packages`. A server a package ships (`memcached`) is installed but kept off; declare the service in `[services]` to run it. This applies on servers whose agent is updated. - Git submodules deploy at the commit they pin, over https only (GitHub ssh URLs are rewritten). A project made from the repository picker also reads a private submodule that is a repository of the same owner that the ox GitHub App can read and your GitHub account can see: add it to the App's installation. Another owner's private repository cannot be a submodule. A project cloned from a plain URL or with a deploy key fetches public submodules only. `ox.toml` and detection read only the main repo. ## [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. An `ox.toml` with neither `[app]` nor `[static]` still gets the app ox detects, so a file that only adds workers keeps the web app when a web framework (or a static site) proves it. When only a generic entry point does (a `package.json` start script with no web framework dependency, a Go main package, a Cargo binary), an `ox.toml` with workers or cron jobs must choose: `ox check` stops until it sets `[app] start` or `[app] enabled = false`. - `enabled`: `false` says the project has no web process, so ox adds none (a bot or a queue consumer), and it drops a detected static site too: the project serves nothing. No other `[app]` key goes with it. - `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. A server that does not read `PORT` (granian, uvicorn, hypercorn, daphne, waitress-serve, `flask run`) needs its port option (`--port $PORT`, `--bind 127.0.0.1:$PORT`), or `ox check` refuses the start. - `health`: a path that must return HTTP 2xx/3xx. Default: the app must answer `GET /` within 5 seconds with any status but 502, 503 or 504 (a 404 counts); set a path if `/` is slower. - `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. - `max_upload`: the largest file a PHP app (FrankenPHP) accepts (`K`/`M`/`G`). Default: `512M`. ox sets PHP's `upload_max_filesize` to it and `post_max_size` 8M above it (room for the form's other fields), after any `php.ini` in the repo. Unset, a limit a repo `php.ini` sets stays the repo's and ox writes the other to fit; set, it wins, and `ox check` says which line it replaces. Any other project that sets it fails the plan. - `stop_before_start`: `true` for an app that cannot run two copies at once. ox normally starts the new release beside the running one and switches with no gap, so an app that holds a lock in its data folder (Gitea's queues), a second fixed port (Mailpit's SMTP), one instance per database (Pocket ID) or a port saved in its own database (Calibre-Web) fails every redeploy. With `true`, a deploy stops the running copy and then starts the new release: the site answers 502 from the stop until the new release answers, on every deploy (the run log says so). If the new release does not come up, the running release is started again. If the agent is killed hard in the middle of a deploy, the site keeps serving the new release until the next deploy or a rollback records it. Default: `false`. Needs a current agent (`ox servers update`). - `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`: paths with no file serve `index.html` (single-page apps), or `200.html` when the build writes one. A prerendered page in a folder, like `story/index.html`, answers at `/story`. A missing path that ends in a file extension (`/favicon.ico`, an old `/assets/app-Abc12345.js`) is a 404, not the app's page; a route like `/users/john.doe` still gets the page, but a client route that ends in one of those extensions (`/files/report.pdf`) is a 404 too. - `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. ox sets the response headers. `index.html`, the SPA fallback and hand-named files get `Cache-Control: no-cache`, so a release or a rollback shows at once. Content-hashed files (under `/_next/static/`, `/_app/immutable/`, `/_nuxt/` or `/_astro/`, a hex hash like `main.3f2a1b9c.js`, Vite's scripts and styles like `/assets/index-BOX4g1k6.js` or `/assets/app-DN-Banja.js`, whose hash holds a digit, `-` or `_`) are cached for a year. Static files may be framed only by the same site. Every response your app or a static file sends gets `nosniff`, a referrer policy and, with a domain, HSTS, each only when the response has none. ox sets no Content-Security-Policy and no caching on your app's responses; a static site sets its policy with a `` tag in `index.html`. A static folder never serves `.env*`, `.git*`, `.ht*`, `.ssh`, `.svn`, `.hg`, `.npmrc`, `.netrc`, `.aws`, `.docker` or `.pgpass` (a request is a 404; `.well-known` is served), and `ox check` names any `.env` it finds in the folder, at any depth. A missing file gets the `404.html` at the top of your static folder, with status 404, when the build writes one, else an empty 404. A 404 your app sends passes through as it is. In a single-page app an unknown route still gets the app's page with status 200: ox can't know your routes, so show a not-found view from your router. ## [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. `install = ""` runs no install. - `commands`: run after `install`, in order. Default: detected from the package scripts. Declaring them replaces only the detected build steps; the install still runs, and `ox check` adds a hint for each detected step the list leaves out (Django's `collectstatic`), so list it again when the release needs it. `commands = []` runs no build: use it for a folder served as it is, in a repo whose `package.json` builds something else. - `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). `migrate = ""` runs none. - `skip_submodules`: submodule paths a deploy does not fetch, like `["apps/server/src/ee"]`; each folder stays empty, as after a plain `git clone`. Use it for a private module the app builds without (an enterprise edition only its vendor can read). Default: every submodule is fetched. ## [workers] Every worker runs as its own process next to the app. Each gets a port of its own in `$PORT`. With `port = true` the app reaches a worker at `$BOT_URL`, and its health check calls that port. Background processes, each its own systemd unit, except a public worker the app serves (below). A worker is a command string (short form) or a table: `{ run, memory, sandbox, port, health, stop_timeout, paths, public, domains }`. - Names match `^[a-z][a-z0-9-]{0,27}$`; `app` is reserved. - Every worker and cron job gets a port of its own in `$PORT`. `port = true` means the project's other processes call this worker: ox publishes a `_URL` variable (name upper-cased, `-` becomes `_`) and starts it before the app switches. This is how processes of one project call each other. - `health` is checked on the worker's own `$PORT`. - `paths` (default none, needs `port = true`) are parts of the site the worker serves instead of the app, like `paths = ["/api/v1/streaming"]` for Mastodon's streaming server or `["/cable"]` for AnyCable. Each prefix and everything under it goes to the worker's `$PORT`, websockets included, on every domain and the preview link, with the path kept as the browser asked for it. A prefix starts with `/`, is not `/` itself, has no trailing `/`, and belongs to one worker; the longest prefix wins, and a `[static]` `paths` or `api` prefix under a worker's prefix is refused, as is a worker's prefix at or under a folder the app's `.htaccess` closes. Unlike the app, the worker has no second side: every deploy, even one that changes only the app, restarts it in place, so its paths answer 502 for those seconds and every websocket open on them drops (its clients must reconnect). The prefix is never stripped. - `public = true` (default false, needs `port = true`, or no `run`) gives the worker a web address of its own, for an app that serves a second site on a name of its own, like Logto's Admin Console at `ADMIN_ENDPOINT`. Every request for the worker's names goes to its `$PORT`, every path and websockets included, with the site's password and headers. Its names are its preview link, `--.