# 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. ox reads its deploy config from a file named `ox.toml` at your repo root. The file names the web process, the workers, the cron jobs, the services and the build. Most repos need no ox.toml at first, because ox detects these from your files. Write one to fix what detection got wrong. Unknown keys, wrong types and bad values are errors, and ox lists every problem at once. Secrets never go in this file. You set them on the dashboard's Variables tab (see [Variables](https://console.t12.sorv.dev/docs/operate/variables)). For a complete file for one framework, start from a guide: [Django](https://console.t12.sorv.dev/docs/guides/django), [Next.js](https://console.t12.sorv.dev/docs/guides/nextjs), [FastAPI](https://console.t12.sorv.dev/docs/guides/fastapi), [Node](https://console.t12.sorv.dev/docs/guides/node), [Laravel](https://console.t12.sorv.dev/docs/guides/laravel), [Rails](https://console.t12.sorv.dev/docs/guides/rails) or a [static site](https://console.t12.sorv.dev/docs/guides/static-site). When a deploy fails on a key, [Troubleshooting](https://console.t12.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]` on your machine before you push. The default dir is `.`. It works offline and reads only the checkout. It prints the resolved plan with the source of each value (`declared`, `detected:` or `default`). It also prints the variables to set on the dashboard, and every problem at once. Fix them until it prints `Ready to deploy.` `ox check --json` gives the same data as JSON. The deploy runs the same checks. A bad manifest or a missing variable stops the deploy before anything on your server changes, and the run shows the exact reason. ## 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. `[app]` serves them, or `[static]` when there is no app. You can also add domains to a project on the dashboard. They apply on the next deploy. - `required_variables`: keys the app cannot start without that no `.env.example` lists, such as `["JWT_SECRET"]`. ox asks for them before the first deploy, like the keys of an example file. Generate makes a value only for a secret the app makes for itself (`JWT_SECRET`, `SESSION_SECRET`, a salt). It never makes a provider's key or token. List names only, and set the values on the Variables tab. - `packages`: Ubuntu system libraries, such as `ffmpeg` or `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 too. Take it out of `packages`. When a package ships a server (`memcached`), ox installs it but keeps it off. To run it, declare the service in `[services]`. - Git submodules deploy at the commit they pin, over https only. ox rewrites GitHub ssh URLs to https. A project made from the repository picker can read a private submodule when it is a repository of the same owner, the ox GitHub App can read it, and your GitHub account can see it. To allow one, 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. Sometimes only a generic entry point proves it: a `package.json` start script with no web framework dependency, a Go main package, or a Cargo binary. Then 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, such as a bot or a queue consumer. ox adds none, and drops a detected static site too, so 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. Some servers do not read `PORT`: granian, uvicorn, hypercorn, daphne, waitress-serve and `flask run`. Give them their port option (`--port $PORT`, `--bind 127.0.0.1:$PORT`), or `ox check` refuses the start. - `health`: a path that must return HTTP 2xx or 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 picks a port and records it, so the new release can start beside the live one with no downtime. A pin (1024 to 65535, unique on the host) runs one copy, which 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` or `G`). A value larger than `[limits] memory` fails validation. - `max_upload`: the largest file a PHP app (FrankenPHP) accepts (`K`, `M` or `G`). Default: `512M`. ox sets PHP's `upload_max_filesize` to it, and `post_max_size` 8M above it to leave room for the form's other fields. ox applies these after any `php.ini` in the repo. When you leave it unset, a limit your `php.ini` sets stays, and ox sets the other one to fit. When you set it, it wins, and `ox check` says which line it replaces. Setting it on a project that is not PHP fails the plan. - `stop_before_start`: `true` for an app that cannot run two copies at once. Default: `false`. ox normally starts the new release beside the running one and switches with no gap. Some apps fail every redeploy that way: an app that holds a lock in its data folder (Gitea's queues), uses a second fixed port (Mailpit's SMTP), allows one instance per database (Pocket ID) or saves its port in its own database (Calibre-Web). 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, and the run log says so. If the new release does not come up, ox starts the old one again. If the agent is killed hard during a deploy, the site keeps serving the new release until the next deploy or a rollback records it. - `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`: a path with no file serves `index.html`, for 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 is a 404 instead of the app's page, such as `/favicon.ico` or an old `/assets/app-Abc12345.js`. A route like `/users/john.doe` still gets the page. A client route that ends in one of those extensions, such as `/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, such as a Django `collectstatic` output next to an SPA. You may leave out `dir` 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 are cached for a year. These are files under `/_next/static/`, `/_app/immutable/`, `/_nuxt/` or `/_astro/`, files with a hex hash like `main.3f2a1b9c.js`, and Vite's scripts and styles like `/assets/index-BOX4g1k6.js` or `/assets/app-DN-Banja.js`, whose hash holds a digit, `-` or `_`. Only the same site may frame static files. Every response your app or a static file sends gets `nosniff`, a referrer policy and, with a domain, HSTS. ox adds each one 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 for one gets a 404. `.well-known` is served. `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. Otherwise it gets 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. It runs 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. Your list replaces only the detected build steps, and the install still runs. `ox check` adds a hint for each detected step your list leaves out, such as 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. When it runs against a database with tables, ox takes a snapshot of the database first. 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, such as 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 runs as its own systemd unit, except a public worker the app serves (below). A worker is a command string (the 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 then publishes a `_URL` variable and starts the worker before the app switches. The name is upper-cased, and `-` becomes `_`. 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`): 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. This holds on every domain and the preview link, and the path stays as the browser asked for it. ox never strips the prefix. A prefix starts with `/`, is not `/` itself, has no trailing `/`, and belongs to one worker. The longest prefix wins. ox refuses a `[static]` `paths` or `api` prefix under a worker's prefix, and a worker's prefix at or under a folder the app's `.htaccess` closes. A worker has no second copy the way the app does. Every deploy restarts it in place, even one that changes only the app. Its paths answer 502 for those seconds, and every websocket open on them drops, so its clients must reconnect. - `public = true` (default false, needs `port = true` or no `run`): gives the worker a web address of its own. Use it 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, `--.