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.
| Backend | Host 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 afterwardsActivate 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 defaultVerify with virsh -c qemu:///system pool-list --all and
net-list --all — default 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 gitignoredA 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.sockEvery 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 setupDocker → 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:
| Path | What it is |
|---|---|
/usr/local/lib/ztd/ztd-docker-forward | the idempotent apply/remove/status script |
/etc/systemd/system/ztd-docker-forward.service | a 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 scriptHost 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 --reloadWhy 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
INPUTchain, 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 aDROPanywhere 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.