Saltar al contenido

Docs / Usa tus propias máquinas

Bring your own boxes

Enrol a VM into your fleet: requirements, the join command, hardening, and what never touches the box.

The compute is yours. The control plane is ours.

That distinction is the whole of this page. developerz.ai is delivered as SaaS, there is no self-hosted edition of the platform, and there will not be one. What is self-hosted is the part that does the work: AI-dev sessions, CI jobs and code review all execute on VMs you own, enrolled into your fleet. We dispatch; your hardware runs.

What a box needs

Checked by the installer before anything is downloaded, and re-checked server-side before your enrolment token is spent:

Requirement Value
OS Ubuntu (the installer refuses anything else)
Architecture x86_64, aarch64 or arm64
RAM ≥ 7600 MiB, which an 8 GB VM clears: it reports about 7751 MiB
Free disk on / ≥ 40 GiB free, not total
Privileges root, or sudo without a password prompt
Docker required (installed for you if absent)

The RAM and disk floors are what the coding CLI needs: it runs on your box with its checkouts, its code index and a language toolchain beside it.

Runner inbound ports: none. The runner dials out; nothing listens for us. You may keep SSH open for yourself, and the installer's default hardening will make sure your detected SSH port stays allowed before it turns the firewall on.

Outbound, the box needs to reach the fleet transport (nats.developerz.ai:4222, TLS), github.com, and whichever model endpoint your key uses.

Enrolling

Two transports. Pick the one that matches how you like to hand over trust.

1. Token join: you run one command

Mint an enrolment token in the dashboard under Fleet → Add box, then on the VM:

read -rp 'Coding model (e.g. z-ai/glm-5.3): ' DZ_MODEL && \
  export DZ_MODEL_OVERRIDE="${DZ_MODEL:-${DZ_MODEL_OVERRIDE:-}}" && \
  read -rsp 'Enrollment token: ' DZ_ENROLL_TOKEN && DZ_JOIN="$(mktemp)" && \
  curl -sfL -H "X-Dz-Enroll-Token: $DZ_ENROLL_TOKEN" \
    https://api.developerz.ai/join -o "$DZ_JOIN" && bash "$DZ_JOIN"

The first prompt asks which coding model the box drives with your own key; a fresh box that names none is refused before anything is installed or your token is spent.

The token travels in a header, not in the URL, so it does not land in proxy logs or shell history the way a query parameter would. The script is downloaded to a temp file and then executed, deliberately not curl … | bash, so a truncated download cannot run half an installer.

The script gates on the requirements above, installs git, unzip, gnupg, gh, Docker and Bun, applies the baseline firewall, installs the OS upkeep timers, writes its credentials to /etc/dz-runner (mode 0700, files 0600), and enrols.

It then installs the dz-runner service and starts it, see after the join below. The last thing it prints is what it actually observed on the box rather than what it hopes happened, so you never have to guess which of the two you got.

2. SSH pull: you hand over coordinates, we dial in

If you would rather not paste a secret into a shell, register the host and let the platform connect:

curl -X POST https://api.developerz.ai/v1/runners/enrollments/ssh \
  -H "Authorization: Bearer dev_pat_…" \
  -H "content-type: application/json" \
  -d '{"host":"box1.example.com","user":"root","model":"z-ai/glm-5.3"}'

model is the coding model the box drives, the same answer the token join prompts for; a fresh box enrolled without one is refused.

For this transport the box's SSH port (the one you registered above) must be reachable from the platform, and since the platform does not publish fixed source addresses, the operator must allow inbound SSH from the public internet on the registered port: exactly what the runner path is deliberately built to avoid.

Scope: write:runners. The operator never holds a token in this mode, it is minted and consumed inside the platform, and it is not in the response.

Either transport prepares the machine, trades your one-time token for the box's permanent credential, installs the coding agent and the signed dz-runner binary, and starts the dz-runner service. The box reports into Fleet → Boxes within one heartbeat.

After the join

One command is the whole thing. It used to be two: the join enrolled the box, and a second step installed and started the service. The join now does both.

The runner binary is fetched from our signed release channel and verified before it is installed: its SHA-256 must match the release manifest, and its detached Ed25519 signature must verify against the signing key your box was handed during enrolment. There is no flag to skip that. If anything about the build cannot be verified (no release channel configured, no systemd on the host, an architecture we publish no build for, an OpenSSL too old to check an Ed25519 signature), the script installs nothing and says which one it hit. A box left without a service is loud and recoverable; a box running bytes nobody could attribute is neither.

So the join script still ends by saying which state it left the box in, and that report is the thing to read:

What it printed What it means
box <id> is ONLINE the service is running; the box reports in within one heartbeat
ENROLLED, BUT NOT BEATING the credential is written, the service is not running: an install guard refused, or the unit was installed and failed to start
UNOBSERVABLE this host has no systemctl, so the script cannot tell either way

On either of the last two, the script's own output names the reason directly above that line. Fix it and re-run the join, but note the token: re-running needs a fresh one, because the one you used is spent. Leave /etc/dz-runner in place either way, and contact us with the runner id if the reason is ours.

Re-running the join on a box that is already running is safe: the binary is updated in place and the service is deliberately left alone, because it may be holding an assignment. The runner checks the channel before it takes its next piece of work, so it converges on the new build by itself.

Hardening

The installer runs a hardening pass by default (DZ_HARDEN=0 opts out, and says so in its output rather than silently skipping):

  • UFW: your detected SSH port(s) and 22 are allowed first, then default deny inbound / allow outbound, then enable.
  • Docker bypass closed: Docker publishes ports by writing iptables rules that sit in front of UFW. A DOCKER-USER block is injected into /etc/ufw/after.rules that returns for established traffic, loopback and RFC 1918, then drops. This is the difference between "I enabled a firewall" and "my Postgres container is not on the public internet".
  • Exposed-service scan: common data-store ports are checked for non-loopback listeners and reported. Nothing of yours is stopped or reconfigured, the scan tells you, you decide.

Once the runner service is installed, it runs as an unprivileged dz-agent system user under a locked-down systemd unit: no new privileges, an empty capability set, ProtectSystem=strict, private /tmp, and a memory/CPU ceiling.

What never touches the box

  • The GitHub App private key. It is not on your box and not on the runner path at all. CI jobs receive a repo-scoped installation token minted just in time with a 15-minute TTL.
  • Plaintext secrets. Vault entries in a task assignment are encrypted blobs; the assigner that routes them does not decrypt them.
  • Your source, permanently. Work happens in a workspace on your own disk.

Updates

The mothership pushes an update request; the runner unsubscribes, drains in-flight work (bounded at 30 seconds), downloads the new binary and its detached signature, verifies the Ed25519 signature, atomically replaces itself, and exits into Restart=always.

There is no git pull, no build step on your VM, and no unsigned artefact path. If the signature does not verify, the update does not happen.

Presence and draining

Each box heartbeats every 30 seconds into a key with a 90-second TTL, liveness is the key existing, not a timestamp in a table. On a graceful drain the heartbeat continues until in-flight work settles and the key is then left to lapse rather than deleted, so nothing can be double-executed mid-drain.

To take a box out of rotation:

box drain <box_id>     # MCP verb; in-flight tasks are re-enqueued

To retire it for good, revoke it: POST /v1/runners/:id/revoke.

How many boxes you may enrol

One AI-dev seat allows three enrolled servers, inclusive of the box running the AI-dev work itself, the other two are yours for CI, tests and review at no extra charge. Boxes are fungible. Trying to enrol past the cap returns 402 Payment Required rather than quietly accepting the box; see billing.