---
name: ox-manifest
description: Author and review ox deploy manifests (ox.toml at the repo root) for the ox1 engine, validate them with `ox check` before pushing, and run ox through its CLI (make the project, set variables, deploy, read logs). Use when generating or editing ox.toml, fixing a validation error, or preparing, deploying or debugging a repo on ox.
---

# ox manifest authoring

`ox.toml` sits at the repo root and is the whole deploy contract for the ox1 engine: the web process, workers, cron, services, build. No LLM runs at deploy time. ox deploys one repo onto one Ubuntu 26.04 host with systemd, Caddy, PostgreSQL 18, Redis, neo4j, qdrant, and MariaDB (`mysql`) from apt or mise, and mise toolchains. There is no Docker. Releases are built from `git archive` of the commit, and each git submodule is exported at the commit it pins.

Use this skill whenever you generate or edit an `ox.toml`, fix a validation error, or prepare, deploy or debug a repo on ox.

## First: the ox CLI

Set up the `ox` CLI before anything else. `ox check` validates the manifest offline, and the other commands do everything the console does (make the project, set variables, deploy, read logs and backups), each with `--json`, so you can work through ox without a browser ("Deploying with the ox CLI" below).

1. Run `ox version`. If `ox` is not found, ask the person to install it and wait until they say it is done: `curl -fsSL https://deploywithox.com/install.sh | sh` (macOS or Linux; it installs `ox` in `/usr/local/bin`, or in `~/.local/bin`, which must be on the `PATH`). It is their machine: run the script yourself only when they ask you to.
2. Run `ox projects --json`. Exit code 3 means not signed in: ask the person to run `ox login` in their own terminal. It prints a code and a URL, they approve the code in a browser signed in to ox Cloud, and the CLI keeps its token in `~/.config/ox/credentials` (0600) for 90 days. It waits until the code is approved, so never start it where you cannot read its output while it runs. Never ask for or print the token.
3. If the person would rather not install it, write the manifest from this skill and tell them it was not run through `ox check`; they deploy from the dashboard.

## Workflow

1. **Start from `ox check --json`.** It reads the repo's production recipe for you (the Dockerfile a release workflow builds, release workflows, `Procfile`, `app.json`, compose files) and returns every value with its source, an `evidence` block of the recipe's facts with `file:line`, and `conflicts` where a value differs from the recipe. Fill only what it cannot: secrets, `domains`, the operator's choices, and a reason for each conflict you settle in ox.toml (the recipe's value, or yours with a comment). Then read on for what it does not read (step 2's list). Read its image facts before you write `start`: a `workdir` not marked `container` is the release (`$PWD`), `env` and `export` values rewritten to `$PWD` are paths in the release and ones marked `container` are paths in the image, not on the server, and the entrypoint's `exec` facts are what its script can run, each `conditional` one only on some arguments or settings, so read the script to see which one your process needs; never copy the container's start (its `/run.sh`, `dumb-init` or `setpriv`), and set an export the app needs in your command when a hint names one.
2. **Inspect the repo.** Read the files that drive detection: lockfiles, `package.json`, `pyproject.toml` / `requirements.txt` / `manage.py`, `go.mod`, `Cargo.toml`, `mise.toml` / `.mise.toml` / `.nvmrc` / `.python-version`, `prisma/schema.prisma`. The detection table below says what ox fills from each. Then read the repo's production recipe when it has one (a `Dockerfile` at the root or in a deploy folder like `tools/deploy/docker/`, `docker-compose.yml`, `Procfile`, `app.json`, its self-hosting docs): it is the setup the project tests, and your plan must match it. Install what it installs (a `uv sync` or `poetry install` there without `--no-dev` or `--only main` installs the dev group, so declare that install: Saleor imports pytest at run time), run every process it runs (web, workers, a scheduler) and its migrations, plus a scheduler the app ships that the recipe does not start (Outline's `cron` service, which its default `--services` leave out; a `cron.php`; a token-guarded `/api/cron` route) as a worker or a `[cron]` entry, since nothing else runs it, and give its environment's required values in ox.vars. Its last stage is what runs: copy its build steps after the install (an asset build like Litestar's `uv run app assets build`), its `ENV` lines the app reads into ox.vars (Mealie's `PRODUCTION=true`), and every folder it makes writable for the app (a `mkdir`, `chmod`, `chown` or `VOLUME` of a path inside the app) into `[storage] keep` (Logto's start rewrites `packages/cli/alteration-scripts`). Migrate with the command it runs; when it runs none because the app migrates as it starts, declare no `migrate`, unless the app has that step as a command of its own: then run it as `migrate`, because a first deploy applies every migration and inside the start that counts against the 120 s health wait (authentik's server and worker both run `lifecycle/migrate.py` as they start, more than 360 migrations took over two minutes and the worker's health check gave up; `migrate = "python -m lifecycle.migrate"` runs them first, and the starts find nothing left to do). Its `apt-get install` lines name Debian packages: use the Ubuntu 26.04 name `ox check` suggests. ox check reads none of these, so carry them yourself: a job in another workflow job or a Makefile target the image build depends on (AFFiNE's native server build, PhotoPrism's `make` targets), with every step of that job up to its upload, renames included, so the job that downloads its artifact finds the same files under the same names (AFFiNE's `build-server-native` renames `server-native.node` to `server-native.x64.node`, `.arm64.node` and `.armv7.node`, one per matrix target, and the server bundle resolves all three: build it once and copy it to each name, or the bundle writes no `dist/main.js` and still exits 0), an app's environment schema in code (Outline's), and a CLI's own options (Mautic's install command).
3. **Write the smallest ox.toml.** Declare only what detection cannot know: `domains`, the `[services]` the app needs, `[workers]`, `[cron]`, and any command detection gets wrong. Leave `[tools]` versions, `[build] install`, `[build] commands`, `[app] start`, and `[static]` out when the repo names them. Detection never overrides a declared value. A build runs `[build] install` (the lockfile's install, detected) and then `[build] commands`; declaring `commands` replaces only the detected build steps, never the install, so list a detected step you still need (Django's `collectstatic`) in your `commands`: `ox check` hints each one you left out. Declare `install` only when the packages are not installed from the root lockfile (a monorepo, a venv on a specific Python) or the repo's recipe installs other groups or extras than the detected install (step 2). `install = ""`, `commands = []` and `migrate = ""` say there is none (a static folder served as it is, beside a `package.json` that builds a library): unset keeps what detection finds. A repo with no `ox.toml` at all deploys zero-config, and dependencies even imply `[services]`; once an `ox.toml` exists, `[services]` is exactly what it declares. An `ox.toml` with neither `[app]` nor `[static]` still gets the detected 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) and the file declares `[workers]` or `[cron]`, `ox check` stops until it sets `[app] start` or `[app] enabled = false`. A project that serves no web page (a bot, a queue consumer) says `[app] enabled = false`, which also drops a detected static site.
4. **Run `ox check`** from the repo root. It is offline and reads nothing but the checkout. It prints the resolved plan with each value's source (`declared`, `detected:<file>`, `default`) and every problem at once. Read the `build.install` and `build.commands` rows: they are exactly what runs, in order. An app with dependencies and no `build.install` row ships a release with no packages, and it crashes at start. Fix every problem and rerun until it prints `Ready to deploy.` `ox check --json` gives the same data as JSON. These are the same checks preflight runs: a bad manifest or a missing variable stops the deploy before the host is touched.
5. **List the variables the operator must set.** Read `.env.example` (or `.env.sample` / `.env.template`) at the root or one directory deep (not a test suite's or docs site's folder: `test`, `tests`, `e2e`, `playwright`, `cypress`, `spec`, `__tests__`, `testdata`, `docs`). Every key there that ox does not provide and that the operator must answer (an empty example, a secret or login like `SECRET_KEY` or `DB_PASSWORD`, a placeholder like `changeme`, an address of the developer's machine or network like `localhost` or `10.0.0.5`, or a URL with a login in it, user info or a `?token=`) must be set in the dashboard's Variables tab, and the deploy is refused until each one is set. A key with a real example value (`DEBUG=False`, `WORKERS=4`) is not asked: the app's own default stands. Read the app's environment schema in code too (Outline's `server/env.ts`), not only the example file: when the app takes mail as a host and port, ask for host, port, user, password and from address as separate variables, since Outline's `.env.sample` names only a mail service and a plain relay then left email sign-in off. Hand the operator the list with a one-line note per key. Commit the example file with placeholder values only; real secrets never go in the repo.
6. **Push.** A push to the default branch deploys at once (GitHub's webhook), with a poll every minute as the fallback (or the operator presses Deploy). A failed commit is not retried until the next push or a manual Deploy.

## Schema (TOML; every key is optional, unknown keys fail)

```toml
domains  = ["example.com"]        # served by [app], or by [static] when there is no [app]
packages = ["ffmpeg"]            # apt system libraries only (no toolchains; nothing that changes sign-in, boot or the firewall, like openssh-server or ufw; a server a package ships stays off, declare it in [services] to run it)
required_variables = ["JWT_SECRET"]  # keys the app cannot start without that no .env.example lists: asked before the first deploy; 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
internal = true                   # serve nothing public: no preview host, no domain, no PUBLIC_URL; the app runs for its workers on loopback. Refused with `domains`

[app]                             # the one web process; gets $PORT
enabled = false                   # no web process and no detected static site (workers only); takes no other [app] key; default: the detected app
start   = "serve"                 # default: detected (§4.2)
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; default: none, the project shares the server. Set it only to a number the repo gives
sandbox = "relaxed"               # may write anywhere in the project home, every release included; the only value; default: strict (writes its home and the release it runs; PHP: only its data dir and tool caches)
max_upload = "2G"                 # a PHP app's largest upload (upload_max_filesize, post_max_size 8M above); default: 512M, or a repo php.ini's own limits; PHP apps only
stop_before_start = true          # an app that cannot run two copies at once (a lock in its data folder, a second fixed port, one instance per database): stop the running copy, then start the new one; costs 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 directories, each served at its URL path

[build]                           # runs as the project user with every variable but PORT; OX_RELEASE is the commit, and .git holds it alone (no history, branch, tags or remote; a server's agent that was not updated builds with no .git); no CI is set, as in a Docker build
install  = "npm ci --include=dev" # runs first; default: the lockfile's install
commands = ["make build"]         # runs after install; default: detected
migrate  = "make migrate"         # runs 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" }
jobs   = { run = "celery -A app worker", stop_timeout = "10m" }  # a stop waits this long for running tasks (default 90s, up to 1h); the command must be the main process: no pipe or &, and a redirect only after exec
stream = { run = "exec node streaming/index.js", port = true, paths = ["/api/v1/streaming"] }  # these parts of the site go to this worker, websockets included (default: none)
admin  = { run = "exec node admin.js", port = true, public = true }  # an address of its own, in $ADMIN_PUBLIC_URL; domains = [...] adds names (default: none)

[cron]                            # 5-field cron or @hourly/@daily/@weekly/@monthly, UTC
digest = { schedule = "0 7 * * *", run = "python manage.py send_digest" }

[services]                        # built-in types: postgres, redis, neo4j, qdrant, mysql, clickhouse, meilisearch, rabbitmq
postgres  = { version = "18", extensions = ["vector"] }
analytics = { type = "postgres" } # a second database: ANALYTICS_DATABASE_URL
redis     = {}                    # private by default
qdrant    = { port = 9200 }       # private only; pin its port

[services.search]                 # a custom service: an entry with run (Meilisearch is also built in: meilisearch = {})
run      = "export MEILI_MASTER_KEY=\"$SERVICE_PASSWORD\"; exec meilisearch --db-path \"$SERVICE_DATA_DIR/db\" --http-addr 127.0.0.1:$PORT --env production"
tool     = "github:meilisearch/meilisearch@1.12.0"   # a mise tool; or packages = [...] (apt)
health   = "/health"              # HTTP 2xx/3xx; default: TCP connect
memory   = "512M"                 # cap and capacity floor; default 128M
provides = { MEILI_URL = "http://${host}:${port}", MEILI_MASTER_KEY = "${password}" }
backup   = true                   # default: its data dir is backed up daily

[tools]                           # a mise tool = version (no npm:/cargo:/pipx:-style package backends, no php, no erlang); default: from repo files (§4.2)
node = "24"
bun  = "1.3"

[storage]
keep = ["media"]                  # repo paths, linked to the project data dir: writable, survive releases; each is backed up daily as a .tar.gz (Back up now, restore) while the app runs; never services/ or .hf/ (ox's own)

[limits]                          # the project's whole slice (processes + builds); default: none
memory = "1G"                     # set it only to a number the operator gives: a build counts too
cpu    = 1.5                      # cores

[models]
huggingface = ["org/repo", "org/repo@revision"]
```

Rules behind the fields:

- **Names.** Worker and cron names match `^[a-z][a-z0-9-]{0,27}$`, and `app` is reserved. A worker name may not start with `app-`, `cron-` or `svc-` (ox names the web, cron jobs and services that way): put the word at the end instead (`a-app`, not `app-a`).
- **Sizes and CPU.** `memory` takes `K`/`M`/`G` suffixes, `cpu` a positive number of cores. A per-process `memory` larger than `[limits] memory` fails validation.
- **Ports.** Leave `[app] port` out: ox allocates and records one, and both sides of the zero-downtime switch need their own. A pin (1024-65535, unique on the host) runs one side only and restarts in place, with a moment of downtime per deploy, and a staging copy or preview whose ox.toml pins the same port cannot start beside it. Every process gets a port of its own in `$PORT`: the app, each worker, each cron job, and `ox exec` runs. A worker or cron whose program listens anyway (a worker mode that still serves a status page) binds it on its own, and a program that ignores `PORT` sees no difference. `port = true` on a worker means the project's other processes call it: ox publishes `<NAME>_URL` and starts it, and waits for its `health`, before the app switches. Name it so that key is free: a worker `media` sets `MEDIA_URL` in every process, over Django's own setting, and `ox check` refuses a key the code reads with a default other than an `http(s)://` or `ws(s)://` URL (call it `media-server`). A worker with `health` is checked on its own `$PORT`, before the app switches too. A server that does not read `PORT` must be told it: granian and uvicorn `--host 127.0.0.1 --port $PORT`, hypercorn `--bind 127.0.0.1:$PORT`, daphne `-b 127.0.0.1 -p $PORT`, waitress-serve `--listen 127.0.0.1:$PORT`, `flask run --port $PORT`; without it they listen on 8000, 8080 or 5000 and `ox check` refuses the start (daphne's `-b` is only its host). gunicorn reads `PORT` itself. Between 9010 and 9999 a process may listen only on its own `$PORT`: a fixed port there fails to bind (permission denied), so never hard-code one.
- **A second web process.** When the browser reaches a second process of the app on the app's own address (Mastodon's streaming server at `/api/v1/streaming`, AnyCable at `/cable`, Laravel Reverb at `/app` and `/apps`, a Socket.IO server at `/socket.io`), make it a `port = true` worker with those prefixes in `paths`. ox's proxy sends each prefix and everything under it to the worker's `$PORT`, websockets included, on every domain and the preview link, with the path kept (`/cable/x` stays `/cable/x`); the rest goes to the app. A prefix starts with `/`, is not `/`, has no trailing `/`, and belongs to one worker; the longest 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 (it would answer 403). Every deploy, even an app-only one, restarts the worker in place (it has no second side like the app), so its paths answer 502 for those seconds and its open websockets drop: the clients must reconnect. ox never strips the prefix.
- **A second site on a name of its own.** When the app serves a second site only on an address of its own, not under a path (Logto's Admin Console answers only on the host in `ADMIN_ENDPOINT`), run that listener as a `port = true` worker with `public = true` and point the app's setting at `${<NAME>_PUBLIC_URL}` (`ADMIN_ENDPOINT = "${ADMIN_PUBLIC_URL}"`). Every request for the worker's names goes to its `$PORT`, whole. Its names are its preview link `<worker>--<project>.<label>.preview.getox.in` and the `domains = [...]` it lists (a public worker's name is longer than 2 characters, and no name holds `--`); `<NAME>_PUBLIC_URL` is `https://` and its first domain, else that link on the scheme it is served with, so never write the scheme. 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, and nothing else runs. Never run the app's start a second time as a worker: two copies of one release race over its files at every boot (Logto's alteration scripts). Never route both through one name.
- **Starts stay in the foreground.** `[app] start` and every worker run under systemd: a command that forks into the background and returns (NodeBB's `node loader.js` without `--no-daemon`, a script ending in `&`) counts as a crash. Use the app's foreground flag and `exec` the server.
- **An app that cannot run two copies at once.** ox starts a new release beside the running one, so an app that holds a lock in its data folder (Gitea's file queues), a second fixed port (Mailpit's SMTP), one instance per database (Pocket ID) or a port saved in its own database (Calibre-Web) passes its first deploy and fails every redeploy. Prefer the app's own fix (`$PORT` every time, state in a service). When there is none, set `[app] stop_before_start = true`: a deploy stops the running copy, then starts the new release, and the site answers 502 for that gap on every deploy. A release that does not come up is put back (the running one starts again). The failure names the key when the journal shows the clash. An Erlang release whose `vm.args` sets `inet_dist_listen_min` with no max listens for other nodes on that one port, so its second copy dies with `:nodistribution`: set `ERL_ZFLAGS="-kernel inet_dist_listen_max <a higher port>"` with `ox vars set` (Livebook).
- **Commands** run in the release directory, the build's steps (`install`, `commands`, `migrate`) with `bash -euo pipefail -c` (an unset variable stops one, and the failure names the key: give it a value, or write `${NAME:-}` where empty is fine) and the start, workers and cron with `bash -c`, with the tools on `PATH`, the release's `.venv/bin` before them and its `node_modules/.bin` last: `gunicorn ...` and `prisma ...` run bare, `uv run` is not needed. `$PORT` and other variables expand in the shell. There are no `{port}` placeholders.
- **`packages`** are Ubuntu 26.04 system libraries (`ffmpeg`, `libmagic1t64`), by their real package name on that release: `ox check` refuses a Debian or older Ubuntu name (`libxml2` is `libxml2-16` there; for headers name the `-dev` package) and a virtual name (`libmagic1`, which `libmagic1t64` provides), and names the right one. Toolchains (`nodejs`, `npm`, `python3-pip`, `golang*`, `rustc`, `cargo`) are refused with the `[tools]` line that replaces them. Ruby, Rust and Python apps get C compilers by themselves (and Go with cgo, Phoenix with a NIF to build), and any `[tools] java` gets `fontconfig` (the JDK's font code needs it): never list them; a Node addon with no prebuilt binary for the pinned version fails its install, and the failure names `packages = ["gcc", "g++", "make", "libc6-dev"]` to add (or pin a version that ships a binary). A source build that misses a library's headers (`pg_config.h`, `mysql_config`, `lber.h`) names the `-dev` package to add, as when your install adds a dependency group detection does not read. A Gemfile that names `mini_magick` or `simple_captcha2` gets `imagemagick` by itself (they run its `convert`); any ImageMagick package (`imagemagick`, `libmagickwand-dev`, `php-imagick`) brings its default font `fonts-urw-base35` with it, since ox installs no Recommends and ImageMagick cannot draw text without it; a gem's program the detection does not know is yours to list.
- **No host paths.** No value may name an absolute path under `/srv`, `/var`, `/etc`, `/opt`, or `/home`. `keep` and `dir` entries are relative and may not contain `..`.
- **`[static]`.** Without `[app]`, the project is a static site. `api` requires `[app]`. `dir` must exist after the build, so it is never at or inside a `[storage] keep` folder: Caddy serves only what the build writes, and the app serves its own uploads. `[static] dir` may be omitted when only `paths` is set. Without `spa`, a page asked for without its `.html` is that page (`guide/intro.html` at `/guide/intro`, as VitePress' `cleanUrls` and other generators' pretty URLs expect, and Docusaurus' `blog.html` at `/blog` beside a `blog` folder of later pages), unless a file or a folder with its own `index.html` stands at the path (such a folder redirects to its slash), so a build that writes `trailingSlash: false` pages needs no step that copies them into folders, and keeps its 404s. With `spa = true`, a prerendered page in a folder (`story/index.html`) answers at `/story`, and a path with no file gets `200.html` when the build writes one (vite-ssg with `dirStyle: 'nested'`), else `index.html`; `about.html` is not served at `/about`, and a missing path ending in a file extension (`/favicon.ico`, an old `/assets/app-<hash>.js`) is a 404, never the shell (so a client route like `/files/report.pdf` is a 404 too). 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` hints at any `.env` it finds in the folder at any depth, so a plain site whose `dir` is the repo root does not leak a committed secret. A missing file gets the `404.html` at the top of `dir`, status 404, when the build writes one (any static site); an unknown SPA route stays the shell with 200, so the router shows its own not-found view. ox sets the cache headers: `index.html` and hand-named files revalidate on every load, and content-hashed build output (`/_next/static/`, `/_app/immutable/`, Vite's `/assets/index-<hash>.js` when the hash holds an uppercase letter and a digit, `-` or `_`, Django's `ManifestStaticFilesStorage` names) is cached for a year, so keep the build tool's hashed file names. A proxied response keeps the app's own headers; ox adds `nosniff`, a referrer policy and (with a domain) HSTS only when the app set none, and no CSP (a static SPA sets one with a `<meta http-equiv="Content-Security-Policy">` tag in `index.html`; there is no headers key).

## Detection (defaults only)

Detection runs on the checked-out commit, only fills keys the manifest leaves empty, and never overrides a declared value. It proposes a value only when a file proves it: a top-level app object, a `main` that exists, a config file plus its dependency. When it sees a framework but cannot be sure (several candidates, an app factory other than a Flask `create_app` or `make_app` that needs no arguments, no server dependency), it proposes nothing and a hint says what to set. With no `ox.toml`, what only an `ox.toml` can fix (a Laravel app's kept `storage`, a WordPress site) is an issue, so `ox check` never says ready for a plan that cannot work.

| Signal | Default filled |
|---|---|
| `mise.toml` and `.mise.toml` (which wins, as in mise), `.tool-versions`, `.nvmrc`, `.node-version`, `.python-version`, `.bun-version`, `package.json` `engines.node`/`packageManager`/`engines.pnpm` (or `yarn`, `bun`) and `devEngines.runtime`/`devEngines.packageManager` (an object or a list of alternatives; the alternative for the manager or runtime ox runs is read, the manager the lockfile picks, Node, or Bun for a Bun project; version an exact one, pinned as `packageManager` is, or a range; it beats `engines`, and `packageManager` beats it; `onFail` and a `deno` runtime are not read), `pnpm-lock.yaml` `lockfileVersion` (pnpm only, when `packageManager` names no version: `6.0` installs pnpm 8, `5.4` pnpm 7, `5.3` pnpm 6, since a newer pnpm refuses an older lockfile), `go.mod` `go`/`toolchain`, `rust-toolchain.toml` | `[tools]` versions (`nodejs` and `golang` are read as `node` and `go`; a range like `>=18` takes ox's default when it allows it; a trailing `.*` or `.x` is dropped: `22.*` pins `22`), only for a tool the stack or a plan command runs: a mise file's or `.tool-versions`' extra (`shellcheck`), or an `.nvmrc` the app does not run (beside a `package.json` that only holds tooling too), is a hint and its package managers go with it, so set `[tools]` when a script or make target needs it |
| `.nvmrc`, `.node-version`, `.python-version`, `.bun-version` one folder down | the version of a tool the root pins nothing for (a monorepo's Python consumer); folders that disagree are a hint. Like any version file it only pins: the tool installs when a command runs it. In any one folder `.nvmrc` wins over `.node-version`, and a disagreement is a hint |
| none of the above for a detected language | the ox release's default version table (`ox check` prints it) |
| `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json` | package manager (with two lockfiles, the one `packageManager`, else `devEngines.packageManager`, names; a Volta pin does not choose), `[build] install` (frozen lockfile, keeping `devDependencies` whatever `NODE_ENV` says: `npm ci --include=dev`, `pnpm install --frozen-lockfile` with `--prod=false` only when the pnpm ox installs, `[tools] pnpm` included, is before 10, Yarn 1's `--production=false`; a declared install for pnpm 10+ or Yarn 2+ leaves the flag out, since pnpm 12.3.4 and Yarn 2+ refuse it), `build` script, `start` script. The C compilers join `packages` when the lockfile installs `better-sqlite3`, `re2`, `isolated-vm` or `node-pty` (they compile with node-gyp when no prebuilt binary fits the server's Node); never list them yourself for these. Every build step and unit has `pnpm_config_verify_deps_before_run=false`, so pnpm 11's `pnpm run` never installs again after the build. Build steps skip puppeteer's, Playwright's and Cypress's browser downloads (ox downloads no browser on its own); a variable of the same name wins. An explicit `playwright install` still downloads: one that installs Chromium (names `chromium`, its headless shell, or no browser) adds Chromium's apt packages to the plan (not Firefox's or WebKit's: list those yourself), so never list them or use `--with-deps` (it runs apt through sudo, which the build cannot use, and stops the plan; for a repo's script the stop names the file and line, and the fix is to drop `--with-deps` there, even when your `[build] install` skips the scripts). A repo that resolves one Playwright below 1.61 (no Ubuntu 26.04 browser build) and runs `playwright install` gets `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-<arch>` in its builds and units; never set it yourself |
| a `package.json` beside a Go, Python, Rust, JVM, Elixir, .NET or PHP app, or a static site | when a `[build]` command runs Node (`npm`, `npx`, `yarn`, `pnpm`, `bun`, `node`, or a `make` target whose recipe does), the lockfile's install and Node join the plan: list the build only, not `npm ci` or `[tools] node`. A declared `install` that installs no Node packages (`uv sync`) gains the lockfile's install too (pretix, whose Django app sits in `src/`). A command that runs `npm`, `yarn`, `pnpm`, `node` or `bun` with no repo file pinning it (a `ui/` folder beside a Rust or Python root) gets the tool at ox's default; set `[tools] node` only to pin another version. ox reads the command line only: a command that runs Node inside the app's own script (`python -m pretalx rebuild` calls npm) needs `[tools] node` declared; read the scripts your build commands call. The one script ox does read is a PHP app's `composer.json` hooks that `composer install` fires (`post-install-cmd`, also through an `@script`): one that runs `npm` brings Node. A PHP or Rails app whose `package.json` has no `build` script builds the one workspace member that has one (`cd resources/assets/v3 && npm run build`); with several, add the ones the app serves. A root `package.json`, Python file or `go.mod` that only holds tooling (no start, server or app entry; documentation, lint and test tools; no Go code) decides no stack and installs nothing: ox plans the other app, and a hint says so (a Rails app installs its `package.json` whatever it holds) |
| `uv.lock`; `poetry.lock`; `requirements.txt` | `[build] install`: `uv sync --frozen --no-dev`; `poetry install --only main`; `uv venv` + `uv pip install -r requirements.txt`. Python is ox's default (no pin and a `requires-python` that allows it), else the nearest release the range allows, and the C compilers come with every Python app; `python-ldap`, `mysqlclient`, `uwsgi`, `psycopg2`, `psycopg[c]`, `wand` and `gssapi` in the install add their headers to `packages` (`libldap-dev libsasl2-dev`, `libmysqlclient-dev pkg-config`, `libpcre2-dev` for uWSGI's `--route`, `libpq-dev`, `libmagickwand-dev`, `libkrb5-dev`), and so do `lxml` and `xmlsec` when `[tool.uv] no-binary-package` builds them from source (`libxml2-dev libxslt1-dev`, `libxmlsec1-dev pkg-config`). A dependency counts for detection (a start's gunicorn or uvicorn, services) only when the install installs it: one only in a dev group or an extra is not seen, except that an install whose `uv sync` names a group or extra (`--group postgres`, `--extra ldap`, `--all-extras`) gets the headers its source packages need (`[tool.uv] default-groups` other than `dev` are installed, so they count). A lock or `pyproject.toml` the install reads that is not valid TOML is an issue in `ox check` naming the file and the fix (`uv lock` or `poetry lock`, then commit); a `pyproject.toml` beside a `requirements.txt` install only gets a hint. Every build step and unit has `UV_NO_SYNC=1`, so `uv run` never syncs after the install: put extras a command needs there (`--extra pgsql`) |
| `manage.py` (Django a dependency, or the file names its settings module) | Django: `migrate --noinput`, `collectstatic --noinput` when `settings.py` or a `settings/` package sets `STATIC_ROOT` (then `compress --force` with django-compressor and `COMPRESS_OFFLINE = True`); start with gunicorn or uvicorn when it is a dependency, else uwsgi (on ox's port, with ox's own settings: the repo's `uwsgi.ini` is not read, because its pidfile and stats port cannot run under ox's unit; serve its `static-map` folder with `[static] paths`, or set `[app] start`). When `manage.py`, `wsgi.py` or `asgi.py` defaults `DJANGO_SETTINGS_MODULE` to a settings module other than `production` and `settings/production.py` exists (bakerydemo's `dev`), a hint names the key to set to the production module (never a required key; it goes once the key is set); when the Dockerfile's `ENV` sets the key and a `wsgi.py`, `asgi.py` or `manage.py` defaults it to another module (Label Studio's `core/wsgi.py`), the hint names the Dockerfile's module: set it in ox.vars. It wins over a `package.json` beside it; add node and the frontend build yourself. Only the root's `manage.py` is read: a Django project one folder down (pretix's `src/`) gets a hint naming the `[app] start` and `[build] migrate` to set, and nothing is proposed |
| `main.py`, `app.py`, `app/main.py`, `src/main.py` with a top-level FastAPI, Starlette, or Flask object | start `uvicorn <module>:<name>` (gunicorn for Flask, or uwsgi when it is the only server) when the server is a dependency |
| `go.mod` | `go build -o .ox/bin/app .` (or `./cmd`, or a single `./cmd/<name>`, when there is no main package at the root: a `.go` file of `package main`, whatever its name), start `.ox/bin/app`. The C compilers join `packages` when `go.mod` requires a cgo module (`github.com/mattn/go-sqlite3`, direct or through `gorm.io/driver/sqlite`; `rogchap.com/v8go`). A root `package.json` that only builds assets (no start script or bin entry, no main or module entry whose file or folder is in the repo, no web server among its `dependencies` (`express`, `fastify`, `hono`, `@remix-run/serve`...), and, when it has any runtime `dependencies`, no root `index`, `server` or `app` file (`.js`, `.mjs`, `.cjs`, `.ts`) and no script in which a JS runner, as a word of its own (not quoted inside another argument, not a flag's value such as `esbuild --platform node`), whatever comes before it (`VAR="a b"`, `exec`, `npx`, `pnpm exec`, `env-cmd -f .env`, `dotenv --`), is given a `.js` or `.ts` script file, or a folder as its first argument (`node`, `tsx`, `ts-node` or `nodemon` with any flags, `bun <file>`, `bun run <file>`, `deno run <file>`, `pm2 start`); npm's lifecycle hooks (`preinstall`, `install`, `postinstall`, `prepublish`, `prepare`, `prepack`, `postpack`, `prepublishOnly`, and the pre and post of any script), a tool's `*.config.*` file and a helper in `scripts/` or `tools/` do not count, and a `scripts` value that is not a string is skipped: wakapi's CSS, the bundled frontends of mailpit and fider) or Python files that list only documentation, lint and test tools (mkdocs, sphinx, pytest, ruff...) and no `manage.py` or `[project.scripts]` do not hide it; any other Python or Node app keeps the repo; add node and the frontend build yourself |
| `Cargo.toml` | `cargo build --release`, start the binary a bare `cargo build` builds (a package's binaries by Cargo's rule: its `[[bin]]` entries, plus `src/main.rs` and `src/bin/` unless `autobins = false`, or edition 2015 with a `[[bin]]`; one binary starts, and among several the one named after the package; the packages are the root, or its `default-members`, or a virtual workspace's members less `exclude`; several → a hint, and a hint says why when there is none); `libssl-dev` and `pkg-config` join `packages` when `Cargo.lock` lists `openssl-sys`, `libcurl4-openssl-dev` for `rdkafka-sys`, with or without an ox.toml, also from a subfolder's `Cargo.lock` a build step runs `cargo` in (`cd quickwit && cargo build`) |
| `composer.json`, or `index.php` (at the root, in `public/`, `web/` or `docroot/`) | PHP on FrankenPHP: start `frankenphp php-server --root <public, ., web or docroot>`. FrankenPHP reads no `.htaccess`, so ox answers 403 for every folder under the root whose own `.htaccess` says `Require all denied` or `Deny from all` (FreshRSS's `data/`, Matomo's `config/`) and lists them in the plan's `closed` row. A root whose own folder is closed, and a closed folder named with anything but letters, digits and `. _ ~ -`, are issues. A request limit the repo's workflow raises (`max_input_vars`, `max_input_nesting_level`: moodle's CI sets `max_input_vars=5000`) goes into ox's PHP settings for the web app and `php-cli` alike, shown as a `php.ini` row: declare nothing for it, and do not add a `php.ini` for it, which `php-cli` never reads |
| a Vite/SPA build with no start script | `[static] dir = "dist"`, `spa = true` |
| `index.html` at the root and nothing else | `[static] dir = "."` |
| `prisma/schema.prisma` (or `schema.prisma` beside the `package.json`, which Prisma also finds) | migrate `prisma migrate deploy`; a workspace member's schema (`packages/database/prisma/schema.prisma`), `drizzle.config.*` or `knexfile.*` runs from that member's folder (`cd packages/database && pnpm exec prisma migrate deploy`); the root's own migration wins, and several matching members give a hint and no migrate |
| Next.js, Nuxt, SvelteKit, Astro, NestJS and other Node frameworks; static generators (Docusaurus, Eleventy, Gatsby, Hexo, VitePress, Jekyll); Rails; Spring Boot, Quarkus, ASP.NET Core, Phoenix, Laravel, Symfony, Deno | the build and the built output's start (never a dev server, nor a start script that sets its own port); read the plan `ox check` prints, and the stack's guide at `https://deploywithox.com/docs/guides` |

- Only when the repo has **no** `ox.toml`, detection also fills `[services]` from dependencies: `psycopg`/`psycopg2`/`asyncpg`/`pg`/`postgres`/Prisma with the `postgresql` provider imply postgres; `redis`/`ioredis`/`bullmq`/`celery[redis]` imply redis (a Node driver in the root `package.json` or in a workspace member's `dependencies`, from `workspaces` or `pnpm-workspace.yaml`, less `!` exclusions; a member's `devDependencies` don't count); a direct `go.mod` requirement (pgx, lib/pq, go-sql-driver/mysql, go-redis), a `Cargo.toml` dependency (sqlx or diesel by its `postgres`/`mysql` feature, tokio-postgres, the redis crate) and Symfony's Doctrine config (the `driver`, or the scheme of the URL in `.env`) imply theirs. On a project that is already live, a variable you set under a key such a service would provide (`DATABASE_URL`) keeps a newly implied one out: ox uses your value and hints so; before the first deploy that variable is refused as ox's. With an `ox.toml`, `[services]` is exactly what is declared: an implied but undeclared service shows as a hint 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.
- The first successful plan records every resolved value with its source. Later plans reuse recorded values until the repo declares the key or the detection signal changes (a new `.nvmrc` re-resolves node; a new ox release never silently changes a recorded version).

## Services

Each `[services]` entry is `<name> = { ... }` (names like workers'). Its type defaults to its name: `postgres = {}` is a postgres, `analytics = { type = "postgres" }` a second postgres for the same project. An entry with `run` is a custom service. ox installs each one, creates its database or instance, generates its password, and writes the connection keys.

| Type | Default mode | Version | Keys ox provides | Backup |
|---|---|---|---|---|
| `postgres` | shared: a database and role per entry | 18 | `DATABASE_URL` (`postgresql://`, which every client takes; SQLAlchemy reads it as psycopg2, so an app on `psycopg` or `asyncpg` changes it in its code with `re.sub(r"^postgres(ql)?://", "postgresql+<driver>://", url)`, which also takes the `postgres://` an older ox gives; code that rewrites only `postgres://` stops `ox check`) | `pg_dump` |
| `redis` | private only (`only_for_this_project = false` is refused) | 8 | `REDIS_URL` (carries its password) | files, while it runs |
| `neo4j` | private only (`only_for_this_project = false` is refused; about 512 MB each) | 5 | `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` | files, stopped for the copy |
| `qdrant` | private only | 1 | `QDRANT_URL` | files, stopped for the copy |
| `mysql` | private only (MariaDB, MySQL-compatible) | 11 | `MYSQL_URL` | files, while it runs |
| `clickhouse` | private only (new, not yet through the droplet gate; arm64 untested) | 26.8 | `CLICKHOUSE_URL` (HTTP interface only, user `app`, database `default`) | files, stopped for the copy |
| `meilisearch` | private only (new, not yet through the droplet gate; arm64 untested) | 1 | `MEILI_URL`, `MEILI_MASTER_KEY` | files, stopped for the copy |
| `rabbitmq` | private only (user and vhost `app`, no guest, no management UI; new, not yet through the droplet gate; arm64 untested) | 4 | `RABBITMQ_URL` | a definitions export (queues, exchanges, bindings, policies) while it runs; queued messages are not backed up |
| custom (`run`) | private only | its `tool` | its `provides`, or `<NAME>_URL` | files, stopped for the copy, unless `backup = false` |

- `only_for_this_project = true` runs a private instance: the unit `ox-<project>-svc-<entry>.service` in the project's slice, as the project user, in the strict sandbox, with data under `$OX_DATA_DIR/services/<entry>/` and its own port (allocated, or the `port` pin). A private postgres makes the project's role the instance's superuser (an app that runs `CREATE ROLE` itself, like Logto, needs it); a shared one is a plain login role that alone may connect to its database. `only_for_this_project = false` uses the shared instance, which only postgres has (it is refused on redis, neo4j and every other type, with the line to remove); `port` on a shared entry fails validation. The default mode is recorded.
- An entry named like its type provides its keys as they are; any other entry prefixes them with its name upper-cased (`job-queue = { type = "redis" }` provides `JOB_QUEUE_REDIS_URL`). Two entries providing the same key fail validation.
- A custom service's `run` runs with `bash -euo pipefail -c` in its data dir with `PORT`, `SERVICE_DATA_DIR`, `SERVICE_PASSWORD`, and the tools' `PATH`, and none of the app's variables. `tool` is `name@version` (a mise tool; a `github:` name in lowercase, and ClickHouse is `github:clickhouse/clickhouse@26.8.21.10-lts`, run as `clickhouse server`); `packages` are apt names; `provides` values are templates over `${host}`, `${port}`, `${password}`, with upper snake case keys. `type`, `version`, `extensions`, and `only_for_this_project = false` are refused on it. Keep `$SERVICE_PASSWORD` off every command line, or the deploy fails (a new service is stopped, a changed one goes back to its earlier definition, an unchanged one keeps running): hand it over as the service's own env var (`MEILI_MASTER_KEY="$SERVICE_PASSWORD" exec meilisearch ...`), on stdin (`exec valkey-server - <<<"requirepass $SERVICE_PASSWORD"`), or in a config file that names it (NATS: `echo 'authorization { token: $SERVICE_PASSWORD }' > auth.conf; exec nats-server -c auth.conf ...`). A Meilisearch, Typesense, Elasticsearch or OpenSearch, Redis, Valkey, ClickHouse, Chroma, Memcached, NATS, Prometheus, or VictoriaMetrics gets an Explore page (bind it to 127.0.0.1 on `$PORT`), known by its `tool`, `packages`, or `run`; set `explore = "<kind>"` only when none of those names it. A `.deb` that `run` or a build step unpacks with `dpkg-deb -x` brings none of its `Depends`: list them in `packages` too, or the program dies at start on a missing `lib*.so` (ParadeDB's `pg_search` .deb depends on `postgresql-18` and `libopenblas0`); `ox check` names each such command.
- A config copied from a Docker image that logs to `/dev/stdout` or `/dev/stderr` (squid's `cache_log`, nginx's `error_log`) works as is: those lines reach the project's logs.
- Private instances start, and pass health, before migrate and the app, and restart only when their definition changes. After a reboot the app, its workers and its cron jobs start only once every service they use accepts connections (a custom service: its `health`, or a TCP connect), so an app that reads its database at start needs no retry or sleep in `start`.
- `version` pins are enforced: a version ox cannot install on this OS fails preflight with the available list. postgres extensions: `postgres = { extensions = ["vector"] }`. ox adds on its own an extension that needs a superuser and ships with PostgreSQL (or `vector`) when `db/schema.rb`, `db/structure.sql`, or a `migrate`, `migrations`, `migration`, `alembic` or `drizzle` folder creates it (`ox check` lists it with its file). List one the app creates some other way (at boot, from code). For one PostgreSQL does not ship (`postgis`), `ox check` hints: add its package to `packages` and its name here. A deploy whose app is refused a `CREATE EXTENSION` names the line to add. Add one only when the app uses it: a restore that stops on an extension the dump creates but whose tables and schema load without it says so, and the fix is a dump without it (`pg_dump --exclude-extension=<name>`), not the line, unless a function or trigger of the app calls it (ox does not check those bodies). A restore loads the dump as the entry's own database role, not a superuser: ox makes the extensions the database already has (the ones listed here) itself first, a dump's `COMMENT ON EXTENSION` is left out, a plain dump with a psql command (a line starting with a backslash) is refused, and an extension the dump needs that the role may not create stops the restore with the line to add.
- Preflight checks the host has room: each shared instance once, every private instance of every project, the `[limits] memory` of every project that is not stopped (previews have a budget of their own, half the server). `ox start` runs the same check first.
- Removing an entry from `ox.toml` stops it and keeps its data; the Services tab lists it as unused with its size and a Delete data button. Deleting the project keeps a 7-day trash with a backup of every service that has a backup method; kept folders and SQLite files are not in it, so download their backups first.

## Variables

Four kinds, all managed in the dashboard's Variables tab:

- **Provided by ox**, on every deploy: `PORT` (its own for the app, each worker, each cron job and `ox exec`), `HOST`, `PUBLIC_URL` (the address the release is served on: `https://` and the first domain, else the preview host on https when its deploy found the certificate and on http otherwise (an http release moves to https on its own, a redeploy of the same commit that starts about a minute after its certificate comes, and https serves the http release's app meanwhile), so build URLs from it and never write the scheme), `PUBLIC_HOST`, `PUBLIC_SCHEME` and `PUBLIC_PORT` (`https` and `443`, or `http` and `80`: the release's own, for an app that takes its address in parts like Kellnr's `KELLNR_ORIGIN__PROTOCOL` and `__PORT`, so write `${PUBLIC_SCHEME}` and `${PUBLIC_PORT}`, never a fixed `https` or `443`), `<WORKER>_URL` for each `port = true` worker (`http://127.0.0.1:<port>`, name upper-cased, `-` becomes `_`), `<WORKER>_PUBLIC_URL` for each `public = true` worker (its own address), `OX_ENV` (`production`/`staging`/`preview`), `OX_PROJECT`, `OX_RELEASE` (the commit's sha, the same on every redeploy of that commit, so key a folder kept per release on the release's own folder, `$(basename "$PWD")`: Mautic's Symfony cache under `$OX_DATA_DIR/cache/$OX_RELEASE` was compiled with the previous release's paths, and the redeploy died on "Cannot redeclare class"), `OX_DATA_DIR`, the service keys above, and `HF_HOME` with `[models]`. Locked in the UI; copy them out when a command needs one.
- **From a service, by the usual names**: an app reads its database or cache from names its example file lists (`DB_HOST`, `POSTGRES_PASSWORD`, `REDIS_PORT`), not from the URL ox provides. When the project has one service of the kind such a name belongs to, ox sets the name from that service, so the review never asks for a value ox holds, and the review, `ox check` and the Variables tab list them as "From postgres". The names are one table: `DB_HOST`, `DB_PORT`, `DB_USERNAME` or `DB_USER`, `DB_PASSWORD` or `DB_PASS`, `DB_DATABASE` or `DB_NAME`, `DB_URL` and `DB_CONNECTION_STRING`, and the same with a `DATABASE_` prefix (a postgres or a mysql, when the project has exactly one of the two); `POSTGRES_HOST` or `POSTGRES_SERVER`, `POSTGRES_PORT`, `POSTGRES_USER` or `POSTGRES_USERNAME`, `POSTGRES_PASSWORD`, `POSTGRES_DB` or `POSTGRES_DATABASE`; libpq's `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`; and `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `REDIS_DB` (a Redis, or a custom Valkey). Once the example lists one name of a group, ox sets every name of it, because an app reads the one its own code names. Laravel's `DB_CONNECTION` (`pgsql` or `mysql`) and the key its `url` reads, and the URL variable Doctrine reads, are set from the service when `config/database.php` or `doctrine.yaml` proves the app reads them. A value of yours always wins, an empty one and an `unset KEY` line too, and a `DB_HOST` or URL of yours makes the whole group yours, so ox never sends its own password to a server you named. With two postgres entries, or a postgres and a mysql for a name that only says `DB_`, the keys stay asked and `ox check` names the references that fit (`DB_HOST=${postgres.host}`). A project that already runs takes only the keys it would be asked. Names of one app (`DISCOURSE_DB_HOST`, `GTS_DB_ADDRESS`, `TYPEORM_HOST`, `SPRING_DATASOURCE_USERNAME`), mysql names with a `MYSQL_` prefix, and the other service kinds are not in the table: set those yourself with `${entry.field}`. An app that reads `DB_HOST` as `host:port` (Apache Answer, WordPress) on a private postgres or mysql needs `DB_HOST=${postgres.host}:${postgres.port}`, which `ox check` hints at.
- **Yours**: API keys and feature config, typed by the operator. Values 6 characters or longer are redacted in run logs, except an absolute path, a path your `[storage] keep` lists, an IP address or `localhost`, and a common setting word (`disable`, `production`, `warning`, ...). A secret the app only needs to be random is generated by the plane in the key's well-known format, so it never passes through a terminal: `ox vars set <project> --generate KEY` (or `ox review --generate KEY`, or Generate on the review screen). `APP_KEY` is `base64:` and 32 bytes, `SECRET_KEY_BASE` 128 hex characters, `APP_SECRET` 32 hex characters, `SECRET_KEY` 64 characters, the auth libraries' and JWT keys their documented sizes, `RSA_PRIVATE_KEY` a 2048-bit RSA key in PEM (Saleor needs one once `DEBUG` is off: generate it, never leave it out or make one in a start script), `JWKS_KEY` a JSON key set with one RS256 RSA key (LobeHub parses it, so a random string breaks its internal calls), a key whose last word is `PASSWORD`, `PASSWD` or `PASS` (`ADMIN_PASSWORD`, `DB_PASS`) 32 characters of letters, digits and `*`, with a lower, an upper, a digit and a `*` that is never first (PeerTube refuses a password over 50 characters, bcrypt reads 72 bytes, Devise's secure_password wants a symbol, Halo's setup takes only `!@#$%^&*.?`; a key ending in `SALT`, `PEPPER`, `SECRET`, `KEY` or `TOKEN`, like `SECURITY_PASSWORD_SALT`, is not a password and keeps the 64-character secret), any other key 48 random bytes, URL-safe (64 characters); `ox check` names the format next to each key to set. A key the example file documents a random command for (`openssl rand -base64 32` or `-hex 32`, on its line or in the comment right above it) is made in that size and encoding, and Generate is offered for it: LobeHub decodes `KEY_VAULTS_SECRET` and refuses any size but 16, 24 or 32 bytes. Generate (and `can_generate` in `ox review --json`) is offered only for a secret the app makes for itself (`SESSION_SECRET`, `SECRET_KEY`, `ENCRYPTION_KEY`, a salt), never for a credential a service issued (`STRIPE_API_KEY`, `STRIPE_SECRET_KEY`, `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, a client or webhook secret, a service's password): ask the operator for those, never `--generate` them. A password the app sets up for itself (its first admin's, like `PT_INITIAL_ROOT_PASSWORD`) may be generated by name. A key that already has a value is refused (unset it first to rotate it), and `RAILS_MASTER_KEY` is never generated: only the one in `config/master.key` works.
- **Required**: key names in the repo's `.env.example` / `.env.sample` / `.env.template`, at the root or one directory deep (not a test suite's or docs site's folder: `test`, `tests`, `e2e`, `playwright`, `cypress`, `spec`, `__tests__`, `testdata`, `docs`). A key is required when its example is empty, a secret or login by name (unless the app is Laravel and its example is `null`, which Laravel reads as "none"; when the name's last word is not itself a credential word, like `OAUTH_TOKEN_URL` or `ACCESS_TOKEN_EXPIRE_MINUTES`, the value decides: a number, a boolean, a duration like `30d`, or an address, or a list of them, is a setting and is not asked), a placeholder, an address of the developer's machine or network, or a URL with a login in it; a key with a real example value is the app's own default and is not asked. A required key is satisfied by a provided key or a key of yours, even an empty one. A key the file lists twice counts once. A dotted key (CodeIgniter 4's `app.baseURL`) is listed as its underscore form, `app_baseURL`, which the framework also reads. Until then the deploy is refused with the list. A key the app cannot start without that no example file lists (Saleor's `RSA_PRIVATE_KEY`, a session secret its code reads) goes in `required_variables`, never only in prose or a comment: it is then asked the same way, and Generate answers a secret one. List there only what the app cannot start without: an optional feature's login (a mail relay's user and password, which many relays do without; web push `VAPID_*` keys) stays in ox.vars, because an operator without one answers Not needed and a required key answered so refuses the deploy (`ox check` hints at it). A file named like an example under another name (fider's `.example.env`, Mastodon's `.env.production.sample`) is not read: `ox check` hints at it, and the keys it lists that the app cannot start without go in `required_variables`.

References: a value may contain `${NAME}` (any provided key or key of yours) or `${entry.field}` (any `[services]` entry) with field `host|port|user|password|database|url`, for example `CELERY_BROKER_URL=${REDIS_URL}`. `$${` is a literal `${`. A bare `$NAME` is never expanded. An unknown reference or a cycle is refused at save. `PORT` and `OX_RELEASE` cannot be referenced. Saving variables deploys the current production commit, because build-time variables such as `VITE_*` can change the build.

ox never injects framework defaults (`DEBUG`, `SECRET_KEY`, `ALLOWED_HOSTS`, `CORS_*`, `FRONTEND_URL`, `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. The connection names above are the one exception: they are a service's own values, set only for a service the project declares.

## Worked examples

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

```toml
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):

```toml
domains = ["app.example.com"]

[app]
health = "/"
```

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

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

```toml
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"]  # npm ci --include=dev is detected from package-lock.json and runs first
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:

```toml
[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", "navodPeiris/minilm-toxic-spam-classifier"]

[tools]
uv = "0.11"
```

## Common mistakes

- **Copying an Alpine recipe's `apk` lines or musl downloads.** The server runs Ubuntu with glibc: list Ubuntu packages under `packages` and fetch the same publisher's `linux-gnu` build of the release the recipe pins. `ox check` stops on both. When that publisher ships only musl builds, another project's build of the library is not a substitute: DocuSeal pins its own pdfium, whose extra functions (`FPDF_HasOnlyDSSChanges`) upstream's build lacks, so the app exits at start. Build the library from the publisher's source if it is public; if not, say in your report that the app cannot run on the server, and why.
- **Migrating and seeding a Rails app in one rake process.** Run them as two commands, as the recipe does (`bundle exec rake db:migrate && bundle exec rake db:seed`): OpenProject's seed aborts inside the migration's process. ox adds no seed from the recipe; Rails' db:prepare seeds a fresh database once (a seed on every deploy can duplicate data): `ox check` names the recipe's seed in a hint with the whole `migrate` line, so paste it only when the app needs the seed on every deploy and the seed is safe to run again (after `db:prepare` it runs twice on the first deploy).
- **Pinning `[app] port`** when the app reads `$PORT`. Leave it out: ox allocates and records one, and zero downtime needs both sides. Pin only for an app that hardcodes its port, and accept the short restart each deploy.
- **Setting a provided key yourself** (`DATABASE_URL`, `REDIS_URL`, `PORT`, ...) as a variable or in `.env.example` values. Refused at save. To use your own external database, remove the service from `ox.toml` and set the URL as yours. A connection name ox answers from a service (`DB_HOST`, `POSTGRES_PASSWORD`) is not provided: set it and your value wins.
- **A submodule ox cannot fetch.** Submodules deploy at their pinned commit over https: GitHub ssh URLs (`git@github.com:o/r`) are rewritten, a relative URL (`../theme.git`) resolves against the repo's, and any other protocol is refused. A private submodule works when its repository is under the project's own owner and is one 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 one; 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, never a submodule's files. `ox check` lists the submodules a deploy fetches: when one points at a repository this account cannot read (a private enterprise edition under the vendor's account, like docmost's `apps/server/src/ee`) and the open-source build runs without it (the code loads it only when present, the Dockerfile copies no such folder), add its path to `[build] skip_submodules`, and its folder stays empty as in a plain `git clone`.
- **Expecting framework env vars** (`DEBUG`, `SECRET_KEY`, `ALLOWED_HOSTS`, `CORS_*`, `NODE_ENV`). ox never injects them; set them as yours.
- **Toolchains in `packages`** (`nodejs`, `npm`, `python3-pip`, `golang`, `rustc`, `cargo`). Refused; declare `[tools]` instead.
- **A `[tools]` name mise's registry lacks** (`monolith`) stops `ox check`. Write its GitHub download, `"github:y2z/monolith" = "latest"`, when its releases ship a Linux build, or install it in the build.
- **A `github:` tool's program name.** A release of one program also runs by the repository's name (`monolith` from `"github:y2z/monolith"`, whatever its file was called); an archive's programs keep their own names (`rg` from ripgrep).
- **`[tools] erlang`.** Refused for every app: mise would build Erlang/OTP from source on the server, which fails there. Write neither Erlang nor its packages: every app with Elixir (a `mix.exs` or `[tools] elixir`) gets Ubuntu's Erlang/OTP 27 from ox. `[tools] elixir`, when you set it, is 1.17 or later and allowed by `mix.exs`'s `elixir:` requirement (`"1.19"`, `"1.19.4"` or `"1.19.4-otp-27"`; ox installs the build for OTP 27); an older one, or an `-otp-NN` build for another OTP, stops `ox check`, and an app release that needs an older Erlang does not run on ox.
- **A `mix.lock` pin no Elixir 1.17 compiles** (ssl_verify_fun before 1.1.7: "can't find include lib public_key.hrl") stops `ox check`. Delete that one entry from `mix.lock` (the install fetches 1.1.7, which the app's deps allow), and keep the rest of the lock.
- **Legacy keys.** Keys of the old engine (`runtime`, `package_manager`, `[deploy]`, `[frontend]`, `writable_paths`, and the old array-of-table blocks for processes, domains, services, models, cron jobs, and apt sources) all fail, each with the ox1 key that replaces it. ox1 spells services `[services] postgres = {}`, domains `domains = ["example.com"]`, and cron `[cron] name = { schedule = "...", run = "..." }`.
- **`{port}` placeholders** from the old engine. Commands read `$PORT` from the shell.
- **Running a proxy of your own as the app** (Caddy, nginx, a Procfile router) to reach a streaming or websocket server. Give that server's worker `port = true` and `paths` instead; ox's proxy routes them.
- **Writing files next to the code at run time.** The app may write its release folder while it runs, but each deploy starts a new release from the commit, so a SQLite file, uploads, or logs at a relative path like `./data` are gone on the next deploy (a database file, `.db`, `.sqlite`, `.sqlite3` or `.db3`, the app wrote there stops the next deploy before anything changes, naming the file and the `[storage] keep` line to set; the run log notes other files the app made there). A PHP release (FrankenPHP or a package whose name holds `php`) stays read-only unless its process runs with `sandbox = "relaxed"` (which writes its whole home), since PHP runs any `.php` file under its web root: there such a write fails with `EROFS`. List a data folder in `[storage] keep` (a repo path like `backend/logs`): ox links it to a writable folder under `$OX_DATA_DIR` that survives releases, so the app keeps its relative path. A keep path ending in `.db`, `.sqlite`, `.sqlite3` or `.db3` (`keep = ["db.sqlite3"]`) is kept as a file with its `-wal`, `-shm` and `-journal`, so a SQLite app keeps its relative path too; when a folder stands there (a LevelDB folder named `cache.db`) it is kept and backed up as a folder. Without a `keep`, ox keeps a stock Django `db.sqlite3` (settings as `startproject` writes them, SQLite the one engine) and a Rails `storage` whose `config/database.yml` names every production database a plain `storage/<name>.sqlite3`; `ox check` shows the line as detected, and a written `keep` replaces it, so list that path there too. A path kept for the first time where the live release's app wrote is carried: the deploy stops the live release's processes, copies what the app wrote there (a database with its journals) into `$OX_DATA_DIR`, and starts the new release on it, so adding the line keeps what the app wrote, and the site is down until the new release starts; if that deploy fails, the old release starts again on its own files and the next deploy carries them again. A carry never deletes a file. A kept database file's old copy in `$OX_DATA_DIR` moves aside to `<name>.ox-before-<release id>`, with its journals. A kept folder's carry merges into the folder `$OX_DATA_DIR` holds: every file there stays, and only a file the carry replaces with a different one moves aside beside itself, as `<file>.ox-before-<release id>` (a database with its journals). The run log names both paths of every move. Nothing removes them: once the new release looks right, delete them with `ox exec <project> 'rm -r "$OX_DATA_DIR"/<path>.ox-before-<release id>*'`. A moved database is backed up as a SQLite file, and a moved file inside a kept folder with that folder; a plain file moved aside outside every kept folder is not backed up. A carry that would write through a link inside the kept folder fails the deploy, naming the link. A release that does not keep a database the live release keeps never goes live: a rollback to it, or a deploy of a commit whose `ox.toml` drops the keep (an older commit), is refused before anything stops, since the rows live in `$OX_DATA_DIR`, and the failure gives the `keep` line to put in that commit's `ox.toml`. A path named like a database with nothing in `$OX_DATA_DIR` yet is kept as a file; if the app makes a folder there (LevelDB's `x.db`) when it starts, that start fails before the switch with a message naming the keep (a worker, or an app that opens it on first use, goes live and fails then with its own error), and the fix is to commit an empty `x.db/.keep`, which makes ox keep it as a folder. Each deploy gives the kept folder every folder and every file the release ships there under a name it does not have yet (Laravel's `storage/framework/views`, default avatars, a seed `cities.json`): a name already there (a file, a folder, a link) is never replaced, so what the app wrote wins and a changed file in the repo does not update a kept copy; a seed file the app deleted comes back on the next deploy; a link the app made there is never followed; and a file the app wrote where the release has a folder fails the deploy. A link the release ships inside a kept folder is copied as a link, once; an absolute link into the release folder dangles once that release is pruned, so make it relative or leave it out. So code never lives in a kept folder: keep only the folders the app writes data to. Do not copy seed files in by hand. A server whose agent is from before this seeds folders only: ox sends the new agent on its own only after its canary stage, then to at most a quarter of servers per hour, so if a shipped file is missing from a kept folder, run `ox servers update <server>` and deploy again. A step that extracts into a kept path fails ("Invalid cross-device link" from GNU tar), because the kept path is a link out of the release: extract with `tar -C "$OX_DATA_DIR"`. The build sees each kept folder already made and empty (what an image build's `mkdir data` gives it), never the live data: drop a recipe's `mkdir` of a kept folder (a plain `mkdir` of it fails with "File exists"; `mkdir -p` is fine), and never expect the data in a build step. List a folder once: a kept folder inside another kept one is refused (`data` keeps `data/media`). A file at the repo root needs a folder of its own, except a database file, which is kept as a file. A Symfony app needs no line for `var/cache/prod/pools`, `var/log` (`var/logs` before Flex), `var/sessions` and the `APP_SHARE_DIR` its `.env` sets (7.4's `var/share`): ox gives every release writable ones of its own (they are not data, so they are not backed up, they start empty on each deploy, and a file log, a file session or a rate-limit count there ends with its release), and a `keep` of the folder or one that holds it (`var`) replaces that. A Dockerfile's `VOLUME` inside its `WORKDIR` names a folder the app writes at run time: keep it when it holds data (`public/uploads`), and leave it out when the build or the install fills it and the app rewrites it at start (NodeBB's `build`, `node_modules`): the app may write its release, and a kept folder starts empty in the build. An ASP.NET Core project whose `wwwroot` holds no file in the repo publishes none, and the app then has no web root to write its caches or uploads under: keep the publish folder's one (`out/wwwroot`).
- **Moving `$HOME` writes out of the way.** The app may write its home folder (`$HOME`) while it runs, except a PHP release (FrankenPHP or a package whose name holds `php`): a tool's `~/.gitconfig`, `~/.config` or `~/.local/share` (Gogs runs `git config --global` at every start) needs no `HOME`, `GIT_CONFIG_GLOBAL` or `XDG_*` of yours, and what it writes there survives deploys and reboots. The home is not backed up, so a database, uploads or anything else the app must not lose goes in a service, a SQLite file under `$OX_DATA_DIR`, or a `[storage] keep` folder. Never set `HOME` yourself: the build installs into the home what the app may look up there.
- **A file the app needs that git ignores.** ox builds the pushed commit, so a file the repo's `.gitignore` lists (Redmine's `config/database.yml` and `Gemfile.local`) reaches the server only if someone force-adds it, and a plain `git add .` leaves it out without a word. Write such a file from `[build] install` instead (`printf 'production:\n  adapter: postgresql\n  url: <%%= ENV["DATABASE_URL"] %%>\n' > config/database.yml` before `bundle install`), so ox.toml carries it.
- **Skipping a release step the repo runs with a `uses:` action.** ox reads a workflow's `run:` lines, not the actions it calls, so a file an action makes is missing on ox. `ox check` hints at each web folder git holds with only a `.gitignore`: find what fills it and make that in `[build]` (or `keep` it when the app writes it while running). Firefly III's release runs `ff3:json-translations v3` to write `public/v3/i18n/en-US.json`, one JSON per `resources/lang/<lang>` folder holding the keys `config/translations.php` lists under `json.v3` by file (`{"config": {"html_language": "en", ...}}`); without it the pages show raw keys and "DATE ERROR". A short `php -r` command in `[build]` writes it.
- **A `keep` path that is not where the app writes.** `keep` is the path from the repo root to the folder the app writes, not a label: a Django app writing `backend/logs/app.log` needs `keep = ["backend/logs"]`, and `keep = ["logs"]` makes a folder the app never sees. Check every relative path the app writes at run time (`MEDIA_ROOT`, log files, SQLite, upload dirs) against the list. A scratch database the app opens inside the repo while it runs counts too, even beside a postgres or MySQL service and even when the app deletes it at every start: Joplin Server opens `packages/server/temp/joplin.sqlite` to render notes, so it needs `keep = ["packages/server/temp"]`, or the next deploy is refused. Search the code for the `.sqlite`, `.sqlite3`, `.db` and `.db3` names it opens, and keep each folder they sit in.
- **Repeating the root lockfile's install in `[build] commands`.** The detected install already runs first; repeating it installs twice. Leave it to detection, or move it to `[build] install` when it is not the root lockfile's.
- **`bun --cwd web run build`.** bun reads `--cwd` before `run` as its own flag, prints its help, and exits 0, so the build "passes" and builds nothing. Write `bun run --cwd web build`.
- **A PHP app whose composer files refuse PHP 8.5.** ox serves PHP 8.5 only (FrankenPHP and Ubuntu's `php-cli`), and `composer install` refuses a `require.php` in composer.json or a locked package's that excludes it (`"php": "8.3.*"`). `ox check` stops on `build.install` and names each one. Prefer a release of the app that supports 8.5; otherwise declare the detected install with `--ignore-platform-req=php` added, and tell the operator the app may break on it.
- **A Caddyfile site that names a host.** A Caddyfile your start runs (the `--config` file of `caddy run` or `frankenphp run`, else `./Caddyfile` where it runs) answers only the hosts its site address names, and ox's proxy forwards the visitor's host, so `http://127.0.0.1:{$PORT}` answers every page with an empty 200 (Mautic's sign-in page came back blank): write the site as `:{$PORT}` with `bind 127.0.0.1` inside it, and `ox check` refuses the other.
- **An app that does not trust ox's proxy.** ox's proxy calls the app from 127.0.0.1 and sends `X-Forwarded-Proto`, `X-Forwarded-For` and `X-Forwarded-Host`. An app that does not trust it reads every https visit as http, and one that wants https or stores its own URL redirects in a loop. Trust 127.0.0.1 in the framework's own setting, where the code reads it: Symfony's `trusted_proxies` (Mautic reads it only from `config/local.php` or `config/parameters_local.php`, never from an env value, so write it there in `migrate`), Laravel's `TrustProxies`, ASP.NET's `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`, Express's `trust proxy`, Django's `SECURE_PROXY_SSL_HEADER` (gunicorn and uvicorn already trust loopback). A deploy whose health check loops fails and names this fix. An app that limits requests or sign-in attempts per address must read the visitor's address from `X-Forwarded-For` too (Vikunja's `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD = "xff"`): otherwise every visitor is 127.0.0.1, and one limit holds them all.
- **A test account the AI tester cannot sign in with.** Tested deploys sign in with accounts named by their variables (`ox tests <project> accounts set name=admin,role=admin,email-var=ADMIN_EMAIL,password-var=ADMIN_PASSWORD`), and a preview starts with an empty database, so the app must make that account from those variables on every deploy and do nothing when it is there: look the user up by its own email, or by its name for an app that signs in by user name, and never skip because some admin exists (Chatwoot's `migrate` makes its super admin only when no user has `CHATWOOT_ADMIN_EMAIL`). A new staging copy's first deploy already holds its own value for each account variable production keeps to itself or lacks (`ox staging <project> KEY...` asks for one, else ox makes it), so a seed that makes the admin only on first start works there; when staging started before such a variable got its value, `ox tests <project>` says so, and `ox staging <project> --remake --confirm <project>-staging` deletes its app and data and makes it again. An app that signs in by user name takes it through `email-var` too (linkding's `LD_SUPERUSER_NAME`); `email=` takes only a literal address. A sign-in helper (`ox tests <project> users COMMAND`) gets `OX_USER_NAME`, `OX_USER_EMAIL`, `OX_USER_PASSWORD` and `OX_USER_ROLE`, runs again on later runs and must exit 0 when the user is already there, and may print `OX_SIGNIN_URL=` and a sign-in link on the copy's own host as its last line, for an app that signs in by an emailed link or code. Keep every key a preview copies test-only (payment, mail): a copy's runs wait while it holds production's value of a key that looks like a credential.
- **Adding to a command what the repo never shows.** Copy a recipe's commands as they are written, and add a flag only when the command's own code or docs in the repo name it: Firefly III's `firefly-iii:upgrade-database` takes no `--force` (Laravel's `migrate` does), and the migrate fails on it. The same goes for versions: Bundler refuses one gem listed twice with different requirements, groups aside, so a `Gemfile.local` that adds a gem the `Gemfile` already names (Redmine's `puma`, in its test group) writes `gem 'puma'` with no version.
- **An installer that writes its own "installed" flag.** The release is read-only, so a flag the installer writes into `.env` at its end never lands: set it as a variable (Akaunting's `APP_INSTALLED=true`) and run the installer with it turned off inline (`APP_INSTALLED=false php artisan install ...`), since an install that sees it on refuses to create its user.
- **An installer you drive over HTTP from `migrate`.** It stores the address and scheme it was called with, so call it as the proxy would: `curl -H "Host: $PUBLIC_HOST" -H "X-Forwarded-Proto: $PUBLIC_SCHEME"`, with the app set to trust the proxy. nopCommerce's `/install`, posted to `http://127.0.0.1:<port>`, saved an http store on 127.0.0.1 and sent every https visit to http. curl with that `Host` drops a `Secure` cookie (an antiforgery or session cookie) and never sends it back, so the form fails with a 400: carry the cookies yourself, `-D headers.txt` on the GET, then `-H "Cookie: $(sed -n 's/^[Ss]et-[Cc]ookie: *\([^;]*\).*/\1/p' headers.txt | paste -sd';' -)"` on the POST, never `-c`/`-b`.
- **Copying a make target's lines.** When the repo has a make target or script for its build or install, run it (`make <target>`) rather than a copy of its steps, which dropped PhotoPrism's `mkdir -p` and failed on the next line.
- **A URL-parsing env processor on old Symfony.** Symfony 4's `%env(key:host:url:DATABASE_URL)%` fails at `cache:clear` (wallabag), so give each setting its own variable with a reference (`DATABASE_HOST=${postgres.host}`, `DATABASE_PORT=${postgres.port}`).
- **Trusting an env override the code never reads.** Before relying on an env value for the port, find where the code that listens reads it, and when it reads a config file (casdoor drops its `httpport` env when the file reloads), write the file at start into `$XDG_RUNTIME_DIR` with `$PORT` in it and point the app's config flag there: `start = "sed \"s/^httpport = .*/httpport = $PORT/\" conf/app.conf > \"$XDG_RUNTIME_DIR/app.conf\" && exec .ox/bin/app -config \"$XDG_RUNTIME_DIR/app.conf\""`.
- **Missing a program a make target or a repo script runs.** A recipe's base image carries programs the server does not have (`unzip` is not on it): when the build runs a `make` target or a repo script, read the target's recipe, the targets it depends on and each script they call, and list in `packages` every program they run that nothing in your plan installs (PhotoPrism's `make dep-models` runs `scripts/dist/download-models.sh`, which calls `unzip`, and the build stopped on "unzip: command not found").
- **Asking the operator for a file the recipe downloads.** A recipe that fetches a prebuilt file at a pinned URL (DocuSeal's Dockerfile downloads pdfium from a `pdfium-binaries` release, with a checksum) already gives you that URL: download it in `[build]` at the same pin, checking the checksum, and when the pinned asset is built for musl (Alpine), take the glibc one of the same release for the server's `uname -m`. Never make such a URL a variable: no operator knows it, and a guessed answer failed DocuSeal's build (round f4).
- **Running a Python package with a compiled part from its source folder.** `python -m` puts the current folder first on the path, so a command run from the repo root imports the source folder, which holds no compiled extension, instead of the copy the install built into `.venv`: Synapse's migrate failed on `No module named 'synapse.synapse_rust.events'` (round k). When `pyproject.toml` builds Rust or C code (`setuptools-rust`, `maturin`, a build script) and the install is not editable, run its `python -m` commands as `python -P -m` (Python 3.11 and later), which leaves the current folder off the path.
- **No way in.** Every app with accounts ends its deploy with an admin the operator can sign in with, made by the app's own command in `[build] migrate` (Pixelfed's `user:create`, Gogs' `admin create-user`, pretalx's `init`). Never leave it to an `ox exec` line for later, a first-visitor setup page, or open sign-up: six round f4 apps closed sign-up, made no admin, and no one could get in. Make it safe on every deploy: skip it when the admin exists (a guard that prints its answer, below) or take the app's own "already exists" error as done. Pass every value the command takes as a variable, the login too (Mautic's `mautic:install` also needs `--admin_username`), and generate the password by name (`ox vars set <project> --generate ADMIN_PASSWORD`). A command that makes up a random password and prints it (solidtime's `admin:user:create`) is no way in: set the password from the variable another way (the app's own user model, run through its console). Write the login in ox.vars as a value the app accepts, not a placeholder: an email when the sign-in page asks one (Label Studio's `LABEL_STUDIO_USERNAME`), a name the app does not reserve (Gogs refuses `admin`: write `owner`). An app that makes users only through its running server (Synapse's `register_new_matrix_user`) does it in the start: when the admin is missing, run the server in the background on a free port of its own on 127.0.0.1 (bind port 0 to get one), wait for it on that port, register the admin, stop that server, then `exec` the real one on `$PORT`. Never give that first server `$PORT`: ox's health check takes whatever answers there for the new release and moves traffic to it before the start stops it (Ghost's setup page was open to visitors for seconds, 2026-10-17). The 120s health wait covers both boots. An app that makes its first admin at boot from variables (Open WebUI's `WEBUI_ADMIN_EMAIL` and `WEBUI_ADMIN_PASSWORD`) then closes sign-up, so put both in `required_variables`: they are the only way in.
- **A preset that marks a setup wizard done.** It skips everything else the wizard makes. Read the wizard's handler and make each thing it creates in `migrate`, once, or drive the wizard itself: CTFd's `PRESET_CONFIGS` `setup` skips `/setup`, which creates the `index` page, so `/` answered 404.
- **Setting a gem path.** ox sets `BUNDLE_PATH=vendor/bundle`, so a plain `bundle install` lands in the release next to the `Gemfile`, where `bundle exec` finds it. No path setting is needed.
- **Precompiling a jsbundling app's assets without its bundle folder.** `jsbundling-rails` and `cssbundling-rails` write their bundles to `app/assets/builds`, and Rails lists only the asset folders that exist when `assets:precompile` boots it, so when git does not hold that folder the bundles are built but never precompiled and every page that loads one answers 500 (Forem's `/` and `/enter`). Keep the detected precompile, which starts with `mkdir -p app/assets/builds && `; `ox check` names a precompile that lacks it.
- **A server or a watcher in `[build]`.** Build steps run one at a time on a server: another project's (or a staging or preview copy's) step waits, and its run log says whose build it waits for. So `[build] install` and each of `commands` must end: one that runs past 3 hours is stopped, and the run fails saying so. Start a server or a watcher (`npm run dev`, `vite`, `--watch`) in `[app] start` or `[workers]`, never in `[build]`. `migrate` has no time limit, so a long data migration runs to its end, and other builds on the server wait for it.
- **Passing files between build steps through `/tmp`.** Each step (`install`, each of `commands`, `migrate`) has its own `/tmp`, emptied when it ends. Write what a later step needs inside the release (a relative path) instead.
- **A one-time setup in `migrate` behind a guard that can fail for another reason.** `migrate` runs on every deploy, and `if ! CHECK; then init; fi` reruns `init` whenever CHECK fails, not only when the setup is missing: pretalx's `shell -c` refuses to run without `--unsafe-disable-scopes`, so every redeploy reran `init` and failed on "That Email is already taken". Prefer a setup command the app makes safe to rerun. Otherwise make the guard print its answer and test the answer, so an error stops the deploy instead of rerunning the setup: `state=$(python -c '...; print("yes" if done else "no")') && if [ "$state" = no ]; then init; fi`.
- **Copying a hosting platform's build without its variables.** `netlify.toml`, `vercel.json` and the like run with the platform's own variables (Netlify's `NETLIFY` and `CONTEXT`, Vercel's `VERCEL_ENV`), which ox does not set, so code that branches on them takes the other branch on ox; read where the code reads them before you copy a command or a heap size from that file. Docusaurus's site builds one locale only when `NETLIFY` is set and `CONTEXT` is `branch-deploy` or `deploy-preview`, so on ox `yarn build:website` builds all five in one process, and Docusaurus's memory grows with each locale: it was killed at the third on a 16 GB server, also with ox's heap size (rounds f3 and k). A build with one `--locale` turns off the locale paths and writes that locale at the output root, so one process per locale left the last one at `/` and the other locales' pages 404 (round f4). Build two locales per process, the default and one other, and move each further one under the build: `docusaurus build --locale en --locale fr`, then for each other locale X `docusaurus build --locale en --locale X --out-dir .ox-X && rm -rf build/X && mv .ox-X/X build/X && rm -rf .ox-X`. Leave `NODE_OPTIONS` to ox, which sizes Node's heap to the build's memory.
- **Guessing `memory`.** A cap below a process's peak gets it OOM-killed in a loop, and a worker without `health` still deploys "successfully" (the crash-loop alert comes later): pretix's Celery worker, one child per core, died under a guessed `1G`. Leave `memory` out unless the repo's docs or compose file give a number. `[limits] memory` caps the builds as well: a guessed `1500M` OOM-killed Halo's Gradle and UI build, so leave `[limits]` out unless the operator asks for one.
- **A placeholder where the recipe has a working default.** When the recipe ships a value the app runs with (Element Web's `config.sample.json` points at the `matrix.org` homeserver), keep that value in ox.vars, or as the default in the step that reads the key (`${ELEMENT_HOMESERVER_URL:-https://matrix-client.matrix.org}`), and leave the key out of `required_variables`. A placeholder (`matrix.example.com`) or the app's own address leaves a client app with no server to sign in to.
- **Calling a store the recipe runs "external".** A store the production recipe runs beside the app (LobeHub's compose runs `rustfs` for S3, with a bucket made at start) is part of the app: LobeHub's file service throws without `S3_*`, and its first page hangs after sign-up. Run it as a custom `[services]` entry that `provides` the app's keys, make its bucket as the recipe does, and never answer those keys Not needed.
- **An empty value for a key the app does not need.** Mark it not needed (`unset KEY` in a vars file), never `KEY=`: an empty value replaces the app's own default (Dify's `ELASTICSEARCH_HOST`, 127.0.0.1 by default, became empty and its config refused to start). The other way round, an example-file key ox does not ask about stays unset, not at the example's value: when the app reads it with no default of its own (Apache Answer's `LANGUAGE` and `SITE_NAME`), set it as yours.
- **A whole `DATABASE_URL` where the app takes separate fields.** Synapse's `database.args` and other psycopg2 or libpq settings read `dbname` as a name, not a URL: give each field its reference (`${postgres.host}`, `${postgres.port}`, `${postgres.user}`, `${postgres.password}`, `${postgres.database}`).
- **A ref of another repo built from the app's version.** Before you set a tag or branch of another repository, check it exists with `git ls-remote --tags <url>` (a Jellyfin deploy asked for `v12.2.0`, built from a version string, where the tag is `v12.2`).
- **Dropping a step a later one reads.** Keep the recipe's `go mod download` when a later command reads a module's folder (`go list -m -f '{{.Dir}}'` is empty before it, and `ox check` refuses that order before a first deploy: new-api), and run a step that needs dev packages (Symfony's `cache:warmup` after `composer update`) before the `--no-dev` install, or install with them.
- **`build-essential` in `packages`.** Refused (it replaces the image's coreutils): list `gcc`, `g++`, `make` and `libc6-dev` instead, and add `make` or `cmake` whenever a command runs one. `ox check` stops on a command that runs `make`, `gcc`, `g++` or `cmake` with no package for it.
- **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 names relative to the repo root.

## Deploying with the ox CLI (for AI agents)

The `ox` binary is also the client of ox Cloud (`https://deploywithox.com`, compiled in: no flag points it elsewhere). Every command takes `--json` (stable snake_case fields) and exits non-zero on failure, so read JSON and check the exit code: 1 a failed run or a refused request, 2 a usage error, 3 not signed in (run `ox login`), 4 the plane could not be reached. With `--json` a failure also writes `{"error": "...", "code": "<kind>"}` to stderr.

The CLI does everything the console does, so an agent never needs the browser except for `ox login` and Connect GitHub (a GitHub page: `ox repos` prints its link when nothing is connected). A project is `name` or `server/name` (or `--server`). `ox <command> -h` says more.

- **Create, review, first deploy.**
  - `ox servers --json` lists servers; `ox servers add` prints the command a person runs on a fresh Ubuntu LTS VPS with sudo (it works once).
  - `ox repos --json` lists the repositories ox can read. `ox new owner/repo [--server S] --json` makes the project (a public https or an ssh URL works too) and prints its `name`.
  - `ox review <project> --json` says `ready` (exit 0), or what is missing (exit 1, code `not_ready`, the review still on stdout): `ask_start` (answer with `--start 'CMD'`, a command that serves HTTP on `$PORT`), `missing` variables (each with an `example` and `can_generate`), and `issues` to fix in `ox.toml`. An ssh repository shows a `deploy_key` a person adds to GitHub, read only.
  - Answer with `ox review <project> --start 'CMD' --generate SECRET_KEY --skip OPTIONAL_KEY --from-file .env.values --wait`: `--generate K` makes a random value, `--skip K` marks a key not needed, `--from-file F` (or `-` for stdin) carries the other values. It saves them and runs the first deploy. Then `ox review` again until it exits 0 with `ready` true, or deploy.
- **Deploy and watch.**
  - `ox deploy <project> --wait --json`: the run's log streams to stderr, `{"run": {...}}` goes to stdout, and the exit code is the run's result. On a failure, read `run.detail`, then `ox logs <project> --run <id>`. A run started without `--wait` (a preview, a saved setting) is watched the same way with `ox logs <project> --run <id> --follow`.
  - `ox status <project> --json`: live commit and release, URL, processes, last run; `ox status <project> --wait` follows the newest run to its end and exits with its result. `ox runs <project>` lists runs. `ox promote <project> --yes --wait` deploys production at the commit staging runs (printing the commits first): production keeps its own variables, data, and domains, and a key the new code needs must be set on production before. `ox crons <project> [--json]` lists the `[cron]` jobs with their next run and newest runs (start, end, exit code); `ox crons <project> run NAME` runs one now. `ox celery <project> <worker> [--json]` reads a Celery worker's app: workers online, running, held, and scheduled tasks, queue depths. `ox logs <project> [-n N] [--follow]` prints the processes' logs, redacted. To look back, `ox logs <project> --since 2h [--until T] [--grep TEXT | --regex RE] [--query Q] [--process P] [--severity warn] [-n N]` searches the server's journal (`--query` is the Logs explorer's language: words, "phrases", `level:error`, `unit:web`, `key:value`, `-` to negate), and `-o FILE` saves everything a range and search match (up to 100 MB). `ox metrics <project> [--range 1h|24h|7d|30d] [--json]` is the usage history: CPU and memory as min, average, and max, with the deploys and alerts in the range (what happened overnight).
  - `ox rollback <project> [--release ID] [--restore-variables] --wait` goes back to a kept release; `--restore-variables` also brings back the variables it ran with. `ox stop <project>` and `ox start <project>` switch it off and on (a stop also ends a cron run already going and gives the project's `[limits] memory` back to the server; `ox start` checks the room first and fails with `capacity` when another project took it, so stop a project whose `[limits] memory` is listed, delete one you do not need, or lower this one's `[limits] memory` and deploy; a rollback to a release with a larger `[limits] memory` is checked the same way); a stopped project stays stopped through deploys and restores until `ox start`: a deploy of it ends `left stopped; ox start <project> to run it`, and `ox status` says since when and by which run. If `ox stop` says the server's agent is older than this action, run `ox servers update <server>` first; if `ox status` says the project "was stopped before ox could hold it", run `ox stop <project>` again.
  - `ox exec <project> [--secret KEY]... "<command>" --json` runs one line of bash against the live release, as the app with its variables (a second admin a person asked for: `printf '%s\n' "$PW" | ox exec <project> --secret DJANGO_SUPERUSER_PASSWORD "python manage.py createsuperuser --noinput" --json`). It has no terminal and no stdin, so pass the command's non-interactive flag; it stops after 10 minutes. The log streams to stderr, `{"run": {...}}` with its `exit_code` to stdout, and ox exits 1 when the command failed. A secret goes in `--secret KEY` (its value one line per key on stdin, in the flags' order), never in the command: the audit log shows the command to every token that reads it. Needs a full access token. Use it for a one-off a person asked for; a step every deploy needs belongs in `[build] migrate` or `[build] commands`.
- **Variables and services.** A variable is a key, a value, and a source: `yours`, `provided` (ox's, locked), `inherited` (a copy's value equal to production's, whoever set it; on a copy `ox vars` prints it as `same as production` and any other value as `overridden`, and `--json` keeps the source names), or `needed` (the repo's `.env.example` asks, with an empty value, a secret or a placeholder, and nothing sets it). `ox vars <project> --json` lists them without values; `--reveal` shows values and is written to the audit log, so use it only when a person asks.
  - Change them with the verb first: `ox vars set <project> KEY [KEY...]` reads each value from standard input (one key: the value itself; several: `KEY=VALUE` lines), `ox vars set <project> --generate KEY` makes a random value in the key's format on the plane, `ox vars set <project> --from-file F` (or `-`) reads a `.env` file, `ox vars unset <project> KEY...`, `ox vars import <project> F --yes` merges a `.env` file. Never put `KEY=VALUE` on the command line: it is refused, because it would keep the secret in the shell history. Saving deploys; add `--no-deploy` to save only (the table says "not live" until the next deploy). A provided key is refused.
  - `ox vars pull <project> -o F` writes your values to a new 0600 file (never over one without `--force`), a key marked not needed as `unset KEY`, which import and `ox review <project> --from-file F` read back as not needed, and is audited like `--reveal`. `ox vars history <project>` lists saved versions, and `ox vars restore <project> VERSION --yes` brings one back. A staging copy or preview is a project of its own (`ox vars <project>-staging`); `ox vars sync <copy> [KEY...] --yes` takes production's values. `ox vars production-only <project> KEY...` keeps a live key (a payment or email provider's) out of new staging copies and previews, a new preview takes the staging copy's own value instead when it has one, sync never takes it, and a copy still holding production's value loses it and redeploys; `--off` undoes the mark. Suggest it when a person adds such a key to a project with previews on. A copy's row with `production` set is an inherited value that still names production's address: suggest the reference in `use` (`${PUBLIC_URL}` or `${PUBLIC_HOST}`), set in production so every environment resolves its own.
  - When `ox deploy` or a push's deploy is refused for missing variables, `ox vars <project> --json` lists them in `needed`; set them, then deploy.
  - `ox services <project> --json` gives each entry's type, mode, version, state, provided key names, and backups (the same list as `ox backups`, newest first).
- **Domains and copies.** `ox domains <project>` lists domains with their DNS state (`waiting` means the A record is not pointing at the server yet); `ox domains <project> add shop.example.com --wait` and `remove`. `ox staging <project> --wait` makes the staging copy; `ox staging <project> --branch B` on a project that has one makes it track B (the fix when its branch was merged and deleted) (`ox delete <project>-staging --confirm <project>-staging` removes it). `ox previews|autodeploy <project> [on|off]` show or set branch previews and deploy on every push; `ox autodeploy <project> checks on` deploys a push only once its GitHub checks pass (any CI that reports to GitHub; the project must come from the GitHub App, and `ox status` says why a commit waits); `ox previews <project>` (`--json`) lists every preview with its state, commit, memory, and a failed one's reason and fix, plus the branches waiting for a slot. All previews on a server share half its memory, so production keeps the rest; a preview that ran out says so, and the fix is to remove another preview, not to raise `[limits]`. Closing a pull request, merged or not, deletes its preview, and reopening it previews the branch again; the pull request's "View deployment" link goes inactive when the preview is deleted. `ox notifications` shows what reaches the account's email and each webhook endpoint; `ox notifications preset problems|everything|nothing [--channel email|ENDPOINT]` sets a whole channel (Only problems is the recommended default; Nothing turns email off and keeps its choices), and `ox notifications on|off EVENT... [--channel email|ENDPOINT]` single events (on email it also turns email on). `ox notifications --project P on|off EVENT...` gives one project its own email events and takes no `--channel`. EVENT is a type (`deploy.failed`) or, for an email event, its key (`deploy_failed`), everywhere. `ox rename <project> <new>` works only before the first deploy.
- **Backups and data.** `ox backups <project> --json` lists each entry's backups (`source` is what a restore and a download take; the `name` alone is refused); `ox backups <project> now <entry> --wait`; `ox backups <project> download <entry> <source> --output F`. Backups in the S3 bucket are age-encrypted (`.age`): `ox backups key > backup.key` prints the account's key (an audited reveal that needs a full access token; run it only when the owner asks, and never put the key in the repository or a log), and `age -d -i backup.key FILE.age > FILE` opens a downloaded copy. `ox restore <project> <entry> --from <source> --confirm <project> --wait`, or `--file dump.sql` to upload one (up to 95 MB). For staging or a preview add `--env` and confirm with that environment's own name (`ox restore shop postgres --env staging --from <source> --confirm shop-staging --wait`); `ox backups <project> --env staging` lists its backups. `ox data <project>` lists data of removed services and `ox data <project> drop <entry> --confirm <entry>` deletes it.
- **Look into a service.** `ox explore <project> <entry> --json` gives the overview of a postgres, redis, neo4j, qdrant, mysql, clickhouse, or meilisearch entry, a custom service ox recognizes, or a SQLite file (`sqlite:<path>`, as `ox services` lists it); `tables` or `keys [PATTERN]` list what it holds, `table NAME` or `key KEY` open one item, `activity` shows sessions or slow commands. `ox explore <project> <entry> query` reads one read-only query from stdin or `--from-file` (SQL, a redis read command, Cypher, or for qdrant `search|scroll|count COLLECTION` and a JSON body). Results can be partial: `truncated` and `next` (continue with `--cursor`) say so. Changes (`allow-changes`, then `change cancel|end-session|delete-key|set-ttl ... --confirm <entry>`, or for postgres `query --confirm <entry>`, which runs the statement read-write and commits it) alter data: only when a person asked.
- **Account.** `ox tokens` lists tokens with what each can do, and `ox tokens create --name N --access read|deploy|full` prints a new token once (keep it secret; give CI `deploy`, which deploys and rolls back and never sees a value; a call a token cannot make exits 1 naming the access it needs); `ox sessions` lists the browsers signed in to the account (id, browser, address, last active), `ox sessions revoke <id>` signs one out and `ox sessions revoke --others` signs out all but the caller (a token has no browser, so it signs out all); run them only when the owner asked. `ox audit [--project P] [--action A] [--page N] --json` reads the audit log, newest first (after a confirmed write, check its `explore allow changes`, `explore write` and `explore write committed` rows there); `ox settings --json` shows what is set, never a secret; `ox settings s3 ... --from-file F`, `ox settings huggingface set --from-file F`, and `ox settings preview-domain D` change it. `ox servers forget <server> --confirm <server>` needs a server with no projects; its agent then removes itself (leaving `/etc/ox/LEFT.txt`; what still runs there keeps running), or the answer gives the command for that server ending in `sudo ox leave`. `ox servers update <server>` needs a connected agent; if it says the server rolled the update back (also `rolled_back` in `ox servers --json`), the server runs its old build again and the reason says why, and if it says the plane did not answer in time, the update may still have finished: `ox servers --json` shows the agent's version. `ox servers check <server>` runs the server's checks; a `restart-needed` warning means Ubuntu installed a kernel or library fix that only loads after a restart (the server page says "Restart needed since ..."), and `ox servers restart <server> --yes` restarts it: the server and its projects are down for about a minute and come back on their own, it is refused while a job runs there, and an older agent says to run `ox servers update <server>` first. Run it only when the owner agrees: ox never restarts a server on its own, and has no Livepatch. `ox servers move <lost server> --to <server> --confirm <lost server>` moves every project of a server that is gone for good (offline, its disk lost) to another, connected server; run it only when the owner says the server will not come back. Then the owner adds each key in its `deploy_keys` on GitHub (the page is `add_at`), and each project's variables are added again (ox keeps no copy: `ox review <project> --from-file F --wait` sets the ones it needs and deploys; keep a copy with `ox vars pull`), and its data restored from the bucket. `ox account export [-o FILE]` downloads everything the plane holds about the account (no secret in it); `ox account delete --confirm <github-login>` deletes the account for good, so run it only when the owner asked: the apps keep running, an ox Pro subscription is cancelled first, and each server's agent removes itself (leaving `/etc/ox/LEFT.txt`) or the answer gives the command for that server ending in `sudo ox leave`. `sudo ox leave` on a server removes only the agent and keeps its apps; `sudo ox uninstall` removes everything ox created, the apps included. `ox plan --json` says whether the account is on Free (1 project) or ox Pro (unlimited), or `unlimited` on a plane that sells no plans (then there is no limit at all); on Free, the projects past the first are paused: they keep running, but a deploy is refused with "Deploys of X are paused: Free includes 1 project", which is the plan, not a broken app, and ox takes no daily backup of them and sends no alert about them (`ox backups <project> now <entry>` still works). Tell the owner, and `ox plan upgrade` prints the checkout link for them to open (`--yearly` for $80 a year instead of $10 a month); never pay for them. With `payment_failed`, ox Pro's last payment failed: the owner updates the card with `ox plan manage`, not a second upgrade. `ox plan cancel --yes` stops ox Pro at the end of the paid month or year (`ox plan keep` undoes it before then), or at once with `payment_failed`; run it only when the owner asked.
- **Tested deploys.** When the owner has them on, a push to a pull request starts an AI tester on its preview. Wait for it with `ox tests <project> run --branch B --commit SHA --wait --json`: exit 0 passed (checks marked okay included), 1 failed, 5 error, 6 skipped or superseded with no run after it, 7 the wait ended first, 8 refused. On 1, read `.checks[]` where `result` is `red` (`.error`, `.expected`, `.received`, `.step_line`, `.page_text`), save the pictures with `ox tests <project> artifacts RUN DIR`, fix the app, push and wait again. A deploy token reads runs and can run `again`, and cannot mark a check okay or skip a held commit; never do either on your own, since that is the owner's call.
- **Destructive commands need the name typed again.** `delete`, `restore`, `servers forget`, `servers move`, `webhooks remove`, `data drop`, `explore ... change`, and `account delete` refuse (exit 2) without `--confirm <name>`: the project's name, the server's, the endpoint's id, the entry's, or your GitHub login. Pass it only when a person asked for that action on that name; never to get past the refusal on your own.
- **Webhooks.** `ox webhooks` lists endpoints; `echo URL | ox webhooks add - --events deploy.failed,site.*` adds one (the signing secret is printed once); `test`, `deliveries`, `redeliver`, `rotate`, `enable`, `disable`, `remove ID --confirm ID`, `event-types`. `ox notifications` picks what each endpoint gets.
- **Secrets never go in a flag.** The S3 secret key, the Hugging Face token, and a webhook endpoint's address and header value are read from `--from-file F` or stdin (`-`); a flag with the value is refused. Do not echo them, and do not paste a printed token or webhook secret anywhere else.

A typical loop: edit `ox.toml`, run `ox check`, push (a push deploys on its own), or run `ox deploy <project> --wait --json`, and fix what the review or the run log says.

## Install this skill

Download it from ox Cloud into the agent's skills folder:

```bash
# Grok Build (all projects)
mkdir -p ~/.grok/skills/ox-manifest && curl -fsSL https://deploywithox.com/ox-skill.md -o ~/.grok/skills/ox-manifest/SKILL.md
# Claude Code (all projects)
mkdir -p ~/.claude/skills/ox-manifest && curl -fsSL https://deploywithox.com/ox-skill.md -o ~/.claude/skills/ox-manifest/SKILL.md
```

For a single project, `.grok/skills/ox-manifest/` or `.claude/skills/ox-manifest/` under the repo root works too; commit it so every agent working in that repo loads it.
