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, so git submodules are refused.
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.
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). 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 folder: `test`, `tests`, `e2e`, `playwright`, `cypress`, `spec`, `__tests__`, `testdata`). Every key there that ox does not provide must be set in the dashboard's Variables tab, and the deploy is refused until each one is set. 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)
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: TCP connect
port = 9034 # pin; default: allocated and recorded
memory = "512M" # this process's cap
sandbox = "relaxed" # may write anywhere in the project home; the only value; default: strict (writes only to its data dir and tool caches)
[static] # files served by Caddy
dir = "dist" # relative to the repo root
spa = true # unknown paths serve index.html
api = ["/api", "/admin"] # these prefixes go to [app]; requires [app]
paths = { "/static" = "backend/static" } # more built directories, each served at its URL path
[build] # runs as the project user with every variable but PORT; OX_RELEASE is the commit, CI=true
install = "npm ci" # 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
[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
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
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); 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
[limits] # the project's whole slice (processes + builds)
memory = "1G"
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. `port = true` on a worker allocates a port and exposes it as that worker's `$PORT`; a worker with `health` must have `port = true`.
- **Commands** run with `bash -euo pipefail -c` in the release directory, with the tools on `PATH`. `$PORT` and other variables expand in the shell. There are no `{port}` placeholders.
- **`packages`** are Ubuntu system libraries (`ffmpeg`, `libmagic1`). Toolchains (`nodejs`, `npm`, `python3-pip`, `golang*`, `rustc`, `cargo`) are refused with the `[tools]` line that replaces them.
- **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. `[static] dir` may be omitted when only `paths` is set. 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`, 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.
## 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`), `go.mod` `go`/`toolchain`, `rust-toolchain.toml` | `[tools]` versions |
| `.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, `[build] install` (frozen lockfile), `build` script, `start` script |
| `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`. 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/uvicorn when it is a dependency. It wins over a `package.json` beside it; add node and the frontend build yourself |
| `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) 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`. `gcc` and `libc6-dev` join `packages` when the code imports a cgo module (`github.com/mattn/go-sqlite3`). A root `package.json` that only builds assets (no start script, no web framework) or a `requirements.txt` that only installs docs tools does not hide it; add node and the frontend build yourself |
| `Cargo.toml` | `cargo build --release`, start the package's binary; `libssl-dev` and `pkg-config` join `packages` when `Cargo.lock` lists `openssl-sys`, with or without an ox.toml |
| 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` | migrate `prisma migrate deploy` |
| Next.js, Nuxt, SvelteKit, Astro, NestJS and other Node frameworks; Rails; Spring Boot, Quarkus, ASP.NET Core, Phoenix, Laravel, Symfony, Deno | the build and the built output's start (never a dev server); 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 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. 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` | `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, stopped for the copy |
| 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). `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"] }`.
- 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 entry that has a backup method.
## Variables
Three kinds, all managed in the dashboard's Variables tab:
- **Provided by ox**, on every deploy: `PORT` (per process), `HOST`, `PUBLIC_URL`, `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, any other key 32 random bytes, URL-safe; `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 folder: `test`, `tests`, `e2e`, `playwright`, `cypress`, `spec`, `__tests__`, `testdata`). A required key is satisfied by a provided key or a key of yours, even an empty one. Until then the deploy is refused with the list.
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 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"
[limits]
memory = "1500M"
```
## 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.
- **Git submodules.** Releases come from `git archive`; submodules are refused. Vendor the code or drop the submodule.
- **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.
- **A worker with `health` but no `port = true`.** Fails: the health check calls the worker's `$PORT`.
- **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 the release ships there (Laravel's `storage/framework/views`), never its files, so a file the app wrote where the release has a folder fails the deploy. A file at the repo root needs a folder of its own.
- **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`.
- **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.
- **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 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 stopped project stays stopped through deploys and restores until `ox start`. 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.
- **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 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 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, or mysql 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, a 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 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. Tell the owner, and `ox plan upgrade` prints the checkout link for them to open; never pay for them. With `payment_failed`, Pro's last payment failed: the owner updates the card with `ox plan manage`, not a second upgrade.
- **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.