Config reference
The deploy config is a file named ox.toml at your repo root. Commit it, push, press Deploy. Every deploy validates it first: a missing or invalid file halts the deployment before anything on your server changes, with the exact reason shown on the run.
Unknown keys are errors. Secrets never live in this file; use the project's Environment editor.
runtime = "python" # executable|python|node|bun|go|rust
package_manager = "uv" # ""|uv|pip|npm|pnpm|yarn|bun
port = 9010 # 1-65535
required_packages = ["npm"]
writable_paths = ["/srv/ox/myapp/media"]
[[processes]]
name = "web"
command = "uv run gunicorn config.wsgi:application --port {port}"
[deploy]
install = ["uv", "sync", "--frozen"]
build = ["uv", "run", "python", "manage.py", "collectstatic", "--noinput"]
migrate = ["uv", "run", "python", "manage.py", "migrate", "--noinput"]
health_url = "http://127.0.0.1:9010/health"
Validate locally
Run ox validate [path] (default ./ox.toml) on your local machine before pushing. It runs the exact same strict validation checks that the deploy gate applies, catching syntax errors and unknown fields locally instead of halting a production deployment. Like nginx -t: exit 0 means valid; exit 1 prints the error with line number and reason.
Validate your deployment config
Run ox validate at your repository root to verify your ox.toml:
$ ox validate
ox.toml: manifest is valid
$ ox validate
ox validate: ox.toml: unknown project field typo_key
Services from the catalog
Postgres, Redis, MySQL, MongoDB, Neo4j, RabbitMQ, Memcached, ClickHouse, Qdrant, Meilisearch, NATS, Mailpit, LiveKit, Celery (+ beat), Flower, Prometheus, Ollama, SQLite tooling — every service is one [[services]] block. The catalog entry owns the mechanism: packages, apt sources, units, config files, credentials, ports. Each entry carries its tested_on matrix — the Ubuntu releases a real verification run covered on a disposable VPS — and the exact script that ran. Browse the services catalog or run ox catalog list / ox catalog show <id>.
[[services]]
id = "postgres@17" # pinned variant; a bare name picks the default
[services.args]
extensions = ["vector"]
[[services]]
id = "celery@5"
[services.args]
app = "config"
workers = 4
queues = ["default", "mail"]
Generated credentials and URL keys (DATABASE_URL stays env-driven, MYSQL_URL, AMQP_URL, NEO4J_PASSWORD, QDRANT_URL, ...) land in the project's environment automatically — never pin them in the dashboard editor or the repo's .env files. Host-shared services keep their fixed ports and slice per-project resources; per-project services run as sandboxed units with ports allocated from 9100 upward.
Models from Hugging Face
Declare the models your app needs; every deploy pulls them into the project's model cache, authorized by the Hugging Face token you paste once in Settings (gated models fail with the settings fix when no token is set). The environment gains HF_HOME, so from_pretrained("BAAI/bge-m3") and friends resolve from the cache without path knowledge.
[[hf-models]]
id = "BAAI/bge-m3"
revision = "main" # optional pin (branch, tag, or sha)
Writable paths are project-relative in the same spirit: writable_paths = ["media"] resolves against the project's base directory — host internals never need to appear in a manifest.
Deploy examples by project type
Twenty-one public reference apps, each tested working end-to-end with domain TLS. Pick the closest match to your stack, copy its ox.toml, swap in your values. Each repo has a walkthrough README and a .env.example.
- JavaScript apps · Next.js, React + Vite (pnpm), Vue + Vite, SvelteKit, React + Vite (yarn), Bun + Vue, Web3 dApp
- Python apps · Django + React, Django + Vue, Flask + Svelte, FastAPI + React, Django + Celery + Redis + Postgres
- Go, Rust, PHP, Ruby · Go + React, Rust + React, Go to WebAssembly, PHP + Vue, Ruby + React
- Workers and cron · Python worker, Go worker, Bun worker, Cron jobs
Every app prints hello world <project>_<GREETING_TAG> from backend runtime env and frontend build-time env, so env handling is verifiable on every deploy. Workers and cron jobs print it to the journal.
Agent instructions
The Copy skill for AI agent button at the top copies the bundled ox.toml skill below for your coding agent. It is the full authoring contract: schema, rules, and examples.
---
name: ox-manifest
description: Author and review ox deploy manifests (ox.toml at the repo root) and validate them with `ox validate` 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: processes, packages, domains, hooks. No LLM runs at deploy time. Deploys happen from the ox dashboard after a git push.
## Workflow
0. **Ask the user where they want this project deployed.** Before writing anything, ask: "Which domain should serve it, or is it internal-only?" Use their answer plus these defaults:
- **Public web app**: one `[[domains]]` entry pointing at their domain (`tls = true`, certbot). No domain yet → claim the host's public IP with `tls = false`; if the plane itself sits behind a hostname, IPs are not claimable, so push for a subdomain like `shop.plane.example.com` instead.
- **API-only or internal service**: omit `[[domains]]`; the process still binds `127.0.0.1:<port>` and tunnels/later edits stay possible.
- **Domain-facing process**: default to the single web entry (`process = "web"`). Static SPA frontends get `spa = true` plus `[frontend].dist`.
- **Port**: leave `port = 0` (or omit it): ox picks the lowest free port from 9010 upward and remembers it across deploys, so ports can never clash. Set an explicit port only when something outside ox must know it up front; a port another project already claims fails the deploy at plan time naming both projects.
- **TLS**: on for real domains, off for IPs (certbot cannot sign raw IPs).
1. **Write** `ox.toml` at the repo root. One file per app, reviewed like code. Run `ox catalog list` (or read the plane's `/docs/services-catalog` page) first: every service the app needs — postgres, redis, celery, qdrant, neo4j, ... — is one `[[services]]` block whose id comes from the catalog; never hand-declare a catalogued service's packages, units, or apt sources. `ox catalog show <id>` prints the args schema and a copyable block. The project name is generated from the repo basename (repo `oxzoo-django-celery` → project `oxzoo-django-celery`; there is no name input), so omit `name`/`source` and write `writable_paths` as project-relative paths (`"shared/media"`); ox resolves them against the project's base dir — never write `/srv/ox/...` in a manifest (host internals are ours, absolute paths stay allowed for genuine host paths like `/var/lib/app/data`). Start from the schema below, the closest pattern in `examples/projects/`, and the matching deployed reference app in `docs/examples/` (one public repo per project type under `github.com/saurav-codes/oxzoo-*`).
2. **Validate**: `ox validate` (path defaults to `./ox.toml`, or pass one). Exit 0 means valid; exit 1 prints the reason to stderr. One run reports **every** violation at once, so fix them all before re-running. These are the same strict rules the deploy gate applies, so fix every error before pushing. Structural feedback without the binary: `ox schema` prints the manifest's JSON Schema (also committed at `schema/ox.toml.schema.json`; Taplo picks it up with `#:schema schema/ox.toml.schema.json` at the top of the file).
3. **Push** the branch.
4. **Deploy**: press Deploy in the ox dashboard, watch the live logs, roll back from the dashboard if needed. A failed activation or health check names its cause next to the failing service: the error carries the process unit's journal tail and one classified hint (port in use, a missing binary with the runtime/required_packages fix, permission denied with the writable_paths fix, or connection refused with the depends_on fix (a process name or a [[services]] instance)), so read the error before the logs.
5. **After everything is done, tell the user to update the DNS record for the domain.** The run's last gate curls the public URL from the host, and certbot needs `tls = true` domains to resolve, so a domain that does not point at the host fails the deploy at the `public url` step. Close out by telling the user: "Point `<domain>` at `<host public IP>` with an A record", then redeploy if that gate failed. Skip this for internal-only projects. A `/etc/hosts` entry on the host passes the gate but leaves the URL unreachable for visitors.
## Detect the stack from the repo
Read the repo first; its files name the stack. There is no `ox init` detector on purpose: this table is the detector.
| Repo files | `runtime` | `package_manager` | Notes |
|---|---|---|---|
| `pyproject.toml`, `requirements*.txt`, `manage.py` | `python` | `uv` (`pip` only when the repo has no pyproject and the flow is a plain venv) | Django: `web` = `uv run gunicorn config.wsgi --port {port}` + `collectstatic` in `build`; celery in deps → `worker` (+ `beat`) processes |
| `package.json` + `bun.lockb`/`bun.lock` | `bun` | `bun` | Commands use the full path `$HOME/.bun/bin/bun` (hook PATH is the fixed system baseline, `~/.bun/bin` is not on it); first install hook line runs the curl\|bash installer |
| `package.json` (any other lockfile) | `node` | `npm` (`pnpm`/`yarn` when their lockfile is present) | `pnpm`/`yarn` have no apt package: the first install hook installs them, e.g. `npm install -g pnpm`, then `pnpm install --frozen-lockfile` |
| `go.mod` | `go` | — | `build = ["go build -o server ./cmd/server"]` |
| `Cargo.toml` | `rust` | — | `build = ["cargo build --release"]` |
| none of these; a checked-in binary or `Dockerfile` only | `executable` | — | `command` runs the binary as committed |
Signals beyond the language: `vite.config.*`/`next.config.*`/`angular.json` plus a separate backend → `spa = true` domain + `[frontend]` (`dist`, `api_paths`); `celery` in deps → a `[[services]]` block for `celery@5` (and `celery-beat@5` when the app registers a schedule) plus its broker service (`redis` or `rabbitmq`), never a hand-written worker process; `DATABASE_URL`/`pgvector` in code or deps → a `[[services]]` block for `postgres` with `extensions = ["vector"]` in its args (the pgvector apt package installs automatically); any other service the app connects to (qdrant, meilisearch, mysql, mongodb, neo4j, memcached, clickhouse, nats, minio-style S3, mailpit, livekit, flower, prometheus, sqlite tooling) → check `ox catalog list` first and declare it as a service; a service genuinely absent from the catalog is a gap: request the entry (or add one with `ox catalog add`) instead of hand-declaring its packages and units — nothing else installs or enables a daemon.
`runtime` also implies the runtime's apt packages, so never hand-declare them: `node` → `nodejs`, `python` → `python3` (+ `uv` installed automatically), `go` → `golang-go`, `rust` → `cargo`. Declare only what the app itself needs beyond the runtime (e.g. `ffmpeg`, `libpq-dev`, NodeSource for a pinned node).
## Install this skill
From the ox repo root, copy the directory into the agent's skills folder:
```bash
# Grok Build (all projects)
mkdir -p ~/.grok/skills && cp -r skills/ox-manifest ~/.grok/skills/
# Claude Code (all projects)
mkdir -p ~/.claude/skills && cp -r skills/ox-manifest ~/.claude/skills/
```
For a single project, `.grok/skills/` or `.claude/skills/` under the repo root works too; commit it so every agent working in that repo loads it.
## Schema (TOML, strict; unknown fields fail validate)
Copy this shape; every block is optional except the header and one `[[processes]]` entry. In a repo `ox.toml`, `name` and `source` are optional (the plane injects both, generating the project name from the clone URL's repo basename; a declared `name` must match the generated one).
```toml
name = "shop" # optional in repo ox.toml; the project name is generated from the repo basename (repo oxzoo-django-celery → project oxzoo-django-celery); must match the generated name when set
source = "git@github.com:org/shop.git" # optional in repo ox.toml; the project's clone URL wins
branch = "main"
runtime = "python" # executable|python|node|bun|go|rust; implies the runtime's apt packages (node→nodejs, python→python3, go→golang-go, rust→cargo)
package_manager = "uv" # ""|uv|pip|npm|pnpm|yarn|bun
port = 0 # 0 (or omitted) = ox picks a free port from 9010 upward and remembers it; set an explicit port only when something else must know it
required_packages = ["nodejs"] # apt-resolvable names, optionally pinned name=version; `nodejs` not `npm` (NodeSource node bundles npm; Ubuntu's npm package conflicts with it)
writable_paths = ["media", "shared/cache"] # project-relative ReadWritePaths beyond the defaults; ox resolves them against the project base (absolute host paths also allowed)
[[hf-models]] # Hugging Face models, pulled every deploy into the project's model cache
id = "BAAI/bge-m3" # HF repo id (org/name or bare name)
revision = "main" # optional pin (branch, tag, or sha); omit for latest
# The operator's HF token (Settings page) authorizes gated pulls; the app
# reads its models through HF_HOME, so from_pretrained() just works.
[[processes]]
name = "web"
command = "uv run gunicorn config.wsgi:application --port {port}" # {port} resolves to this process's port, else the project [port]
port = 0 # optional per-process override; 0 or omitted follows the project [port]
depends_on = ["redis", "worker"] # process names or [[services]] instance names; unknown refs fail validate
pre_start = ["mkdir -p /srv/ox/shop/media"] # ExecStartPre, shell-wrapped like command
readiness = "http://127.0.0.1:{port}/health" # deploy polls this http(s) or tcp://host:port target before continuing
restart_policy = "always" # systemd Restart= value; default on-failure
memory_max_mb = 512 # optional per-process overrides of [limits]: memory_high_mb|memory_max_mb|cpu_percent
[limits]
memory_high_mb = 400
memory_max_mb = 512
cpu_percent = 150 # systemd CPUQuota: 100 = one full core, 150 = 1.5 cores
tasks_max = 200
open_files = 65535
[deploy]
install = ["uv", "sync", "--frozen"]
build = ["uv", "run", "python", "manage.py", "collectstatic", "--noinput"]
pre_deploy = ["./scripts/notify.sh"] # runs after the release is built, before switching traffic
migrate = ["uv", "run", "python", "manage.py", "migrate", "--noinput"]
post_deploy = ["./scripts/warm-cache.sh"] # runs after the new release is live
health_url = "http://127.0.0.1:{port}/health" # {port} resolves like everywhere else
skip_migration_snapshot = false # true skips the pre-migrate database dump taken when migrate runs against a local postgres
cleanup = ["npm uninstall -g pnpm"] # runs once when the project is deleted from the dashboard, as the project user in the current release
[frontend]
dist = "dist" # built SPA assets, relative to the release root
api_paths = ["/api"] # URL prefixes that stay proxied to the app
[[frontend.static]]
prefix = "/static" # URL prefix served from disk, with hard 404s
dist = "staticfiles" # directory relative to the release root
[database]
stale_policy = "backup-then-drop" # backup-then-drop|keep; omitted = default backup-then-drop
[secrets] # credential files, names only; values never live in the manifest
files = ["db_password", "s3_key"] # each name mounts into every process unit via LoadCredential=
[[domains]]
name = "shop.example.com"
tls = true
websockets = false
rate_limit = 0
spa = true # serve dist/ statically instead of proxying everything
process = "web" # optional; proxies this declared process's port
[[services]] # catalog services: everything comes from the entry
id = "postgres@17" # pinned variant; a bare name ("postgres") picks the default variant
# name = "db" # optional instance name; defaults to the entry name
[services.args] # entry-specific, schema-checked (ox catalog show postgres@17)
extensions = ["vector"]
[[services]] # a second instance: one block per instance
id = "celery@5"
[services.args]
app = "config" # the celery app module
workers = 4
queues = ["default", "mail"]
[[cron_jobs]] # rendered as a systemd oneshot service + timer
name = "nightly-prune"
schedule = "0 3 * * *" # strict 5-field crontab; no @macros
command = "uv run python manage.py prune_carts"
[[apt_sources]] # external repo (PGDG, NodeSource); https only
name = "pgdg"
url = "https://apt.postgresql.org/pub/repos/apt"
suite = "noble-pgdg"
components = ["main"]
key_url = "https://www.postgresql.org/media/keys/ACCC4CF8.asc"
```
Golden manifest exercising every block: `examples/projects/remote-full.toml`. Mirror its shape when in doubt.
## Rules that make a spec valid
Check each rule before pushing. A violation is a hard error, never warn-and-skip.
- **Packages**: literal apt-resolvable names, optionally `name=version` (a pin: apt installs exactly that version and the deploy verifies it after install — drift fails naming both versions and what apt can install). `runtime` implies the runtime's packages (node→nodejs, python→python3, go→golang-go, rust→cargo; a declared pin of the same name wins over the inferred bare name), and `python`/`package_manager = "uv"` install uv automatically, so declare none of those by hand. Curated ids (`git`, `python3`, `uv`, `go`, `postgresql`, `redis`, `nginx`, `ufw`, `lynis`, `lnav`, `certbot`, `sqlite3`) resolve deterministically at deploy; other names must pass `apt-get -s install` on the host, after `apt-get update`. Unresolvable name: the deploy aborts with a fix suggestion. Declare `nodejs`, never `npm`: the host's NodeSource node bundles npm, and Ubuntu's separate npm package conflicts with it. Deleting the project removes with apt whichever of these no other registered project still requires (host primitives stay; a dry-run guard refuses the removal when apt would take a reverse dependency with it), so keep the list to what only this app needs.
- **Every command's binary must be installable** (binary lint, checked by `ox validate`): the first token of every process `command`, `pre_start`, cron `command`, and deploy hook must be provided by `runtime`, `package_manager`, `required_packages`, or an earlier install hook. `node`/`npm`/`npx` need nodejs or npm; `python`/`pip` need the python3 set; `go` needs go; `cargo` needs cargo; `uv` needs runtime python or package_manager uv. `bun`/`pnpm`/`yarn` have no apt package: they pass only when an install-hook line installs them first (`npm install -g pnpm`), and bun additionally uses the full path `$HOME/.bun/bin/bun` because hook PATH is the fixed system baseline. Tokens containing `/` and unknown binaries are never checked, so the lint cannot false-positive. The error names the binary, the location, and the fix.
- **[[hf-models]] pulls the Hugging Face way**: one block per model (`id`, optional `revision`); every deploy runs `hf download` into the project's model cache as the project user, with the operator's token from the plane's Settings when one is set. The env gains `HF_HOME`, so `from_pretrained("BAAI/bge-m3")` and friends resolve from the cache without path knowledge. Gated models without a token fail the deploy with the settings fix.
- **No hand-declared daemons**: `[[services]]` is the only way a service reaches the host — the catalog entry installs, enables, and converges it (the neo4j entry owns its credential sync; declaring the service is all the manifest does). A manifest declaring a catalogued service's packages or units by hand is a bug.
- **[[services]]: the catalog owns the mechanism.** One block per instance; `id` is a catalog entry id (`postgres@17`, or the bare `postgres` for its default variant — the resolution is recorded, so it never changes silently); optional `name` overrides the instance name (used in unit names and env keys); `args` are typed per entry (`ox catalog show <id>` prints the schema) and unknown args fail validate with the accepted list. Generated credentials and URL keys (MYSQL_URL, AMQP_URL, NEO4J_PASSWORD, QDRANT_URL, ...) land in the project env automatically: never set an entry's `provides` keys by hand — in the dashboard env editor or the repo's `.env`/`.env.example` — a user-provided value wins and silently desyncs the service from its env. Host-shared services (postgres, redis, mysql, ...) keep their fixed ports and slice per-project resources from the env; per-project services (qdrant, meilisearch, nats, mailpit, livekit, flower) run as sandboxed units under the project with ports allocated from 9100 upward, and their data lives under `/srv/ox/<project>/services/<service>/` (no `writable_paths` needed). Declaring a service that shares a unit with an already-installed variant (postgres@16 beside an installed 17) converges the active unit instead of installing side by side. `tested_on` on the catalog page names the Ubuntu releases each variant was verified on; a deploy on an unverified release warns loudly but proceeds.
- **Cleanup hooks** (`[deploy].cleanup`): the author's uninstaller for anything the install hooks put outside the release (global tools, `curl | bash` installs). They run once at project delete, as the project user, in the current release, and their failures are reported but never block the delete. Declare them whenever `install` writes outside the release directory. They cannot remove paths directly under the project's base dir (`/srv/ox/<name>`): the base dir is root-owned, so `rm -rf $HOME/<anything>` fails with permission denied — the delete removes the base dir's contents itself; hooks only need to clean paths outside the project's own tree.
- **writable_paths are project-relative**: the project user's HOME is the base dir `/srv/ox/<name>`, and ox keeps that base dir root-owned (`root:ox 0755`); relative paths resolve against it (never write `/srv/ox` in a manifest). A hook cannot create anything directly under it, so `mkdir $HOME/.bun` or `mkdir /srv/ox/<name>/shared/...` fails at deploy with `Permission denied`. Declare every such path in `writable_paths` (e.g. `["/srv/ox/shop/media", "/srv/ox/shop/shared/logs"]`); ox provisions them 2775, owned by the project user, before hooks run. `releases/`, `repository.git/`, `.ssh/`, `.cache/`, and `.local/` are already pre-provisioned. A path under `/srv/ox` must stay under this project's own base dir: a typo like `["/srv/ox/shopapp/media"]` for project `shop-app` fails validate naming the path and the base dir, instead of provisioning a tree no project owns. The rule fires when the project name is known (a declared `name`, or the deploy gate, where the plane injects the generated name); a nameless manifest under local `ox validate` skips the rule. Paths outside `/srv/ox` (e.g. `/var/lib/app/data`) are allowed and removed with the project.
- **[[apt_sources]]**: escape hatch for non-distro versions (PGDG, NodeSource). https-only URL and key URL, literal suite, at least 1 component; rendered to `/etc/apt/sources.list.d/ox-<name>.sources`. Runs before packages in every deploy, so pin through it.
- **Processes bind 127.0.0.1** (behind nginx); FRONTEND_URL/CORS land on `port+1` for JS frontends (`https://<domain>` once a TLS domain is set).
- **[[domains]] is explicit**: fields are `name`, `tls`, `websockets`, `rate_limit`, `spa`, `process`. `process` names a declared `[[processes]]` entry; unknown references fail validate.
- **SPA domains** (`spa = true`): serve `[frontend].dist` from the current release with `try_files $uri $uri/ /index.html`. Only `[frontend].api_paths` prefixes keep proxying (e.g. `["/api", "/admin"]` for a Django admin). `[[frontend.static]]` mounts serve a release-relative dir with hard 404s (e.g. Django `collectstatic` output). Validated: `dist` is a clean relative path (no `..`, no whitespace); prefixes are absolute clean paths without `.`/`..`, trailing slash, duplicates, api/static collisions, or `/.well-known/*`. `spa = true` without `[frontend].dist` fails validate. At deploy, `www-data` joins the `ox` group to read group-shared releases; `build` hooks must produce `dist` and every static mount.
- **No inline env values**: secrets live in the project's env file on the host (the dashboard Environment editor); validation rejects inline ones. Never commit real secrets to git. In repos, provide `.env.example` with placeholders (e.g. `DATABASE_URL=`, `STRIPE_KEY=`). Real secrets belong in the ox dashboard Environment editor.
- **[database].stale_policy**: `backup-then-drop` (default, also when the block is omitted) dumps before dropping; `keep` leaves databases untouched. Only these two values validate.
- **[secrets] are names only**: `files` lists credential file names (`^[a-z0-9_-]{1,64}$`, no paths, no duplicates). The operator writes the values to `/srv/ox/<project>/secrets/<name>` out-of-band; deploy only ensures that directory exists (0750 root:ox) and errors while a declared file is missing; it never writes secret content. Every process unit gains `LoadCredential=<name>:/srv/ox/<project>/secrets/<name>`; the process reads the secret read-only from `$CREDENTIALS_DIRECTORY/<name>`, never from the host path.
- **[[processes]] refs**: `depends_on` accepts process names or [[services]] instance names, rendered as `After=` behind the service's unit (the packaged unit for host-shared services, `ox-<project>-<service>` for per-project ones); unknown refs fail validate. `readiness` = http(s) URL or `tcp://host:port`; `{port}` resolves process port before project port. `port`, `memory_high_mb`/`memory_max_mb`/`cpu_percent` override the project values for this unit.
- **[[cron_jobs]]**: `schedule` is strict 5-field; macros and day names fail validate. Removing an entry deletes its units on the next deploy (same reconcile rule as processes).
- **The env names the postgres database with `DATABASE_URL` alone, the service args carry the extensions**: a repo `.env` or the dashboard editor both count; leave it empty or a placeholder and ox provisions a local database named after the project (`-` → `_`). Split `DATABASE_*` keys are the app's own ordinary env and ox never reads or writes them. Extensions come from the `postgres` service's args (`extensions = ["vector"]`) and their apt packages install automatically (`vector` → the matching `postgresql-<major>-pgvector`, `postgis` → `postgresql-<major>-postgis-3`), resolved against the installed major.
- **No `[host]` block exists**: deploys always run on the ox host itself; a `[host]` key fails strict decoding as an unknown field.
## Patterns (examples/ is the golden reference)
Pick the closest pattern; copy the file; adjust name/port/commands.
| Pattern | Processes | Source in repo |
|---|---|---|
| Node/bun API | one `web` process per entry point | `examples/projects/node-express.toml`, `bun-hono.toml` |
| Vite SPA (+ API) | `web` for the API; nginx serves `dist/` (`spa = true`) | `examples/projects/vite-spa.toml` |
| Python Django | `web` (+ `worker` and `beat` when celery) | `examples/projects/django-celery.toml` (includes `[[apt_sources]]`) |
| Python FastAPI | one `web` | `examples/projects/fastapi-uv.toml` |
| Go binary | `web` built from `go build` | `examples/projects/go-service.toml` |
| Rust binary | one `web` | `examples/projects/rust-actix.toml` |
| Every block at once | pins + tcp readiness + `[[apt_sources]]` | `examples/projects/remote-full.toml` |
End-to-end tested reference apps for these patterns live in `docs/examples/`: one public repo per project type under `github.com/saurav-codes/oxzoo-*`, each with a working `ox.toml`, a `.env.example`, and a README walkthrough.
Env merge order at deploy: deterministic defaults (e.g. `PORT`, `NODE_ENV=production`), then the repo's real `.env` (its values win over templates), then `.env.example`/`.env.sample` templates, then the project's env file on the host (secrets preserved), then the dashboard Environment editor (highest priority). Env text never installs a service: `[[services]]` declares them, and each entry fills its env keys (URLs, generated credentials) when they are absent or placeholders — a user-provided value always wins.
Top-level keys
name(optional here): the project name. Omit it and the connected project's name is used. If you set it, it must match the project name.source,branch: ignored; the project's clone URL and the deployed branch come from the project.runtime,package_manager: how the app is built and run.port: the port your app binds on 127.0.0.1 (behind nginx). Required;{port}substitutes into commands.required_packages: apt-resolvable names, optionally pinnedname=version.writable_paths: extra ReadWritePaths for the app's systemd units.
[[processes]]
One systemd service per entry. At least one is required.
name: lowercase, alphanumeric start and end. Units are namedox-<project>-<name>.service.command: the ExecStart.{port}resolves to this process's port.port: per-process port override.depends_on: other process names or[[services]]instance names (rendered asAfter=behind the service's unit). Unknown references fail validation.pre_start: ExecStartPre lines run before the command.readiness:http(s)://ortcp://host:portprobe; ox waits for it before continuing.restart_policy: systemd Restart= value; defaulton-failure.memory_high_mb,memory_max_mb,cpu_percent: per-process resource overrides.
[limits]
memory_high_mb,memory_max_mb: cgroup memory limits.cpu_percent: CPUQuota; 100 is one full core.tasks_max,open_files: process count and fd limits.
[deploy]
install: dependency install commands, run in the new release before switching.build: build commands run after install.migrate: migration commands; the database is snapshotted before these run.cleanup: commands run once when the project is deleted from the dashboard, as the project user in the current release. The author's uninstaller for anythinginstallput outside the release (global tools,curl | bash); a failure is reported in the delete dialog and never blocks the delete.health_url: checked after activation; a failing check fails the deploy.
Deleting a project
The delete dialog lists what the project owns on this server; each row is kept unless you uncheck it. Units, the registered manifest, and [deploy].cleanup always go.
- Database: dumped, then dropped unless another registered project claims it; a kept database is remembered under
/var/lib/ox/kept-databases/<db>. - Packages:
required_packagesare removed with apt only when no other registered project requires them and they are not host primitives (git,nginx,postgresql,ufw,lynis,lnav,certbot,python3,sqlite3). A dry run refuses the removal when apt would also take something else; nothing is ever autoremoved or purged. Auvproject removes the curl-installed binary from/usr/local/bin. - Env file, secrets, releases, nginx site: removed when unchecked.
[frontend] and [[frontend.static]]
dist: built SPA assets directory, relative to the release root.api_paths: URL prefixes that stay proxied to the app.[[frontend.static]]:prefix+distmounts a release directory at a URL prefix with hard 404s.
[[domains]]
name: the domain nginx serves.tls: request a Let's Encrypt certificate.spa: serve[frontend].diststatically withtry_files; requires[frontend].dist.process: which declared process's port this domain proxies to.websockets,rate_limit: proxy tuning.
Postgres databases and extensions
- The env names the database with
DATABASE_URLalone: left empty, ox provisions one named after the project. SplitDATABASE_*keys are the app's own ordinary env and ox never reads or writes them. - Extensions come from the
postgresservice's args (extensions = ["vector"]) and are created on the env-named database; their apt packages install automatically. [database].stale_policy:backup-then-drop(default) orkeepfor databases left behind on destroy. A database kept at delete time is remembered under/var/lib/ox/kept-databases/<db>until the marker is removed.
[[cron_jobs]]
name,schedule(strict 5-field crontab, no macros),command: rendered as a oneshot service plus timer.
[[apt_sources]]
- External repos (PGDG, NodeSource) rendered as deb822 sources before package install. Keys:
name,url(https only),suite,components,key_url.
Environment variables (.env handling guide)
Managing environment variables and secrets safely between git, ox.toml, and your server:
- Never put environment variables in
ox.toml:ox.tomlstrictly validates keys. Inline[environment]blocks or raw env key-values are rejected at deploy validation. - Use
.env.examplewith placeholders in git: When generating or committing repo files, agents and operators should commit a.env.example(or.env.sample) containing variable keys with dummy/placeholder values (e.g.DATABASE_URL=postgres://...,STRIPE_SECRET_KEY=,DEBUG=false). Never hardcode real credentials or secrets into git. - Set actual secrets in the Dashboard Environment Editor: Real secrets and production values belong exclusively in the project's Environment editor in the ox dashboard. They are written to the project's env file on the server (
/srv/ox/<name>/env, mode 0600), merged at deploy, and redacted from deploy logs. - Merge priority at deploy time:
- Deterministic defaults (e.g.
PORTmatching your process port,NODE_ENV=production) - Repository templates (
.env.example,.env.sample) - Existing server file (keys already present in the project's env file on disk are preserved)
- Dashboard Environment editor (highest priority; overrides earlier values)
- Deterministic defaults (e.g.
- Automatic infrastructure autowiring: If the merged environment references PostgreSQL (e.g.
DATABASE_URL,POSTGRES_*) or Redis (REDIS_URL), ox automatically provisions and runs those native services on the host. - The env file's home: the final merged env always lands at
/srv/ox/<name>/env(mode 0600, root-owned). The path is derived from the project name; there is no setting for it, and a leftoverenvironment_filekey is rejected like any unknown field.
[secrets]
Credential file names (values provided on the server out of band); units load them through systemd LoadCredential=. Names only, never values.