For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.
6i. Customer deploys on Cloudflare: the deployer Container
DESIGN.md §17, "Customer deploys run on Cloudflare, not on GitHub Actions", and decision 38. Switched on (2026-09-28, Ant's instruction, on image run 30): the deploys queue's consumer is deployer, so a customer deploy runs in a DeployContainer on Cloudflare. The dispatcher's scheduled floor and the runner path (§6h) still work, and setting the consumer back to dispatcher is the fallback.
What it is. A Worker, deployer, consumes the deploys queue. For each message it starts one DeployContainer instance for that request alone (deploy:<request>@<image>#<attempt>). The instance runs divvent deploys --request <id> --stored, the same code the runner's run job runs. It destroys the instance when the request settles, whatever it settled as. It builds nothing: --stored deploys the build container's own bytes for the hash, read from the artifacts bucket and held to the registry's built_digest. Where no stored build exists, it asks the builder for one and puts the request back on the queue. A rehearsed land stages its rehearsal in that instance. Its generated suites then run in a SuiteContainer instance with no environment, no bindings and no way back to the Worker, and a deployer instance lands it with their verdict.
A rehearsed land is three steps, each on the delivery's clock (decision 99, packages/workers/deployer/src/steps.ts). Stage, suites, land. The staging pass stores its bundle through the door before it answers, and the Worker stores the suites' verdict beside it, in the artifacts bucket under rehearsals/<request>/<rehearsal>/ (deleted when the request settles). Each delivery reads where the request stands and runs the next step, so a delivery Cloudflare ends mid-way is resumed by the next rather than rehearsed again an hour on. A delivery waits at most twelve minutes (INVOCATION_BUDGET_MS); a step it has no room left for, or one that outruns it, is put back on the deploys queue as a continuation — acked, never a retry, so Cloudflare's attempts are for failures. The suites run under the request's claim, and the land under its pass's: a delivery that finds another deployer delivery's claim live waits a minute and looks again, and once the claim is stale (CLAIM_MINUTES, twenty) it is taken over, once.
What it holds is in docs/SECURITY.md §18. In short:
- the Worker holds
DEPLOYER_OPENING_KEYand has no route of any kind; - the instance holds no platform token and runs no SQL. It reaches the control plane as named calls to its Worker's door (
DOOR_CALLS), each held to the one request the Worker bound the instance to. It also reaches the claimed application's own build output and its build jobs; - the suites instance holds nothing and reaches only
registry.npmjs.org.
The switch. Do these in order. Skipping a step is refused where it can be:
holderImageRefusalsrefuses a control-plane deploy that switches the consumer before the image is pinned, or that pins the three containers apart;- the Worker leaves every message for the runner while it has no key or no image.
- Build an image that carries the deploy server. Dispatch
image.ymlfrom a commit at or after this one. The image now also carries the CLI (packages/cli/container/deploy-server.mjsandsuite-server.mjs), and it is the first image whose builds store their built bytes (built_digest). - Pin its digest three times, identically. Put the five lines
image.ymlprints (image,builtFrom,engine,engineFingerprint,toolchain) on each ofcontainers.BUILD,containers.DEPLOYERandcontainers.SUITESininfra/control-plane.json. Commit and push. The staging deploy creates both new container applications. From then on every build stores its bytes. Rows built by an older image have none, and are rebuilt the first time the deployer is asked to deploy them (below). - Check that the token can do it. The control-plane deploy token already has
Workers Containers:Writefor the builder (§1.3). The same scope creates the two new applications. No new permission is needed. - Switch. In
infra/control-plane.json, setqueues[DEPLOYS].consumerfrom"dispatcher"to"deployer", then commit and push. A queue has one consumer, so the deploy detaches the dispatcher first and says so (replaced). - Watch one deploy. Approve a change on a staging test tenant. Then check two things:
wrangler tail divvent-staging-deployershows a{"deployer":…}line for each stage (deploy, thensuitesandlandwhere the land was rehearsed), ending in{"outcome":"done"};divvent doctor --env staging --require heatcois green. Nodeploys.ymlrun should have started for it.
The fallback. Set queues[DEPLOYS].consumer back to "dispatcher", then commit and push. The deploy moves the consumer back, and the next message dispatches a runner as before. Nothing else changes, because the runner path was never removed. Leaving the containers pinned is harmless: nothing starts them without a message.
Staging, previews and the renewal go through it too (Ant, 2026-09-29). None of these deploys from a runner any more:
- Staging's test fleet.
staging.yml'stenantsjob runsdivvent deploys --env staging --tenant "$READY" --enqueue --wait 1200: each request the seed raised goes ondivvent-staging-deployswith the message the portal sends, and the step reads the request back every twenty seconds until it settles. A request the deployer left queued (a build past its three deliveries) is put on the queue again after seven minutes.failedorblockedfails the step with the request's reason; still queued at the deadline is left, and thelandjob'sdoctor --requiresays so. The step holds the platform's token and no opening key. - Previews.
preview.yml'spreview-deployjob makes eachpr<n>-heatcoa company of ours inpr-<n>'s control plane (account create --internal --instance pr-<n>), seals the preview account's token there (tenant register --instance), seeds the example (tenant seed --instance, which asksdivvent-pr-<n>-builds), and runsdeploys --env preview --instance pr-<n> --enqueue --wait 1500 --json. The preview's own deployer — created bycontrol-plane deploy --instancewith everything else — deploys it. The comment's links are the deploy records' URLs. - The telemetry renewal. The scheduler's hourly
requestTelemetryRenewalsputs one{"kind":"renew"}message on the deploys queue where any credential is waiting (telemetry-renewal: … enqueued renew-<hour> for the deployerin the scheduler's log). The deployer startsrenew:<pass>@<image>#<attempt>, runsdivvent telemetry-renew, and its door mints each credential withDIVVENT_COMMAND_SIGNING_KEY, which the deployer Worker holds for this alone (holds; never in an instance).wrangler tail divvent-<env>-deployershows{"renewal":…,"stage":"renew"}, then the outcome:renewed,nothing,failed, orunconfiguredwhere the Worker has no signing key. The fallback: dispatchdeploys.ymlwith renew ticked, which renews on a runner as before.deploys.ymlno longer renews on its schedule; itsrenewjob still runs the domains pass and approved splits there.
Both paths at once is safe. The dispatcher's scheduled floor (§6d) keeps running whichever Worker consumes the queue. So a pass on a runner can meet a request the container is deploying. The claim is a compare-and-swap on the runner's name, so one of them deploys it and the other records claimed-elsewhere and moves on. The floor is also what picks up a request the container left:
- one that was still waiting on its build after three deliveries;
- one whose delivery died mid-pass. Its claim goes stale and is taken again.
Reading it. Each line in wrangler tail divvent-<env>-deployer is JSON. It names the request and a stage or how the request ended, and nothing the pass or the suites printed: that is customer output, and it stays out of a log the platform keeps. Why a request ended as it did is on the request, in the console's queue and the customer's portal, as it was on a runner:
| Outcome | What it means | What to do |
|---|---|---|
done, parked, blocked, failed | Settled. The request says why | As on a runner |
building, unplanned | No stored build of that hash yet. The builder was asked, and the message is retried in two minutes, three deliveries at most | Nothing, unless it outlasts the build. Then the runner's floor takes it |
rehearsing | The suites have not answered, or nothing stored the bundle and the hour has not passed | As building. The hour rehearses it again |
continued with a why | Carried on to the next delivery, on the deploys queue: its next step had no room in this one (a deploy.continued row on the request), a step outran the clock (DeployOverdue or SuitesOverdue in the fault list, and a row), or another delivery holds its claim | Nothing. One overrun is a slow step; the same request overrunning every delivery is a pass that never finishes — read its rows |
abandoned | Carried on for ninety minutes, or four overruns, without settling (DeployAbandoned) | Left queued for the rollout's hourly ask and the runner's floor. Find out why its steps never finish before retrying it |
unconfigured | No key or no image on this Worker | Steps 1–2, or the key (§9.3) |
retry with a reason | The instance could not run the pass. The reason is the Worker's own sentence, or the class of an error it did not write | Read the request's detail in the console. Reproduce on a runner (the fallback) if it needs the pass's output |
A DoorRefusal on the request | The pass asked the door for something that is not its request's. That is a bug in the pass, or an instance that is not behaving | Tell Ant with the request id. The refusal names the rule, not the data |
Where a release's minutes went (decision 100). Every step writes how long it took on the request's application, so a release is measured off its rows rather than the gaps between them:
| Row | Written by | Says |
|---|---|---|
deploy.step | The deployer Worker, after each step | the deploy step took 184s — the stage (or the whole pass, where nothing is rehearsed), suites, land: the instance started, the pass or the suites run, the answer read |
deploy.timing rehearsal … staged | The staging pass (needs an image from deploy-speed or later) | Its compile, the clearing of what an earlier one left, the time before the rehearsal's deploy (materialising, the compile, the stored build, the credentials), each deploy phase, the schema read, the teardown and the bundle |
deploy.timing rehearsal … copy of production | The land pass | The copy's whole time; its steps are the deploy.upgrade.rehearsal.copy rows |
deploy.timing land | The land pass | before the deploy, then preflight, snapshot, stage, migrate, smoke, flip and watch — the last is the wait after the flip's steps for the five-minute window, which opens at the flip |
deploy.done, then deploy.watched | The land pass | deploy.done when production serves the revision — the request is done, its watch watching — and deploy.watched when the watch has passed. A watch that fails writes deploy.failed instead and fails the request from done |
**done comes before the watch ends** (decision 100, decided (b) by Ant on 2026-10-01). A request is done once production serves its revision, and the portal and the console say "Live, being watched" until its five-minute watch ends. While it is watched the target is busy: a new request for it is refused ("is live on … and being watched"), a release's next step waits, and a canary's bake has not started — it starts when the watch passes. A failed watch fails the request with what it displaced recorded, so the rollback is offered (divvent deploy --rollback, or Roll back in the portal); nothing is rolled back by itself. A request left done with its watch watching after twenty minutes is a runner that died mid-watch: nothing is held to it any more, and the revision serves. Read the target's health, and deploy or roll back as for a stale claim.
They are ordinary audit rows on the application, read wherever its audit trail is. A step much slower than its row usually is, on one request, is the one to read first.
The suites' install goes through Cloudflare's HTTPS interception. Its container trusts /etc/cloudflare/certs/cloudflare-containers-ca.crt (suite-server.mjs points Node at it), and only registry.npmjs.org is let through. The first rehearsed land on staging is where that is seen working. If the install cannot fetch, the rehearsal fails closed and the request is blocked with the reason. The fallback is the runner, whose rehearse leg is unchanged.
What it does not do yet (PROGRESS.md has the detail):
- Previews. The door answers only the environment's own control database. A
pr-Ncontrol plane still deploys on a runner (preview.yml). - **
renew.** The telemetry and command-key renewal pass stays ondeploys.yml's schedule — and runs only where there is work (the full analysis, A2):divvent renew-dueinplanreads what is due (renewals asked for, domain claims to attach, detach or re-check — a verified TXT record everyDOMAIN_SWEEP_HOURS, six — registrar and sending-domain work), andrenewruns where it finds some, whereplanfound requests waiting, on any dispatch, or where the answer is missing (fail-open: a broken check costs minutes, not renewals).divvent renew-due --env stagingsays why. - Store the bundle from an image before decision 99. Only an image whose CLI posts the bundle to the door stores it before the pass answers; until one is pinned, the Worker stores it from the reply, so a delivery ended during the staging pass still loses it, and the hour rehearses it again. Pin an image built from
deployer-budgetor later (§6i step 1). - A release's round trips batched, and its timing rows, from an image before decision 100. The batched D1 work, the secrets in the upload, the assets and the stored build read a few at once, the smoke's checks four at once, the watch opened at the flip and the
deploy.timingrows all run in the deployer container, so they arrive with an image built fromdeploy-speedor later (§6i step 1). Until then only the Worker'sdeploy.steprows are written.