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

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
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:<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.
  • 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 <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 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 <NAME>_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, <worker>--<project>.<label>.preview.getox.in, served from the server's free preview certificate like the app's, and the domains it lists (default none, needs public = true), on HTTPS like the app's. ox publishes the address as <NAME>_PUBLIC_URL: https:// and the first domain, 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 be broken or another's. A name goes to one process; staging copies and previews drop the worker's domains, as they do the app's. When the app's own process serves both sites, picking one by the request's host (Logto does), 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: two copies of one release race over its files at every boot.
  • stop_timeout (default "90s", from "1s" to "1h") is how long a stop waits for the work the worker is running. A deploy restarts each worker in place, and a stop sends SIGTERM to the worker's main process only, so Celery or RQ finishes its running tasks; after the timeout everything it started is killed. Keep the command 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> does the same, 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 = "..." }, rendered as a systemd timer.
  • schedule is a strict 5-field cron expression or @hourly/@daily/@weekly/@monthly, in UTC.
  • With tested deploys on, a check can run a job on a preview and on staging and assert on its 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.

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 database 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: 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 (MySQL-compatible). Provides MYSQL_URL, with the user app and the database app.
  • clickhouse: private only, ClickHouse 26.8 LTS, new and not yet through the droplet gate (arm64 untested). Provides CLICKHOUSE_URL for its HTTP interface, with the user app and the database default; the native protocol is not offered.
  • meilisearch: private only, Meilisearch 1.54 in production mode, new and not yet through the droplet gate (arm64 untested). Provides MEILI_URL and MEILI_MASTER_KEY, the generated master key.
  • rabbitmq: private only, RabbitMQ 4.0 from Ubuntu, new and not yet through the droplet gate (arm64 untested). Provides RABBITMQ_URL, with its own user and vhost, both app, and no guest user. A backup holds its queues, exchanges and bindings, not the messages waiting in them.
  • 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, which only postgres has (it is refused on every other type). 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 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, and a private Redis or MySQL is archived while it runs; any other 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      = "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, with $PORT, $SERVICE_DATA_DIR, $SERVICE_PASSWORD, and the tools on PATH. It gets 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. Root and the server's login users can read a process's arguments, so a deploy that finds the password in one of the service's fails: a new service is 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 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, Memcached, NATS, Prometheus, or VictoriaMetrics 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 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 = 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. A build step may use up to three quarters of the server's memory, or memory when lower, and 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). An install or build command that runs past 3 hours is stopped (a migrate is not).
  • [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 out. It never overrides a declared value.

  • Version files (mise.toml, .tool-versions, .nvmrc, .python-version, package.json engines and devEngines, go.mod, rust-toolchain.toml) fill [tools], and a one-tool file one folder down (consumer/.python-version) pins a tool the root does not when that folder holds the tool's manifest (package.json for node and bun, pyproject.toml or requirements.txt for python); 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.js Postgres drivers imply Postgres; redis, ioredis, bullmq, celery[redis] imply Redis; Go's direct go.mod requirements (pgx, lib/pq, go-sql-driver/mysql, go-redis), Rust's Cargo.toml dependencies (sqlx or diesel by their postgres or mysql feature, tokio-postgres, the redis crate) and Symfony's Doctrine config imply theirs. With an ox.toml, [services] is exactly what is declared; an implied but undeclared service shows as a hint on the review screen naming the key it would provide. That key must be set before deploying only on a project's first deploy 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.
  • 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 (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]. Locked in the UI. Saving your own value under a provided key is refused; remove the service instead to use your own.
  • During the build (install, commands and migrate) every variable is already set except PORT: 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 it 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: a value of yours replaces it. See the variables guide for the names and the rules.
  • 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, 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 preview, a key still equal to production's reads same as production (inherited in --json), whoever set it, and one that differs reads overridden. 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. Only the keys you must answer are needed: an empty example, 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), or 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 is not asked. 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. 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 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, 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, 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/, 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.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 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.
  • A submodule ox cannot fetch: another protocol than https, a private repository the ox GitHub App cannot read (add it to the App's installation, or make it public), or a pinned commit that was never pushed. 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. 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 an error that says what to do instead: the key that replaces it, or that ox now reads the value elsewhere and the key can go.
  • {port} placeholders. Commands read $PORT from the shell.
  • 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. 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. It first asks you to install the ox CLI and sign in, so the agent can check the file and deploy for you. The same text is at /ox-skill.md as a plain file.