ox docs: deploy a GitHub repo to your own Ubuntu VPS
Deploy a GitHub repo to your own Ubuntu server with ox, on systemd and Caddy, no Docker. Start with the quickstart, a framework guide or the ox.toml keys.
Last updated 2026-10-08
On this page
ox is a deployment platform: you connect a GitHub repo and ox deploys it to your own Ubuntu server with systemd and Caddy, no Docker. You run one command on the server, pick the repo in the dashboard, and every push deploys. A new release gets traffic only after its health check passes, so a failed deploy never reaches your visitors.
Start here
Pick the page that matches what you are doing now.
- QuickstartFive steps from a fresh server to a live app.
- ExamplesPublic repos on GitHub that ox deploys
- GuidesA complete, checked ox.toml for a specific kind of app.
- Config referenceEvery ox.toml key, what ox detects, services, and variables.
- TroubleshootingA failed health check, a failed build, or a domain that does not work.
- OperateLogs, rollbacks, backups, variables, domains, staging and previews.
- CLI referenceEvery ox command, with
--jsonfor scripts and agents. - SecurityWhat ox locks down on your server, and how to check it.
For AI agents
The Copy skill for AI agent button above copies the whole ox.toml authoring contract, so your coding agent can write and check the file for you. The same text is at /ox-skill.md.
Every docs page also comes as Markdown: add .md to its address, or press Copy page as Markdown. /llms.txt lists them all with a note each, and /llms-full.txt is every page and guide in one Markdown file.
---
name: ox-manifest
description: Author and review ox deploy manifests (ox.toml at the repo root) for the ox1 engine and validate them with `ox check` before pushing. Use when generating or editing ox.toml, fixing a validation error, or preparing a repo for a deploy from the ox dashboard.
---
# 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 a repo for a deploy from the ox dashboard.
## Workflow
1. **Inspect the repo first.** Read the files that drive detection: lockfiles, `package.json`, `pyproject.toml` / `requirements.txt` / `manage.py`, `go.mod`, `Cargo.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`. Its `apt-get install` lines name Debian packages: use the Ubuntu 26.04 name `ox check` suggests.
2. **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 1). `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.
3. **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. Install it with `curl -fsSL https://deploywithox.com/install.sh | sh` when it is not installed. These are the same checks preflight runs: a bad manifest or a missing variable stops the deploy before the host is touched.
4. **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`, or an address of the developer's machine like `localhost`) 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. 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.
5. **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)
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; the only value; default: strict (writes only to 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), CI=true
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)
[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); 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.
- **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. 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.
- **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, and a worker has no domain of its own: a process that wants a host name of its own (Logto's Admin Console) needs a second domain on the app.
- **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 with `bash -euo pipefail -c` in the release directory, 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); 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.
- **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. 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 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`, `.tool-versions`, `.nvmrc`, `.node-version`, `.python-version`, `.bun-version`, `package.json` `engines.node`/`packageManager`/`engines.pnpm` (or `yarn`, `bun`), `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 (a range like `>=18` takes ox's default when it allows it; a trailing `.*` or `.x` is dropped: `22.*` pins `22`) |
| `.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 |
| 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 Volta's single `yarn` or `pnpm` pin, names), `[build] install` (frozen lockfile, keeping `devDependencies` whatever `NODE_ENV` says: `npm ci --include=dev`, `pnpm install --frozen-lockfile --prod=false`, Yarn 1's `--production=false`), `build` script, `start` script. 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 runs no browser); a variable of the same name wins |
| a `package.json` beside a Go, Django, JVM, Elixir, .NET or PHP app | 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, also when the root `package.json` made npm's install the main one (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. 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 |
| `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` and `psycopg[c]` in the install add their headers to `packages` (`libldap-dev libsasl2-dev`, `libmysqlclient-dev pkg-config`, `libpcre2-dev` for uWSGI's `--route`, `libpq-dev`). 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). 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`, with or without an ox.toml |
| `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 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 (shared: an index per entry, no password) | 8 | `REDIS_URL` (private: carries its password) | private: files, while it runs |
| `neo4j` | private (shared: one instance, one admin for all) | 5 | `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` | private: 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; `port` on a shared entry fails validation. The default mode is recorded, so a project whose redis resolved shared before keeps shared.
- 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.
- Private instances start, and pass health, before migrate and the app, and restart only when their definition changes.
- `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).
- Preflight checks the whole host has room: each shared instance once, every private instance of every project, every `[limits] memory`.
- 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
Three 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 stays http until the next deploy), so build URLs from it and never write the scheme), `PUBLIC_HOST`, `<WORKER>_URL` for each `port = true` worker (`http://127.0.0.1:<port>`, name upper-cased, `-` becomes `_`), `OX_ENV` (`production`/`staging`/`preview`), `OX_PROJECT`, `OX_RELEASE`, `OX_DATA_DIR`, the service keys above, and `HF_HOME` with `[models]`. Locked in the UI; copy them out when a command needs one.
- **Yours**: API keys and feature config, typed by the operator. Values 6 characters or longer are redacted in run logs. 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), any other key 48 random bytes, URL-safe (64 characters); `ox check` names the format next to each key to set. 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 password): ask the operator for those, never `--generate` them. 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"), or a placeholder; 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. 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.
## 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
- **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 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.
- **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.
- **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 release directory is read-only to the app (strict sandbox): a SQLite file, uploads, or logs at a relative path like `./data` fail with `EROFS`. List the 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. 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. A Rails app needs no line for `tmp/`, nor a Symfony app for `var/cache/prod/pools`, `var/log`, `var/sessions` and the `APP_SHARE_DIR` its `.env` sets (7.4's `var/share`), nor a Phoenix release for the `priv` folder of `tzdata` or `tz`: 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 (`tmp`, `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 set `[app] sandbox = "relaxed"` when the build or the install fills it and the app rewrites it at start (NodeBB's `build`, `node_modules`), since 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`).
- **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.
- **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.
- **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.
- **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.
- **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.
- **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 60 minutes 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`.
- **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.
- **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.
- `ox login` prints a code and a URL on stderr; a person approves it in the browser. The token lands in `~/.config/ox/credentials` (0600) and lasts 90 days. Never ask for or print the token.
- **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); 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 Django app's first admin: `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 still equal to production's), 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`) 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, 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 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 takes); `ox backups <project> now <entry> --wait`; `ox backups <project> download <entry> <source> --output F`. `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 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 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.
- **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.