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

On this page

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).

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
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:<file> 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 <meta http-equiv="Content-Security-Policy"> 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 <NAME>_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, <worker>--<project>.<label>.preview.getox.in, served from the server's free preview certificate like the app's. They also include the domains it lists (default none, needs public = true), on HTTPS like the app's.
    • ox publishes the address as <NAME>_PUBLIC_URL. It is https:// and the first domain, or else the preview link on the scheme it is served with. So ADMIN_ENDPOINT = "${ADMIN_PUBLIC_URL}" is the whole setup.
    • A public worker's name is longer than 2 characters, and no project or worker name holds --. So its preview name can never break or clash with another's. A name goes to one process. Staging copies and previews drop the worker's domains, as they do the app's.
    • Sometimes the app's own process serves both sites and picks one by the request's host, as Logto does. Then give the worker no run and no port. admin = { public = true } sends its names to the app's process with the host kept, behind the site's closed folders, and nothing else runs for it. Don't run the app's start a second time as a worker, because two copies of one release race over its files at every boot.
  • stop_timeout (default "90s", from "1s" to "1h"): how long a stop waits for the work the worker is running. A deploy restarts each worker in place. A stop sends SIGTERM to the worker's main process only, so Celery or RQ finishes its running tasks. After the timeout, ox kills everything it started.
    • Keep the command as its main process. A pipe, a redirect, a loop or a & (| tee, 2>&1) keeps bash in front, so the stop ends bash and the worker is killed at once. ox refuses such a run and names the fix: drop the redirect or the pipe, or start the last command with exec.
    • A script whose last line is not exec <command> has the same problem, and the server's checks warn about it.
    • A launcher in front of the worker must pass SIGTERM on and wait for it, as uv run and npm run do. For one that does not, start the worker itself or exec it.

[cron]

Each entry becomes a timer on your server that runs its command on schedule, in UTC.
  • name = { schedule = "...", run = "..." }. ox turns each one into a systemd timer.
  • schedule is a strict 5-field cron expression, or @hourly, @daily, @weekly or @monthly. It runs in UTC.
  • With tested deploys on, a check can run a job on a preview and on staging. It can assert on the job's exit status and on what it changed.

[services]

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

Eight 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 database, and analytics = { type = "postgres" } is a second Postgres database for the same project. When an entry's name differs from its type, ox puts the name in front of its keys: ANALYTICS_DATABASE_URL.
  • postgres: shared by default, with one database and role per project. Version 18. Provides DATABASE_URL. Extensions such as vector go on the service: postgres = { extensions = ["vector"] }.
  • redis: always private, one instance per project. Version 8. Provides REDIS_URL.
  • neo4j: always private, one instance per project (about 512 MB each). 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, which is MySQL-compatible. Provides MYSQL_URL, with the user app and the database app.
  • clickhouse: private only, ClickHouse 26.8 LTS. Provides CLICKHOUSE_URL for its HTTP interface, with the user app and the database default. The native protocol is not offered. It is not yet tested on arm64.
  • meilisearch: private only, Meilisearch 1.54 in production mode. Provides MEILI_URL and MEILI_MASTER_KEY, the generated master key. It is not yet tested on arm64.
  • rabbitmq: private only, RabbitMQ 4.0 from Ubuntu. Provides RABBITMQ_URL. Its user and vhost are both app, and there is no guest user. A backup holds its queues, exchanges and bindings, but not the messages waiting in them. It is not yet tested on arm64.
  • only_for_this_project = true runs a private instance. It has its own unit, its own data under the project's data dir, its own password and its own port (picked by ox, or the port pin). false shares the host's instance. Only postgres can be shared, and ox refuses false on every other type. port on a shared service fails validation.
  • version pins are enforced. A version ox cannot install on this OS fails, and the error lists the versions it can install.
  • ox backs up every entry with data daily after 03:00 UTC, and on demand with Back up now on the Services tab. It keeps 7 on the server, or 2 once they are in the S3 bucket you set in Settings, and 30 in the bucket. Postgres is dumped while it runs. A private Redis or MySQL is archived while it runs. Any other private instance stops for the moment ox archives its data dir. A restore takes a kept backup, an object in the bucket, or a file you upload.
  • Removing a service never drops its data. 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. It is always private:

A custom service, defined by the repo
[services.search]
run      = "MEILI_MASTER_KEY=$SERVICE_PASSWORD meilisearch --db-path $SERVICE_DATA_DIR --http-addr 127.0.0.1:$PORT"
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. It gets $PORT, $SERVICE_DATA_DIR, $SERVICE_PASSWORD and the tools on PATH, and none of the app's variables.
  • Pass the password in the service's environment (as MEILI_MASTER_KEY above), on its stdin, or in a config file that names $SERVICE_PASSWORD. Never pass it as an argument. Root and the server's login users can read a process's arguments, so a deploy that finds the password in the service's arguments fails. A new service is then stopped, a changed one goes back to its earlier definition, and an unchanged one keeps running.
  • provides values are templates over ${host}, ${port} and ${password}. Keys are upper snake case and may not collide with another provided key. Without provides, the entry provides <NAME>_URL = http://127.0.0.1:<port>.
  • On every deploy it starts and passes health before the workers and the app. It restarts only when run, tool, packages, memory or its port changes.
  • ox refuses type, version, extensions and only_for_this_project = false on it.
  • Some services get an Explore page: Meilisearch, Typesense, Elasticsearch or OpenSearch, Redis, Valkey, ClickHouse, Chroma, Memcached, NATS, Prometheus and VictoriaMetrics. ox knows them by their tool, packages or run. When none of those names it, set explore = "meilisearch". Explore sends $SERVICE_PASSWORD only when run or provides uses it, and never to Prometheus or VictoriaMetrics. The 50 most common services lists what each one gets.

[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 and its version, such as node = "24" or uv = "0.11". A download-only backend like aqua:, github: or ubi: works too; write such a tool's release at its full version, every number of it, or "latest", since mise resolves a short version like "1" only among its newest 100 releases. ox check notes one you wrote, here or in a service's tool, and a failed install names the fix. Default: read from mise.toml, .nvmrc, lockfiles, go.mod and similar files, or ox's default table. The first deploy records the versions it resolved, and a new ox release never changes them on its own.
    • ox refuses backends that run a package's own code while installing (npm:, cargo:, pipx:, gem:, go:, asdf:, vfox:), because every project's tools share one tree. Install those in your build instead.
  • [storage] keep: folders in the repo the app writes to, like ["media", "backend/logs"]. ox links each one to the project data dir (OX_DATA_DIR), so it is writable and kept across releases.
  • [limits]: memory caps the project's whole slice, its processes and builds. cpu is a number of cores. A build step may use up to three quarters of the server's memory, or memory when that is lower. One build or migrate step runs at a time on a server. Another project's step waits, and its run log names the build it waits for (waiting for the build of shop (run ...) to finish). ox stops an install or build command that runs past 3 hours. A migrate has no such limit.
  • [models] huggingface: model repos pulled into the project's cache on every deploy, as "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 out. A value you declare always wins.

  • Version files fill [tools]: mise.toml, .tool-versions, .nvmrc, .python-version, package.json engines and devEngines, go.mod and rust-toolchain.toml. A detected language with no version file gets ox's default table. A one-tool file one folder down (consumer/.python-version) also pins a tool the root does not, when that folder holds the tool's manifest: package.json for node and bun, or pyproject.toml or requirements.txt for python.
  • Lockfiles fill the package manager and the install, build and start commands: bun.lock, pnpm-lock.yaml, yarn.lock, package-lock.json, uv.lock, poetry.lock and requirements.txt.
  • manage.py fills Django's migrate, collectstatic and a gunicorn or 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.js Postgres drivers imply Postgres.
    • redis, ioredis, bullmq and celery[redis] imply Redis.
    • Go's direct go.mod requirements (pgx, lib/pq, go-sql-driver/mysql, go-redis) imply theirs.
    • Rust's Cargo.toml dependencies (sqlx or diesel by their postgres or mysql feature, tokio-postgres, the redis crate) imply theirs.
    • Symfony's Doctrine config implies its database.
    With an ox.toml, [services] is exactly what you declare. A service the code implies but you did not declare shows as a hint on the review screen, naming the key it would provide. That key blocks a deploy only on a project's first deploy, and only when the framework reads it by default (Rails' DATABASE_URL). A client library alone, or a project that already has a release, stays a hint.
  • For a repo with nothing to run and no start command ox can detect, the review screen asks for one 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 has a key, a value and a source. You manage them on the dashboard's Variables tab or with ox vars. None of them live in ox.toml.

  • Provided by ox, on every deploy: PORT (its own for the app, each worker, each cron job and ox exec), 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]. They are locked on the dashboard. ox refuses to save your own value under a provided key. To use your own, remove the service instead.
  • During the build (install, commands and migrate) every variable is already set except PORT. That covers your variables, the service keys, PUBLIC_URL and PUBLIC_HOST (the address the release will serve), and OX_RELEASE, the commit being built. PORT exists only in a running process.
  • From a service, by the usual names: a key such as DB_HOST, POSTGRES_PASSWORD or REDIS_PORT that your .env.example lists. When the project has one service of the kind the key belongs to, ox sets it from the service and never asks for it. The review and ox check show it as from postgres. It is not locked, so a value of yours replaces it. See the variables guide for the names and the rules.
  • Yours: API keys and feature config that you type in. Values stay hidden until you reveal one. Run logs redact values of 6 characters or more, except a path, an address, or a common setting word like production. 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 a preview, a key still equal to production's reads same as production (inherited in --json), whoever set it. One that differs reads overridden. Sync from production takes production's values.
  • Needed: key names in the repo's .env.example (or .env.sample or .env.template), at the root or one folder deep. ox refuses the deploy with the list, and the project page shows fields for them, until each one is set. A provided key or a key of yours satisfies one, even when empty. Only the keys you must answer are needed:
    • an empty example value
    • a secret or login (SECRET_KEY, STRIPE_API_KEY)
    • a placeholder (changeme, your-api-key)
    • an address of your own machine or network (localhost, a private network address)
    • a URL with a login in it (amqp://guest:guest@..., ?token=...)
    When a name's last word is not itself a secret word (OAUTH_TOKEN_URL, ACCESS_TOKEN_EXPIRE_MINUTES), the value decides. A number, a boolean, a duration like 30d, or an address is a setting, and ox does not ask for it. A key with a real example value (DEBUG=False, WORKERS=4) keeps the app's own default until you set it, and ox check names those keys.
  • A value may reference another: ${NAME} for any variable, or ${service.field} with the field host, port, user, password, database or url, like CELERY_BROKER_URL=${REDIS_URL}. $${ is a literal ${, and a bare $NAME is never expanded. ox refuses unknown references and cycles when you save. PORT and OX_RELEASE cannot be referenced.
  • ox never injects framework defaults (DEBUG, SECRET_KEY, ALLOWED_HOSTS, CORS_*, NODE_ENV). It never reads the repo's .env and never edits a value of yours. Set framework variables yourself when the app needs them.
  • ox keeps every save as a version on your server, never on ox's. History lists the last 20 and restores one through the same diff. A rollback can also restore the variables its release ran with.

Staging and 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. It lists the commits production does not have yet, newest first, up to 100, and 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, 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, ox pauses production's auto-deploy, so the next push does not replace it. The review says so, and Settings resumes it.

Promote never moves these:

  • Variables. Production keeps its own, so 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, press 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/, which the repo's own build produces:

A built SPA with no backend
domains = ["www.example.com"]

[static]
dir = "dist"
spa = true

Next.js SSR. The start, the build and the Node.js 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 has the same shape, with cargo build --release --locked and the 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, with 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, because a pin costs a short restart per deploy.
  • Setting a provided key (DATABASE_URL, REDIS_URL, PORT) as your own. ox refuses it when you save. To use an external database, remove the service and set the URL as yours.
  • A submodule ox cannot fetch. That is one on a protocol other than https, a private repository the ox GitHub App cannot read, or a pinned commit that was never pushed. For a private one, add it to the App's installation, or make it public. The error names the path and the fix.
  • Expecting framework env vars (DEBUG, SECRET_KEY, ALLOWED_HOSTS, CORS_*, NODE_ENV). ox never injects them.
  • Toolchains in packages. ox refuses them, so 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 an error that says what to do instead. It names the key that replaces it, or says ox now reads the value elsewhere and the key can go.
  • {port} placeholders. Commands read $PORT from the shell instead.
  • Cron in local time. Schedules run in 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. ox refuses them, so use paths relative to the repo root.

Agent instructions

The Copy skill for AI agent button at the top copies a one-line task and the bundled ox.toml skill for your coding agent. The skill is the full guide to writing the file: schema, detection, services, variables, examples and mistakes. It first asks you to install the ox CLI and sign in, so the agent can check the file and deploy for you. The skill alone is at /ox-skill.md as a plain file.