oxid/ docs

// guide

How Oxid is used

A walkthrough of the whole product, in the order you meet it. Every command and value on this page exists in the shipped build — where behaviour depends on how you run it, both variations are spelled out rather than one being assumed.

The three nouns

NounWhat it isCreated by
Project One repository. Holds the base domain, the idle/lifetime policy, and the git credential for a private repo. The first oxid up, the wizard, or POST /api/v1/projects.
Branch A git branch inside that project. Not stored on its own — it is the key an environment hangs off. A push, or naming it in oxid up.
Environment One deploy of one branch: a container, a URL, a state and an audit trail. A new row per deploy, so history survives. Every deploy.

One project, many branches, many environments per branch over time — but only ever one live environment per branch. Older ones stay as destroyed rows so oxid rollback has something to roll back to.

Choose a topology first

This is the one decision everything else follows from, and it is a single environment variable. Set OXID_DOCKER_NETWORK and you are in Traefik mode; leave it unset and you are in direct-publish mode.

Traefik modeDirect-publish mode
TriggerOXID_DOCKER_NETWORK=oxid-netvariable unset
How a branch is reached<branch>.<base-domain> through the proxya host port Docker picks per deploy
Needs wildcard DNSyes — *.your-domain → this hostno
Scale-to-zeroactivedisabled — the sweep is a deliberate no-op
Stable address across redeploysyes, the subdomainyes — Oxid's own proxy port, not the container's
Good fora shared team node, the supported production topologya laptop, a first look, a host whose ports you control

Scale-to-zero is off in direct-publish mode on purpose. Idle detection is driven by the proxy reporting real traffic; with no proxy nothing reports it, so pausing on that silence would be pausing on data Oxid does not have. The daemon warns about this at startup rather than pretending.

Bringing up Traefik

$ oxid infra status   # read-only: network, proxy, this daemon's own wiring
$ oxid infra setup    # idempotent: creates whatever is missing

infra setup creates the Docker network and starts the proxy. It never relabels the daemon's own container — Docker cannot do that to a running container without recreating it, and recreating the process executing the request is not something to automate. infra status reports what is missing and the exact labels to add; the shipped docker-compose.yml already has them.

Port 80 already taken? Set OXID_TRAEFIK_HTTP_PORT. The proxy always listens on 80 inside its container; this only chooses where that surfaces on the host. Branch URLs then need that port, since wildcard DNS cannot add one.

Register a project — three ways

All three end in the same place; which one fits depends on whether the daemon can read your checkout.

1 — From a local checkout

$ cd ~/code/my-app
$ oxid up main

The daemon reads the directory directly, so this needs the daemon and your checkout on the same filesystem — a native install, or a containerized daemon with the directory mounted (the shipped compose mounts ./repos for exactly this).

2 — From a git URL works with a containerized daemon

$ oxid up main --repo https://github.com/you/app.git
$ oxid up main --repo git@github.com:you/app.git   # scp-style, normalized server-side

The daemon clones into its own git cache. No shared filesystem, nothing mounted.

3 — A private repository

$ oxid up main --repo https://github.com/you/private.git --git-token ghp_xxx
$ oxid configure --git-token ghp_xxx     # or set it later
$ oxid configure --git-token ''          # clear it

Stored encrypted, never echoed back. It is needed because the daemon's own clone does not inherit any credential helper from your shell.

With no oxid.toml, Oxid falls back to a docker-compose.yml and then to a bare Dockerfile, deriving the name from the directory, the base domain as <name>.local.dev and the port from EXPOSE. With none of the three it refuses, because there is nothing to build.

Deploy a branch — three ways

1 — Push (the point of the product)

Add a webhook in your Git host. Every pushed branch deploys itself, and deleting a branch destroys its environment. All four providers are supported:

ProviderEndpointHow it is verified
GitHubPOST /api/v1/webhooks/githubHMAC-SHA256, X-Hub-Signature-256 (sha256= prefixed)
GitLabPOST /api/v1/webhooks/gitlabthe secret echoed back in X-Gitlab-Token — GitLab's whole model
GiteaPOST /api/v1/webhooks/giteaHMAC-SHA256, X-Gitea-Signature, bare hex
GogsPOST /api/v1/webhooks/gogsHMAC-SHA256, X-Gogs-Signature, bare hex

All four need OXID_WEBHOOK_SECRET set. Until it is, pushes are rejected by design — a typo'd variable must not silently open deploys to anyone who can reach the port.

A delivery is answered 202 {"status":"queued"} immediately and built afterwards, off a queue that survives a daemon restart. Providers abandon a webhook in seconds — GitHub at ten, with no retry for push events — while a real first build takes far longer, so deploying inside the request reported failures for deploys that had actually succeeded.

2 — From the CLI

$ oxid up feature/checkout          # deploy or redeploy
$ oxid status                       # what is live, and where
$ oxid status --sort state --filter running
$ oxid down feature/checkout        # destroy (asks first; --force skips)
$ oxid down feature/checkout --purge-secrets

3 — Dashboard or API

The dashboard at the daemon root does the same things, and both it and the CLI are thin clients over one HTTP API you can drive with curl.

Redeploys never take the branch down

The new container is built, started and confirmed healthy before traffic cuts over, and only then is the previous one retired. A broken push leaves the previous build serving.

oxid.toml

Every field, with its default. All of them are optional except the project name.

[project]
name = "my-app"            # required
pause_after = "30m"        # idle → paused.    default 30m
destroy_after = "7d"       # idle → destroyed. default 7d

[build]
dockerfile = "Dockerfile"  # relative to context
context = "."              # monorepo? point at a subdirectory
on_start = ["./seed.sh"]   # run inside the container once it is up
memory_limit_mb = 512      # falls back to the daemon's default
cpu_limit_millicores = 1000 # 1000 = one core

[routing]
base_domain = "my-app.local.dev"
port = 8080                # the port your app listens on inside the container

[deploy]
branches = ["main", "release/*"]  # which pushes deploy. empty = all
ignore = ["dependabot/*"]         # refused outright, beats `branches`
max_environments = 25             # hard cap. no default

[dependencies.db]
type = "postgres"          # postgres | redis
shared_instance = "local-pg"
inject_url_as = "DATABASE_URL"

Which fields the branch controls

Not all of them, and the split is deliberate. Every deploy re-reads oxid.toml from the commit being deployed:

Comes from the commitStays with the project
[build], [routing].port, [dependencies] [routing].base_domain, pause_after, destroy_after, [deploy]
Properties of the code — a branch that adds a dependency or needs more memory gets it. Operator decisions, owned by oxid configure. Otherwise one branch could rewrite another branch's URL or TTL.
$ oxid configure --pause-after 45m --destroy-after 3d

[deploy] is on the project side for a reason of its own: the filter has to answer before the checkout. Reading it from the pushed commit would mean fetching and checking out the branch first — exactly the work the filter exists to avoid.

Choosing which branches deploy

By default every pushed branch gets an environment, because that is the product. On a repository with two hundred branches it stops being what you want: most of them are someone's abandoned experiment, and each one costs an image, disk and a queue slot.

# in oxid.toml, versioned with your repo:
[deploy]
branches = ["main", "develop", "release/*"]
ignore   = ["dependabot/*", "wip/*"]
max_environments = 25

# or without touching the repo, on a project already registered:
oxid configure --branches "main,release/*" --ignore "dependabot/*" \
               --max-environments 25
RuleBehaviour
Empty branchesEvery branch deploys. This is the default, so nothing changes for a project that never configures it.
ignore beats branchesSo the usual shape — allow everything except the bot — works: branches = ["*"] with ignore = ["dependabot/*"].
* crosses /feat/* matches feat/team/thing. Someone writing that means everything under feat/, and a filter that quietly skips branches is worse than one slightly too generous.
max_environmentsA new branch past the cap is refused; a redeploy of a branch that already has an environment never is, or reaching the cap would freeze everything already running.

Two things that are deliberately not how this works

A manual deploy is never filtered. oxid up feat/carrito deploys that branch whatever the patterns say. A person naming a branch is asking for it, and that is the escape hatch for “I need to see this one today”.

The decision comes from the branch name, not the commit message. A tag like [oxid] in a commit is per-push, so it has no answer for the second push: destroying a live environment and leaving it serving stale code are both wrong, and nobody could predict which they would get. A branch name gives the same answer on every push of its life, including the one that deletes it.

What a skipped push looks like

It is accepted, not failed — the push was valid, and painting a Git host's delivery log red for a repository's ordinary traffic would be wrong:

HTTP 202
{
  "status": "skipped",
  "branch": "feature-carrito",
  "skipped": [
    { "project_id": 1, "service": ".",
      "reason": "branch `feature-carrito` matches no pattern in `[deploy].branches`" }
  ]
}

The daemon logs the same reason at info, so a developer asking “why didn't my branch deploy?” has an answer without guessing.

A malformed oxid.toml on a branch fails that deploy with the parse error, and the failure is recorded against the environment. A branch with no config file at all keeps the project's settings.

Shared dependencies

One Postgres and one Redis serve every branch: each environment leases its own logical database, and the connection string arrives in the variable you named. Point the daemon at them with OXID_POSTGRES_URL / OXID_REDIS_URL. A project that declares a dependency the daemon has no connection string for fails its deploy with that exact message, rather than starting without a database.

Secrets & variables

Three scopes, resolved most-specific-wins.

$ oxid env set SENTRY_DSN=https://... --scope global
$ oxid env set APP_NAME=shopfront --scope project
$ oxid env set FEATURE_X=on --scope branch --branch feature/x
$ oxid env list --scope project
$ oxid env delete FEATURE_X --scope branch --branch feature/x
OrderScopeApplies to
1 — weakestglobalevery environment on the node
2projectevery branch of one project
3branchone branch
4 — strongestruntimeinjected by Oxid itself (below)

Values are write-only: the daemon returns names and scopes, never a stored value — not to the CLI, not to the dashboard, not to the API. At rest they are AES-256-GCM encrypted under a key in secret.key, with a fresh nonce per value.

What every container receives

VariableValue
OXID_BRANCHthe branch name, e.g. feature/checkout
OXID_COMMITthe deployed commit SHA — what a /version endpoint wants
OXID_ENV_URLthe environment's routed hostname
your declared dependency variablee.g. DATABASE_URL for this branch's own database

Branch secrets survive oxid down by default — a recurring feature branch should not lose its config because it idled out. Pass --purge-secrets to clear them too.

The lifecycle

An environment is in exactly one state, and the moves between them are fixed.

StateWhat it meansContainer
buildingimage building, container startingbeing created
runninglive and servingrunning
pausedscaled to zero after pause_after of no trafficstopped — its memory is returned
hibernatingstill idle after 4 × pause_afterstopped
build_failedthis deploy did not come up — the reason is in its audit trailnone
destroyedtorn down, by TTL or by hand. Terminal, kept as historyremoved

build_failed is its own state, not a flavour of destroyed: "someone's push is broken" and "this was torn down" are different things to whoever is reading oxid status. It can only move on to being cleaned up — a branch recovers by deploying again, which creates its own environment.

Waking

A visit to a sleeping branch wakes it. The proxy has no route for a stopped container, so a catch-all router on the daemon answers instead, starts the environment, and returns a small page that polls until the app is up. Measured on the reference machine: 285–900 ms from asleep to serving — see the benchmarks.

$ oxid pause feature/checkout   # by hand
$ oxid wake feature/checkout

Suspending stops the container rather than freezing it in memory. A frozen container keeps its whole resident set and — more importantly — disappears from the proxy's routing table without ever coming back, which made waking impossible. Stopping costs a process restart and buys a branch that can actually be woken.

Capacity and the queue

With OXID_RESERVED_MEMORY_MB set, a deploy that does not fit is queued rather than overcommitting the host, and deployed automatically as capacity frees. Only running environments count against the budget — a sleeping one holds no memory.

$ oxid queue    # what is waiting, oldest first
$ oxid stats    # CPUs, memory, how many environments are running

Working as a team

Give people scoped tokens, not the master one

$ oxid token create alice --project 3        # repeatable for several projects
$ oxid token create ci                       # no --project = full access
$ oxid token list
$ oxid token revoke 4

A scoped token gets 404 outside its projects — not 403, so it cannot even enumerate what it may not touch — and 403 on node-wide routes: global secrets, capacity, key rotation and token management. It cannot mint another token, so it cannot escalate. The raw value is shown once and only its hash is stored.

Only the master credential (OXID_API_TOKEN) can manage tokens. On a fresh zero-config install, oxid token generate fetches the auto-generated one and saves it as a context, so nobody has to dig through container logs.

Talking to more than one daemon

$ oxid context add prod --api https://oxid.internal --token $TOKEN
$ oxid context add staging --api http://10.0.0.5:8080 --token $OTHER
$ oxid context use prod
$ oxid context list          # tokens masked to their last 4 characters
$ oxid context current

Stored at ~/.config/oxid/config.toml. Resolution order for every command: --api/--token flags, then OXID_API/OXID_TOKEN, then the active context, then http://127.0.0.1:8080.

Who did what

Every deploy, pause, wake and teardown lands in the audit trail with an operator: the named token that made the request, or the user the webhook says pushed.

$ oxid audit                                  # recent, across every project
$ oxid audit feature/checkout                 # one branch's full history
$ oxid audit --kind build_failed --since 2026-08-01T00:00:00Z
$ oxid audit --project 3 --branch main --until 2026-09-01T00:00:00Z

Two branches, one subdomain

feature/x and feature-x both normalise to feature-x — DNS labels cannot tell /, _ and . apart from -. The second deploy is refused, naming the branch that already owns the address, instead of leaving one of them silently unreachable.

Day-2 operations

Rolling back

$ oxid rollback feature/checkout              # to the deploy before the live one
$ oxid rollback feature/checkout --to a1b2c3d  # to a specific commit

It replays the deploy pipeline at the older commit, with the same zero-downtime cutover — it is a deploy, not a revert of the container. Only commits in that branch's own deploy history are accepted.

Watching

$ oxid logs feature/checkout        # snapshot
$ oxid logs -f feature/checkout     # live, over SSE
$ oxid doctor                       # reachable? authenticated? versions? infra?

Backups and keys

$ oxid backup oxid-$(date +%F).tar   # database + secret key, consistent snapshot
$ oxid restore oxid-2026-08-29.tar   # staged; applied on the daemon's next start
$ oxid rotate-key                    # re-encrypt every secret, no downtime

A restore never touches the live database in place — it is staged and applied at the next startup, and the daemon refuses the upload at all unless OXID_ALLOW_RESTORE=1. Rotation re-encrypts inside one transaction that excludes concurrent secret writes, and only swaps the in-memory key after it commits: a failed rotation leaves every secret exactly as it was. If you set OXID_MASTER_KEY explicitly rather than relying on secret.key, update it before the next restart.

Scripting it

$ oxid status --json | jq -r '.[] | select(.state=="build_failed") | .branch.name'
$ oxid ps --json | jq '.[].name'

--json works on every command that returns data, is never translated, and errors still go to stderr as plain text — use the exit code to tell failure kinds apart.

Language

Oxid speaks English and Spanish, and each surface picks its language its own way.

SurfaceHow it decides
Dashboard The switcher in the top bar, which changes the page in place without a reload. Otherwise your previous choice, then the browser's own languages, then English.
CLI --lang es, then OXID_LANG, then your shell's LC_ALL/LC_MESSAGES/LANG, then English.
API errors Accept-Language, honouring quality values. The dashboard sends whatever its switcher is set to.
$ oxid --lang es status
$ OXID_LANG=es oxid doctor
$ curl -H 'Accept-Language: es' http://localhost:8080/api/v1/stats

Three things stay in English on purpose: --json output and API field names, because scripts parse them; log lines, because aggregators match on their text; and any message coming from Docker, git or SQLite, because those strings are produced by those tools and are what you search for when something breaks.