Host prerequisites

ZTD keeps the host footprint minimal: all orchestration tooling (Terraform, providers, Task) runs in the toolbox container. The only thing that can’t be containerized is a hypervisor for a local VM — and that is “the VM” itself.

BackendHost needs
kvm (local Linux)Docker + libvirt/qemu + virtiofsd
vz (local macOS)Docker + Virtualization.framework (via lima)
proxmox / ec2 (remote)Docker only

virtiofsd relays the repo live-share into the guest (it runs as root and reflects the guest uid to the host owner, so files created in the VM are owned by you on the host).

Local kvm on Arch

sudo pacman -S --needed qemu-full libvirt dnsmasq virtiofsd
sudo systemctl enable --now libvirtd   # socket-activated; virtqemud on modular setups
sudo usermod -aG libvirt "$USER"       # re-login afterwards

Activate the default storage pool and network

A fresh libvirt install doesn’t activate the default storage pool (where ZTD writes the VM’s base and disk images) or the default NAT network (which the VM attaches to). Without them ./ztd up fails with can't find storage pool 'default' and network 'default' is not active. Set both up once — no sudo needed once you’re in the libvirt group:

# Storage pool
virsh -c qemu:///system pool-define-as default dir --target /var/lib/libvirt/images
virsh -c qemu:///system pool-build default
virsh -c qemu:///system pool-start default
virsh -c qemu:///system pool-autostart default

# NAT network (usually pre-defined but inactive; if net-start says it's not
# defined, run: virsh -c qemu:///system net-define /usr/share/libvirt/networks/default.xml)
virsh -c qemu:///system net-start default
virsh -c qemu:///system net-autostart default

Verify with virsh -c qemu:///system pool-list --all and net-list --alldefault should be active with autostart yes in both. ./ztd check verifies the pool, network, and virtiofsd for you.

The toolbox container talks to the host libvirt socket: compose.yaml runs it with network_mode: host, mounts /var/run/libvirt/libvirt-sock, and sets LIBVIRT_DEFAULT_URI=qemu:///system.

Not installed on the host

Terraform and Task are not host dependencies — they live in the toolbox image (docker/Dockerfile), built automatically on the first ./ztd run.

Keeping ztd’s credentials out of git

ztd runs inside your repository and writes a private key (.ztd/keys/id_ed25519), API tokens (.ztd/secrets/*.env), Terraform state and agent transcripts into .ztd/. ztd init writes a .ztd/.gitignore covering all of it — ztd.toml and any agent skills you author are the only things meant to be committed.

It is ignore-all-but on purpose, so anything ztd starts writing there in a later version is ignored by default rather than silently committable. Re-running ztd init retrofits the file onto an older project and never overwrites one you have edited.

ztd check verifies the result, for every backend:

Project hygiene (git)
  [ ok ] no ztd credentials tracked by git
  [ ok ] ztd keys/secrets/state/runs are gitignored

A tracked credential is a hard failure — and note that git rm --cached does not remove it from history, so rotate anything that was committed.

Your user must be able to reach the Docker socket

Installing Docker is not the same as being able to use it. The daemon socket is root:docker mode 0660, so an account outside the docker group gets:

permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

Every ZTD verb on the kvm, proxmox, and ec2 backends runs through the toolbox container, so this breaks all of them at once. Add yourself to the group:

sudo usermod -aG docker "$USER"

Group membership is only picked up by new login sessions — log out and back in, or use newgrp docker in the current shell. (Being in the docker group is effectively root on the host; that is inherent to using Docker, not something ZTD adds.)

ztd check verifies this by actually calling docker info. Note that docker --version and docker compose version are answered by the client binary alone and prove nothing about daemon access — which is why they aren’t the test. The acceptance suites check it too, as a hard precondition, so a run fails immediately with the fix rather than deep inside ztd up.

Host prep for the local backend (./ztd setup)

Two host-side fixes the local kvm backend needs — both idempotent, both flagged by ./ztd check, both applied by:

./ztd setup

Docker → libvirt forwarding. ZTD requires Docker on the host (the toolbox), and when the Docker daemon starts it sets the netfilter FORWARD policy to DROP and isolates its own bridges. That silently blocks libvirt guest NAT: the guest gets a lease and reaches the gateway, but can’t reach the internet, so cloud-init can’t install anything. setup adds an allow rule for the libvirt bridge to Docker’s DOCKER-USER chain. By hand:

sudo iptables -I DOCKER-USER -i virbr0 -j ACCEPT
sudo iptables -I DOCKER-USER -o virbr0 -j ACCEPT

…and makes it stick. Docker flushes DOCKER-USER every time the daemon restarts, so the rule on its own is a shim that evaporates — and the failure is silent, because it looks identical to a host that was never set up. setup therefore also installs:

PathWhat it is
/usr/local/lib/ztd/ztd-docker-forwardthe idempotent apply/remove/status script
/etc/systemd/system/ztd-docker-forward.servicea oneshot unit, WantedBy= + PartOf=docker.service

WantedBy starts it with Docker; PartOf propagates Docker’s restart to it, so the rule is re-applied every time the chain is flushed. ./ztd check reports the rule and whether the unit is enabled — a host with the rule but no unit is one Docker restart away from broken guests.

The script is copied to /usr/local/lib/ztd/ rather than referenced inside ztd’s extracted asset cache on purpose: that cache is version-scoped and replaced on upgrade, which would leave the unit pointing at a path that no longer exists.

./ztd setup --transient   # apply the rule now, install nothing (hosts without systemd)
./ztd setup --undo        # remove the rule, the unit, and the installed script

Host firewall → guest DHCP. An active ufw or firewalld drops the guest’s DHCP request on INPUT, before libvirt’s dnsmasq ever sees it, so the guest gets no IP at all and up fails with no DHCP lease after 3 boot attempts.

This is the most misleading failure in the whole local stack, because nothing looks wrong: the bridge is up, dnsmasq is bound to it, the DHCP range is correct, the tap device is enslaved and forwarding, and the guest really is sending requests. setup adds the exception; by hand:

sudo ufw allow in on virbr0 comment 'libvirt guest DHCP/DNS'
# or, with firewalld:
sudo firewall-cmd --permanent --zone=trusted --add-interface=virbr0 && sudo firewall-cmd --reload

Why this is new. libvirt 12 and later default to the nftables firewall backend. The older iptables backend inserted libvirt’s DHCP/DNS accept rules directly into the iptables INPUT chain, ahead of ufw’s default deny — libvirt punched through the host firewall on your behalf. Under nftables those accepts live in libvirt’s own table and no longer sit in front of ufw, and a DROP anywhere in netfilter still wins. Hosts that worked for years can break on a libvirt upgrade alone.

Note this is a different problem from the Docker forwarding fix above, and neither substitutes for the other: Docker’s is FORWARD (the guest gets a lease but has no internet), the firewall’s is INPUT (no lease at all).

The repo live-share itself needs no host-side permission fix: it’s shared over virtiofs, whose daemon virtiofsd runs as root — it traverses your 0700 home on its own and reflects the guest uid to the host file owner, so files created in the VM are owned by you on the host, with QEMU staying confined.