Skip to content

Per-customer builds (downloadable images)

The portal can produce ready-to-download deployment media per customer, so a technician just downloads a pre-built file instead of assembling one by hand. Two kinds are supported:

  • Provisioning EXE — a self-extracting executable with the engine, the customer's provisioning config, and a scoped API token baked in. Run it on a target machine and it provisions and reports back to the cloud as that customer.
  • Bootable USB / ISO — a WinPE + OSDCloud ISO with the same payload baked into the boot media, for bare-metal / reimage scenarios.

How it works

The portal never builds anything itself (a Cloudflare Worker can't compile a Windows EXE or author an ISO). Instead:

  1. In a customer's workspace, Builds → Request build inserts a queued job (kind + label + the customer's config). The backend stores the job in D1; artifacts live in an R2 bucket.
  2. A Windows build runner you host (build/Invoke-TotlBuildRunner.ps1) polls the backend, claims the oldest queued job, and is handed a freshly-minted, tenant-scoped API token (shown once).
  3. The runner writes config.json (apiBase + token + tenant + config) and packages the artifact — IExpress for the EXE (built into Windows, no third-party tools), OSDCloud for the ISO.
  4. The runner uploads the artifact; the job flips to ready and a Download button appears in the portal. Downloads stream through the Worker behind Cloudflare Access, so only authorized users get the file.
Portal (queue job) ──> Worker/D1 ──> Build runner (claim, package, upload) ──> R2 ──> Portal (download)

One-time backend setup

# From the repo root:
# 1) Apply the builds table migration
wrangler d1 execute totlprovision --remote --config backend/wrangler.toml `
  --command "$(Get-Content backend/migrations/0007_builds.sql -Raw)"

# 2) Create the artifact bucket (binding ARTIFACTS in wrangler.toml)
wrangler r2 bucket create totlprovision-artifacts

# 3) Set the shared runner secret (a long random string)
wrangler secret put BUILDER_TOKEN --config backend/wrangler.toml

# 4) Deploy the Worker
wrangler deploy --config backend/wrangler.toml

Running the build runner

Run this on a Windows box (a VM is fine) that has the repo checked out. The EXE path needs nothing beyond Windows; the ISO path additionally needs the Windows ADK + WinPE add-on and the OSDCloud (OSD) module (Install-Module OSD -Force).

# Loop, polling for jobs:
.\build\Invoke-TotlBuildRunner.ps1 -BuilderToken $env:TOTL_BUILDER_TOKEN -RepoRoot C:\src\TotlProvision

# Single pass (CI / smoke test):
.\build\Invoke-TotlBuildRunner.ps1 -BuilderToken $t -RepoRoot . -Once

The runner authenticates with the BUILDER_TOKEN you set above. Treat it like a credential — anyone holding it can claim jobs and mint tenant-scoped tokens.

Why a runner exists (and why the cloud can't build for you)

A Cloudflare Worker is a sandboxed JavaScript runtime — no filesystem, no Windows, and no ability to run compilers or Windows tooling (IExpress, the ADK). Producing a Windows self-extracting EXE or a bootable WinPE ISO requires a real Windows machine. Every product that ships Windows images builds them on a Windows host somewhere; there is no serverless primitive that does it.

So the split is: the portal queues the job (cloud), and a Windows runner produces it. Crucially, you (the provider) run one runner — your customers never build anything. From a customer's point of view it stays "click Request build, download when ready." The runner is invisible background infrastructure, not something each customer touches.

So builds happen automatically instead of you starting a script by hand, run the runner continuously on one always-on Windows box (a small cloud Windows VM or a spare office VM). The simplest way is a Scheduled Task that starts at boot and keeps looping:

# One-time setup on the build box (run as admin). Stores the builder token for the task and registers it.
$token  = 'PASTE-YOUR-BUILDER-TOKEN'
$repo   = 'C:\LocalDev\NewComputerSetup'          # where the repo is checked out
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
  -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$repo\build\Invoke-TotlBuildRunner.ps1`" -BuilderToken `"$token`" -RepoRoot `"$repo`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName 'TotlProvision Build Runner' -Action $action -Trigger $trigger -Principal $principal -Settings $settings

# Start it now without rebooting:
Start-ScheduledTask -TaskName 'TotlProvision Build Runner'

After this the box polls forever: every queued build is produced automatically and appears in the portal as ready for download. Keep the box patched and the repo updated (git pull) so builds use the current engine. For heavier isolation you can instead wrap the runner as a true Windows service with a tool like NSSM, but the scheduled task above is enough for most shops.

What gets built — provisioning profiles

Each build bakes a provisioning profile plus the cloud reporting settings and a customer-scoped token. You author profiles with a guided form in the customer's workspace (Provisioning profiles → New profile) — no JSON. The form covers naming, local-admin rotation, debloat level, apps (a checklist of common winget IDs plus a free field for extras), Microsoft 365, BitLocker, CIS baseline, Windows/Dell updates, edition upgrade, domain/Entra join, Wi-Fi, time zone, and Autopilot. The backend assembles a valid engine config from those choices (backend/src/profile.js, unit-tested), and you pick a profile in the Builds panel when requesting a build.

Profiles can carry secrets (domain-join credentials, edition product key, Wi-Fi password). These are stored with the profile and returned to the runner at claim time, which bakes them into the image as environment variables the engine reads — so, like the API token, they live inside that build's media (scoped to that build, revocable by deleting it). The build snapshots the profile's config + secrets, so editing a profile later never changes an image you already produced.

The runner writes the config to config.json inside the image and the engine consumes it on the target machine at first run.

Security notes

  • The API token baked into an artifact is scoped to that one customer and can be revoked from the portal (deleting the build revokes its token). Mint short-lived builds and delete them when a batch of machines is provisioned.
  • Artifacts are private in R2 and only reachable through the Access-protected download endpoint — there is no public URL.
  • The runner receives the token over TLS once at claim time; it is never stored in D1 in plaintext (only a salted hash is kept, like every other API token).