Terraform that provisions a single Ubuntu 24.04 VM on an xcp-ng pool (via Xen Orchestra) running the full WorkInABox stack:
- KVM + Firecracker inside the guest (nested virtualization), with a boot
smoke test that fails provisioning if
/dev/kvmor a real microVM boot is not available. - Backend (
wiab) installed from its latest GitHub Release, run as a systemd service on:8080. - Frontend installed from its latest GitHub Release, served by nginx over
HTTPS (Let's Encrypt), with
/apiproxied to the backend.
All in-guest setup is done by cloud-init running scripts/provision.sh.
| File | Purpose |
|---|---|
versions.tf |
Terraform + vatesfr/xenorchestra provider pin |
providers.tf |
XO connection (url/token/insecure) |
variables.tf |
All inputs |
main.tf |
Template/network/SR data sources + the xenorchestra_vm |
outputs.tf |
host_ip, url |
terraform.tfvars.example |
Sample inputs — copy to terraform.tfvars |
templates/cloud-init.yaml.tftpl |
cloud-config (writes env + provision.sh) |
templates/network-config.yaml.tftpl |
Static-IP netplan config |
scripts/provision.sh |
The actual in-guest setup |
scripts/wiab-deploy.sh |
Release deploy + model reconcile + health-gated rollback (installed as /usr/local/bin/wiab-deploy) |
scripts/wiab-cert.sh |
Let's Encrypt DNS-01 issue/renew (installed as /usr/local/bin/wiab-cert) |
images/ |
Firecracker guest image builders: base/, developer/, agent/, team/, kernel/, initramfs/, and the guest systemd units under guest/ |
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars
terraform init
terraform plan
terraform applyWatch in-guest progress: ssh ubuntu@<host_ip> then
tail -f /var/log/wiab-provision.log (or /var/log/cloud-init-output.log).
Note … | tee buffers, so the log can lag — cloud-init status --wait is a
truer "is it done" signal.
The host carries a /usr/local/bin/wiab-deploy script that pulls a release,
swaps the binary+libs / static bundle, restarts the backend (with a /health
check and auto-rollback to the previous build on failure), and atomically
repoints the frontend. cloud-init and Terraform both drive it.
To ship a new version in place (no VM recreation, no cert re-issue):
# pin the new tag(s) in terraform.tfvars
backend_version = "v0.2.0" # and/or frontend_version
terraform apply # only null_resource.deploy_app runs; VM untouchedterraform plan after a bump should show only null_resource.deploy_app
being replaced — never xenorchestra_vm.host. Updates take seconds.
Notes:
- Pin explicit tags to drive updates. A constant
"latest"never changes the trigger, so to re-pulllatestuseterraform apply -replace=null_resource.deploy_app. - Rollback = set the version back to the older tag and
apply(it re-downloads), or on the hostwiab-deploy --backend <oldtag>. - Deployed versions are recorded in
/etc/wiab/versions; re-deploying the same tag is a no-op.
The host serves HTTPS with a Let's Encrypt certificate obtained over the DNS-01
challenge, because it has no inbound port 80 for the usual HTTP-01 challenge. DNS for
workinabox.ai is hosted at one.com, which has no API certbot can drive, so the
challenge record is added by hand — issuance and renewal are manual.
The host carries /usr/local/bin/wiab-cert. Run it to obtain or renew the certificate:
sudo wiab-certIt pauses and prints a TXT record. In the one.com DNS panel for workinabox.ai, add:
| host | type | value |
|---|---|---|
_acme-challenge.demo |
TXT | the string wiab-cert prints (different every time) |
Wait ~1–2 min for it to propagate, press Enter, and certbot verifies, issues, and installs
the cert into nginx (adds listen 443 ssl + an 80→443 redirect). On success it prints the
expiry date.
| Trigger | Action |
|---|---|
| First time / TLS not yet configured | sudo wiab-cert |
| Certificate nearing expiry (within the last ~30 days of its 90 days) | sudo wiab-cert again |
After any terraform destroy + apply (a rebuild wipes /etc/letsencrypt) |
sudo wiab-cert again |
The cert does not auto-renew — certbot's manual authenticator can't run unattended
against one.com, so its renewal timer will attempt daily and fail harmlessly; that failure
is expected and is not your signal to act. Check the expiry any time with
sudo certbot certificates, or set a calendar reminder ~60 days out.
Current certificate: issued 2026-08-09, expires 2026-11-07. (Renew before then; this line
is updated by hand at each renewal — sudo certbot certificates is the live source of truth.)
The public DNS record for demo.workinabox.ai points at the site's public IP. With no port
forward, reach the host on the LAN by pointing the name at its LAN IP (192.168.1.4) in the
hosts file of each machine you use — per-device, persistent across reboots. Add:
192.168.1.4 demo.workinabox.ai
- Windows — edit
C:\Windows\System32\drivers\etc\hostsas Administrator, thenipconfig /flushdnsin an elevated terminal. - Windows + WSL — WSL2 mirrors the Windows hosts file into its own
/etc/hostson start, so the Windows entry above appears in WSL automatically. (To keep a WSL-only entry, setgenerateHosts=falseunder[network]in/etc/wsl.conffirst.) - Linux — append the line to
/etc/hostswithsudo. Flush only if a cache runs:sudo resolvectl flush-caches(systemd-resolved); otherwise/etc/hostsis read live. - macOS — append the line to
/etc/hostswithsudo, thensudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder.
Caveats: phones/tablets can't edit hosts without root, so a plain mobile device can't reach the demo this way (a LAN DNS server would be needed); and a browser with "Secure DNS"/DoH enabled bypasses the hosts file.
- Ubuntu 24.04 template required. The backend release is built on Debian bookworm (glibc 2.36); 22.04 (glibc 2.35) may fail to run it.
- Nested virt is experimental on xcp-ng (domain crashes / reboots reported;
Intel hosts fare better than AMD).
exp_nested_hvm = trueenables it; the Firecracker smoke test verifies it actually works. - certbot is non-fatal. If DNS/NAT aren't ready at apply time the host comes
up HTTP-only; re-run the
certbot --nginx ...line from the provision log once ready. - HSTS deliberately not set (deferred future work). The host serves HTTPS with
an
http→httpsredirect but sends noStrict-Transport-Securityheader. HSTS wouldn't let us close port 80 anyway (still needed for Let's Encrypt HTTP-01 renewal and the first-visit redirect), and it only protects repeat visits per browser — the first visit still hits port 80 unless you alsopreload, which is a sticky, hard-to-undo commitment not worth it for a demo. Revisit if this becomes a real production surface or you want SSL-strip hardening for repeat visitors: addadd_header Strict-Transport-Security "max-age=..." always;to the nginx 443 block (rampmax-ageup from a low value), and considerpreloadat that point. - WebRTC media (mediasoup) is UDP straight to the host. The backend pins its
transport range (
WIAB_MEDIASOUP_MIN_PORT/MAX_PORT, default40000-40999), so the firewall opens40000-40999/udp— ~1,000 ports, not the ~50,000 an unbounded range would need (provision.sh:479; defaults insfu.rs). WebRTC does not go through nginx. - The smoke-test kernel/rootfs URLs (
fc_test_*_url) point at Firecracker CI artifacts and may need bumping over time.