ox Docs
Log in

Config reference

The deploy config is a file named ox.toml at your repo root. It names the web process, workers, cron jobs, services, and build. Every key is optional: detection fills what you leave out from the repo's own files, and never overrides a declared value. 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.

domains  = ["example.com"]   # served by [app], or by [static] when there is no [app]
packages = ["ffmpeg"]          # apt system libraries only (no toolchains)

[app]                          # the one web process; gets $PORT
start   = "serve"              # default: detected
health  = "/healthz"           # HTTP 2xx/3xx on this path; default: TCP connect
port    = 9034                 # pin; default: allocated and recorded
memory  = "512M"               # this process's cap
sandbox = "relaxed"            # the only value; default is strict

[static]                       # files served by Caddy
dir = "dist"                   # relative to the repo root
spa = true                     # unknown paths serve index.html
api = ["/api", "/admin"]       # these prefixes go to [app]; requires [app]
paths = { "/static" = "backend/static" }  # more built dirs, each at its URL path

[build]                        # runs as the project user, variables available
commands = ["make build"]      # 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 only
postgres = { version = "18", extensions = ["vector"] }
redis    = {}
qdrant   = { only_for_this_project = true, port = 9200 }

[tools]                        # any mise tool name = version; default: from repo files
node = "24"
bun  = "1.3"

[storage]
keep = ["media"]               # names under the project 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

  • domains: the names Caddy serves, served by [app], or by [static] when there is no app. Domains can also be added per project in the dashboard and apply on the next deploy.
  • packages: Ubuntu system libraries (ffmpeg, libmagic1). Toolchains (nodejs, npm, python3-pip, golang*, rustc, cargo) are refused; declare them in [tools].
  • Git submodules are refused: releases are built from git archive of the commit.

[app]

The one web process, behind Caddy.

  • start: the command that serves HTTP on $PORT. Default: detected from the repo. Commands run with bash -euo pipefail -c in the release directory, with the tools on PATH; $PORT and other variables expand in the shell.
  • health: a path that must return HTTP 2xx/3xx. Default: a TCP connect on the port.
  • port: leave it out. ox allocates and records a port, and the zero-downtime switch runs two sides at once. A pin (1024-65535, unique on the host) runs one side and restarts in place, with a moment of downtime per deploy. Pin only for an app that hardcodes its port.
  • memory: this process's cap (K/M/G). A value larger than [limits] memory fails validation.
  • sandbox: the only value is "relaxed"; the default is strict.

[static]

  • dir: the built files Caddy serves, relative to the repo root. It must exist after the build. Without [app], the project is a static site.
  • spa: unknown paths serve index.html.
  • api: URL prefixes that stay proxied to [app]. Requires [app].
  • paths: more built directories, each served at its URL path (a Django collectstatic output next to an SPA). dir may be omitted when only paths is set.

[build]

  • commands: run as the project user, in order, in the new release. Default: detected from the lockfile and package scripts.
  • migrate: runs after the build, before traffic switches. A snapshot of the database is taken first when it runs against a database with tables. Default: detected (Django, Prisma).

[workers]

Background processes, each its own systemd unit. A worker is a command string (short form) or a table: { run, memory, sandbox, port, health }.

  • Names match ^[a-z][a-z0-9-]{0,27}$; app is reserved.
  • port = true allocates a port, exposed as that worker's $PORT and as a <NAME>_URL variable (name upper-cased, - becomes _). This is how processes of one project call each other.
  • health needs port = true: the health check calls the worker's $PORT.

[cron]

  • name = { schedule = "...", run = "..." }, rendered as a systemd timer.
  • schedule is a strict 5-field cron expression or @hourly/@daily/@weekly/@monthly, in UTC.

[services]

Four services ship. Declaring one is all you do: ox installs it, creates the database or instance, and writes the connection keys on every deploy.

  • postgres: shared, one database and role per project. Version 18. Provides DATABASE_URL. Extensions, like vector, come from the service: postgres = { extensions = ["vector"] }.
  • redis: shared, one database index per project. Version 8. Provides REDIS_URL.
  • neo4j: shared (private allowed). Version 5. Provides NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD.
  • qdrant: private only. Version 1. Provides QDRANT_URL.
  • only_for_this_project = true runs a private instance with its own unit, its own data under the project's data dir, and its own port (allocated, or the port pin). port on a shared service fails validation.
  • version pins are enforced: a version ox cannot install on this OS fails with the available list.
  • Removing a service never drops data by itself. The service console 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.

[tools], [storage], [limits], [models]

  • [tools]: any mise tool name = version (node = "24", uv = "0.11"). 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: names under the project data dir (OX_DATA_DIR), writable and kept across releases, like ["media"].
  • [limits]: memory caps the project's whole slice (processes and builds); cpu is a number of cores.
  • [models] huggingface: model repos pulled into the project's cache on every deploy ("org/repo" or "org/repo@revision"). The environment gains HF_HOME. Gated models need the Hugging Face token from Settings.

Detection

Detection runs on the checked-out commit and only fills keys the manifest leaves empty. It never overrides a declared value.

  • Version files (mise.toml, .tool-versions, .nvmrc, .python-version, package.json engines, go.mod, rust-toolchain.toml) fill [tools]; a detected language with no version file gets ox's default table.
  • Lockfiles fill the package manager, install, build, and start commands: bun.lock, pnpm-lock.yaml, yarn.lock, package-lock.json, uv.lock, poetry.lock, requirements.txt.
  • manage.py fills Django migrate, collectstatic, and a gunicorn/uvicorn start. go.mod fills go build and the start binary. Cargo.toml fills cargo build --release. A Vite build with no start script fills [static] dist with spa = true.
  • Only when the repo has no ox.toml, dependencies also fill [services]: python or node postgres drivers imply postgres; redis, ioredis, bullmq, celery[redis] imply redis. With an ox.toml, [services] is exactly what is declared; an implied but undeclared service shows as a hint on the review screen.
  • A repo with nothing to run and no detectable start command is asked for one on the review screen before the first deploy.

Variables

Three kinds, all managed in the dashboard's Variables tab. None of them live in ox.toml.

  • Provided by ox, on every deploy: PORT (per process), 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.
  • Yours: API keys and feature config, typed by you. Values 6 characters or longer are redacted in run logs. Saving variables deploys the current production commit, because build-time variables can change the build.
  • Required: key names in the repo's .env.example (or .env.sample / .env.template), at the root or one directory deep. The deploy is refused with the list 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.

Examples

A built SPA with no backend (Caddy serves dist/, the repo's own build produces it):

domains = ["www.example.com"]

[static]
dir = "dist"
spa = true

Next.js SSR (start, build, and the node version come from package.json and the lockfile):

domains = ["app.example.com"]

[app]
health = "/"

Python with postgres, redis, a worker, cron, a migration, and persistent uploads:

[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):

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 ci", "npm run build"]
migrate  = "go run ./cmd/migrate"

[services]
postgres = {}
redis    = {}

[tools]
node = "24"

A bot with no public URL: a shared neo4j, a private qdrant, and Hugging Face models. The worker binds its own port so its health check can call it:

[build]
commands = [
  "uv venv --python 3.12 .venv",
  "uv pip install --python .venv -r requirements.txt",
]

[workers]
bot = { run = "exec .venv/bin/python bot.py", port = true, health = "/health", memory = "1G" }

[services]
neo4j  = {}
qdrant = {}

[models]
huggingface = ["KanariKanaru/nsfw-image-detection-384-onnx"]

[tools]
uv = "0.11"

[limits]
memory = "1500M"

Common mistakes

  • Pinning [app] port when the app reads $PORT. Leave it out; a pin costs a short restart per deploy.
  • Setting a provided key (DATABASE_URL, REDIS_URL, PORT) as your own. Refused at save. Use an external database by removing the service and setting the URL as yours.
  • Git submodules. Refused; releases come from git archive. Vendor the code.
  • Expecting framework env vars (DEBUG, SECRET_KEY, ALLOWED_HOSTS, CORS_*, NODE_ENV). ox never injects them.
  • Toolchains in packages. Refused; declare [tools] instead.
  • Legacy keys from the old engine (runtime, package_manager, [deploy], [frontend], writable_paths, and the old array-of-table blocks). Each fails with the ox1 key that replaces it.
  • {port} placeholders. Commands read $PORT from the shell.
  • A worker with health but no port = true. Fails: the health check calls the worker's $PORT.
  • Cron in local time. Schedules run UTC.
  • static.api without [app], or domains with neither [app] nor [static]. Both fail validation.
  • Absolute host paths under /srv, /var, /etc, /opt, or /home. Refused; use paths relative to the repo root.

Agent instructions

The Copy skill for AI agent button at the top copies the bundled ox.toml skill below for your coding agent. It is the full authoring contract: schema, detection, services, variables, examples, and mistakes.