// 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
| Noun | What it is | Created 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 mode | Direct-publish mode | |
|---|---|---|
| Trigger | OXID_DOCKER_NETWORK=oxid-net | variable unset |
| How a branch is reached | <branch>.<base-domain> through the proxy | a host port Docker picks per deploy |
| Needs wildcard DNS | yes — *.your-domain → this host | no |
| Scale-to-zero | active | disabled — the sweep is a deliberate no-op |
| Stable address across redeploys | yes, the subdomain | yes — Oxid's own proxy port, not the container's |
| Good for | a shared team node, the supported production topology | a 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:
| Provider | Endpoint | How it is verified |
|---|---|---|
| GitHub | POST /api/v1/webhooks/github | HMAC-SHA256, X-Hub-Signature-256 (sha256= prefixed) |
| GitLab | POST /api/v1/webhooks/gitlab | the secret echoed back in X-Gitlab-Token — GitLab's whole model |
| Gitea | POST /api/v1/webhooks/gitea | HMAC-SHA256, X-Gitea-Signature, bare hex |
| Gogs | POST /api/v1/webhooks/gogs | HMAC-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 commit | Stays 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
| Rule | Behaviour |
|---|---|
Empty branches | Every branch deploys. This is the default, so nothing changes for a project that never configures it. |
ignore beats branches | So 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_environments | A 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
| Order | Scope | Applies to |
|---|---|---|
| 1 — weakest | global | every environment on the node |
| 2 | project | every branch of one project |
| 3 | branch | one branch |
| 4 — strongest | runtime | injected 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
| Variable | Value |
|---|---|
OXID_BRANCH | the branch name, e.g. feature/checkout |
OXID_COMMIT | the deployed commit SHA — what a /version endpoint wants |
OXID_ENV_URL | the environment's routed hostname |
| your declared dependency variable | e.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.
| State | What it means | Container |
|---|---|---|
building | image building, container starting | being created |
running | live and serving | running |
paused | scaled to zero after pause_after of no traffic | stopped — its memory is returned |
hibernating | still idle after 4 × pause_after | stopped |
build_failed | this deploy did not come up — the reason is in its audit trail | none |
destroyed | torn down, by TTL or by hand. Terminal, kept as history | removed |
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.
| Surface | How 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.