oxid/ docs

// install & setup

One command, and the credentials are printed for you

The installer generates the API token and webhook secret, starts the daemon and Traefik, verifies the wiring, points your CLI at it, and ends by printing everything you need. There is no .env to hand-wire and no log to grep.

curl -fsSL https://raw.githubusercontent.com/sazardev/oxid/main/install.sh | sh -s -- --docker

What it prints when it finishes:

[+] daemon is healthy
[+] Traefik + network verified (scale-to-zero ready)
[+] CLI configured — try: oxid doctor

[+] Oxid is up. Everything below is ready to use — nothing else to run.

    Dashboard    http://127.0.0.1:8080/     (on this machine)
    API token    9f3c1a…  (64 hex chars)
                 (paste into the dashboard's token box — the CLI on this
                  machine is already configured with it)

    CLI          oxid doctor          (verifies the token) — then oxid ps
    Deploy       oxid up <branch>     (from a git checkout)

    Webhook      secret: 4e77b2…  (64 hex chars)

Re-running the installer is safe: existing secrets are reused and never rotated.

Choosing a path

CommandWhat you getBest for
sh -s -- --docker Compose stack in ./oxid-stack: daemon + Traefik, wake-on-request wired, published on 127.0.0.1:8080. Almost everyone. Production topology, nothing to configure.
sh -s -- --server Native binaries + a systemd unit, /etc/oxid/oxidd.env, bound to 0.0.0.0:8080. A dedicated host where you want the daemon outside Docker.
sh (no flags) oxid and oxidd in /usr/local/bin (or ~/.local/bin without root). Nothing started. Running the daemon under your own supervisor, or just the CLI.

Flags accepted by every mode:

FlagEffect
--version TAGPin a release (default: latest published).
--bindir DIRWhere binaries go (default /usr/local/bin, falling back to ~/.local/bin without root).
--root DIRSandbox prefix for /etc, /var and systemd paths — for testing and containers.
--no-startWrite everything, start nothing.

Prebuilt binaries cover Linux (musl/gnu, x86_64/aarch64), macOS and Windows. On macOS drop --server/--docker and install with --bindir, or use nix run github:sazardev/oxid.

Docker — what actually happens

In order, so nothing is a surprise:

  1. Fetches docker-compose.yml into ./oxid-stack and pins the image to ghcr.io/sazardev/oxid:<version>, verifying the pin took.
  2. Writes ./oxid-stack/.env at 0600 with a fresh 64-hex OXID_API_TOKEN and OXID_WEBHOOK_SECRET — unless one already exists, which is reused untouched.
  3. docker compose up -d, then waits up to 60s for /api/v1/health.
  4. POST /api/v1/infra/bootstrap (retried three times) to create the oxid-net network and the Traefik container that scale-to-zero needs.
  5. oxid context add local and oxid context use local, so the CLI on this machine is authenticated without any flags.

What lands on disk:

./oxid-stack
├── docker-compose.yml   # daemon + Traefik, image pinned
└── .env                 # 0600 — API token + webhook secret

# daemon state lives in the `oxid-data` docker volume (see Daemon → data layout)

Without the installer

The shipped compose runs with OXID_AUTO_TOKEN=1, so it works with nothing filled in — the daemon generates both credentials, persists them under /data at 0600, and prints them once:

$ git clone https://github.com/sazardev/oxid && cd oxid
$ docker compose up -d
$ docker compose logs oxid-daemon | grep -A2 Generated   # printed once

Explicit values always win: set OXID_API_TOKEN / OXID_WEBHOOK_SECRET in a .env to pin your own. Retrieve a generated one later with docker compose cp oxid-daemon:/data/api-token -.

Where it listens — and what that means

On every interface, on purpose. The compose publishes 8080:8080 and --server binds 0.0.0.0:8080, so your team's CLIs and your Git host can reach it without editing anything. A preview-environment server nobody can reach is not one.

What stands between that port and your cluster is the credential, not the bind:

  • Every /api/v1/* route requires a bearer token.
  • The daemon refuses to start on a non-loopback bind without OXID_API_TOKEN.
  • Credentials carry a role, a scope and an expiry.
  • OXID_BOOTSTRAP_TOKEN_ACCESS is off, so nothing hands out a token pre-auth. The installer prints it instead.

Put TLS in front before this crosses a network you do not control. A bearer token over plain HTTP is readable by anything on the path. On a trusted LAN this is fine as it stands; on anything else, terminate TLS at Traefik or set OXID_TLS_CERT/OXID_TLS_KEY. To go the other way, narrow the publish to "127.0.0.1:8080:8080" and reach it over SSH.

Verifying the install

$ oxid doctor
[+] Daemon reachable at http://127.0.0.1:8080 (v0.3.1, 0ms)
[+] Control API authenticates correctly
[+] CLI (v0.3.1) and daemon (v0.3.1) versions match
[+] Docker capacity: 12 CPU(s), 15.5 GiB memory, 0 env(s) running
[+] Docker network `oxid-net` exists
[+] Traefik is running

$ oxid ps        # registered projects — node-wide, needs no checkout

oxid status is not the command to check an install with: it is scoped to the checkout it runs in and registers it. doctor and ps ask about the daemon itself.

Your first deploy

The daemon clones the repository itself, so it needs a URL it can reach — not a path on your laptop:

# register and deploy in one:
oxid up main --repo https://github.com/you/app.git

# private repo (the PAT is stored encrypted, never echoed back):
oxid up main --repo https://github.com/you/private.git --git-token ghp_xxx

# from inside a checkout, --repo is inferred from its `origin`:
cd ~/code/app && oxid up main

That last form is worth explaining. Registration sends the working-tree path, which a containerized daemon cannot see — and a container is what the Docker install runs. When the daemon rejects the path, the CLI retries with the checkout's origin and says so:

[~] daemon cannot see /home/you/code/app (it is likely containerized)
    — registering its origin instead: https://github.com/you/app.git

No Dockerfile? None is needed for the common stacks — see Stacks & builds.

Webhooks — deploy on push

Point your Git host at the daemon and every push deploys its branch; deleting a branch destroys its environment.

URL     http://YOUR-HOST:8080/api/v1/webhooks/github
        # also: /gitlab, /gitea, /gogs
Secret  the OXID_WEBHOOK_SECRET the installer printed
        (or: grep ^OXID_WEBHOOK_SECRET oxid-stack/.env | cut -d= -f2)

Got a repository with hundreds of branches? By default every pushed branch gets an environment. See choosing which branches deploy — patterns in oxid.toml plus a hard cap.

Two things to get right:

  • The Git host has to reach the port. It is published on every interface, so on a server with a public address this already works; behind NAT you still need a forward or a tunnel.
  • The secret must match. Webhooks are rejected entirely while OXID_WEBHOOK_SECRET is unset, and a bad signature is a 401.

A quick check that the secret is right, without waiting for a push:

# 404 = signature accepted, repo just isn't registered. 401 = wrong secret.
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  http://127.0.0.1:8080/api/v1/webhooks/github \
  -H 'X-GitHub-Event: push' -H 'X-Hub-Signature-256: sha256=<hmac>' \
  -d '{"ref":"refs/heads/main","repository":{"full_name":"you/app"}}'

Native server — systemd

curl -fsSL https://raw.githubusercontent.com/sazardev/oxid/main/install.sh | sh -s -- --server

Writes, all at 0600 where they hold secrets:

PathContents
/usr/local/bin/oxid, oxiddCLI and daemon binaries.
/etc/oxid/oxidd.envGenerated token + webhook secret, OXID_DATA_DIR=/var/lib/oxid, OXID_DOCKER_NETWORK=oxid-net, OXID_DAEMON_URL=http://172.17.0.1:8080, backups every 300s keeping 7, JSON logs.
/etc/systemd/system/oxidd.serviceRestart=on-failure, NoNewPrivileges=true, LimitNOFILE=65535.
/var/lib/oxid/Database, master key, git cache, backups.
$ journalctl -u oxidd -f
$ sudo grep ^OXID_API_TOKEN /etc/oxid/oxidd.env | cut -d= -f2

TLS: terminate at Traefik, or set OXID_TLS_CERT/OXID_TLS_KEY in that env file and systemctl restart oxidd.

Other ways to run

Single docker run

docker run -d --name oxid-daemon \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v oxid-data:/data \
  -p 127.0.0.1:8080:8080 \
  -e OXID_API_TOKEN=$(openssl rand -hex 32) \
  -e OXID_WEBHOOK_SECRET=$(openssl rand -hex 32) \
  ghcr.io/sazardev/oxid:latest

Without OXID_DOCKER_NETWORK=oxid-net this is direct-publish mode: each environment gets its own host port, no DNS is needed, and scale-to-zero is off by design — idle detection is driven by Traefik's heartbeat, which is not in the path.

Nix / NixOS

nix run github:sazardev/oxid#oxid -- ps
nix run github:sazardev/oxid#oxidd
# or: nix develop  # dev shell with the full toolchain

From source

git clone https://github.com/sazardev/oxid && cd oxid
cargo build --workspace   # toolchain pinned in rust-toolchain.toml
cargo run -p oxid-daemon

See CONTRIBUTING.md for the fmt → clippy -D warnings → test gate order and hook setup.

Requirements

  • Docker on the daemon host, for build/run/stop. Not needed for cargo test.
  • A long random OXID_API_TOKEN for any non-loopback bind — the daemon refuses to start otherwise.
  • For subdomain routing: a wildcard DNS or hosts entry pointing at the host, e.g. *.local.dev → 127.0.0.1.

Upgrading and removing

# docker stack:
cd oxid-stack && docker compose pull && docker compose up -d

# native/binaries — re-running never rotates secrets:
curl -fsSL https://raw.githubusercontent.com/sazardev/oxid/main/install.sh | sh -s -- --server

Migrations run automatically at startup. Pre-built binaries exist for every tagged release — see Releases.

Removing it

There is no uninstall flag; removal is explicit, because -v destroys your data:

# docker stack — `down -v` also deletes the oxid-data volume:
cd oxid-stack && docker compose down -v
docker rm -f oxid-traefik && docker network rm oxid-net

# native:
sudo systemctl disable --now oxidd
sudo rm /etc/systemd/system/oxidd.service /usr/local/bin/oxid{,d}
sudo rm -rf /etc/oxid /var/lib/oxid          # this is your data

# cli config, if you want it gone too:
rm -f ~/.config/oxid/config.toml

Take an oxid backup first if there is anything you might want back — the archive carries the database and the master key together, which is the only combination that restores.

When it doesn't work

SymptomCause and fix
401 Unauthorized: missing or invalid bearer token The CLI is not pointed at this daemon. oxid context use local, or pass --token "$(grep ^OXID_API_TOKEN oxid-stack/.env | cut -d= -f2)". An OXID_TOKEN left in your shell overrides the stored context.
403 Forbidden: this action needs the … role Working as intended: the credential's role is below what the action needs. oxid token list shows every role; an admin can issue a stronger one.
403 … this access expired / … suspended The credential ran out or was switched off. oxid token resume <id> restores a suspended one; an expired one has to be reissued.
git failure: failed to resolve path '/home/…' A containerized daemon cannot see your checkout. Upgrade to v0.3.1+, where the CLI falls back to the checkout's origin, or pass --repo <url> explicitly.
infra bootstrap failed The daemon was not ready or the token was wrong. cd oxid-stack && docker compose restart oxid-daemon, then oxid infra setup (idempotent).
Environments 404 after going idle The wake catch-all router is missing. oxid infra status reports it; it ships in the compose file and is what turns a request for a stopped environment into a wake.
Webhook pushes do nothing Check the daemon is reachable from the Git host at all, then that the secret matches — the provider's delivery log shows the response code. 401 is the secret; 404 is a repository that is not registered under that exact path.
no service declares a build: key The repository has a docker-compose.yml Oxid found first. Add build: . to the service you want deployed, or an oxid.toml.
Daemon refuses to start A non-loopback bind with no OXID_API_TOKEN. Set one — or OXID_ALLOW_OPEN_API=1 if you genuinely want an open API.

Next steps

  • Stop using the master token day to day: oxid token create juan --project 1 --role developer --expires-in 90d — see giving people access.
  • Shared Postgres/Redis: set OXID_POSTGRES_URL / OXID_REDIS_URL so projects declaring those dependencies get a logical database per branch.
  • Backups: OXID_BACKUP_INTERVAL_SECS, restored with oxid restore.
  • Tune throughput: OXID_DEPLOY_CONCURRENCY — 15 simultaneous pushes settle in 7.1s at the default, 4.2s at 16.

Deep dive: How Oxid is used · Stacks & builds · Daemon env vars · CLI reference · HTTP API · Security · PRODUCTION.md