Hardened VM deployments for running agents.
- Create/destroy multiple VMs
- Tailscale setup for host and VMs
- Host firewall (nftables)
- Caddy proxy for authenticated access to APIs in VM (via Tailscale HTTPs)
- Promtail setup for log collection
- Host with fresh Debian 13 install
- Tailscale account (+ auth keys) with HTTPs enabled
inventory/hosts.ymlis set with the IP/hostname of your host machine- Ansible collections (
ansible-galaxy collection install -r requirements.yml -p ./collections, or see requirements.yml)
If you already have a hardened non-root user, you can skip this step.
- Set
host_user_manage: trueininventory/hosts.yml - Set
host_userto desired username - Run playbook, temporarily overriding user as
root:ansible-playbook playbooks/host-setup.yml --tags user -e ansible_user=root
- SSH as
rootand set a strong password (passwd <user>) - SSH as the new user to confirm setup, and verify sudo capabilities with something like
sudo ls
Visit tailscale.com/admin/settings/keys to create an auth key.
- Install and configure Tailscale:
ansible-playbook playbooks/host-setup.yml --tags tailscale --ask-become-pass -e tailscale_auth_key=<key>
- Update
inventory/hosts.yml(or your local SSH config) to use the host's Tailscale IP/hostname
Run after the non-root user is working. Ensure the firewall allows
host_ssh_portbefore changing the port.
- Set
host_ssh_port(and optionallyssh_permit_root_login,ssh_password_authentication) ininventory/hosts.yml - If you have an external firewall, allow the new port
- Run:
ansible-playbook playbooks/host-setup.yml --tags ssh_hardening --ask-become-pass
- Install and configure libvirt:
ansible-playbook playbooks/vm-deploy.yml --tags setup,pool --ask-become-pass
- Review default allowed ports (
vm_allowed_outbound_ports) - Install and configure nftables:
ansible-playbook playbooks/host-setup.yml --tags nftables --ask-become-pass
- (Recommended) Review external firewall so only required ports are open
- Set
promtail_enabled(true/false) - Run promtail setup:
ansible-playbook playbooks/host-setup.yml --tags promtail
- Define VM entries under
vmsininventory/hosts.yml - Acquire a Tailscale auth key (warning: key is stored in VM; prefer one-time/ephemeral keys)
- Deploy/update all VMs in
vms:# Deploy all, with tailscale key for setup ansible-playbook playbooks/vm-deploy.yml --tags network,vms --ask-become-pass -e tailscale_auth_key=<key> # OR - Update/deploy all ansible-playbook playbooks/vm-deploy.yml --tags network,vms --ask-become-pass # OR - Deploy specific vm, with tailscale key for setup ansible-playbook playbooks/vm-deploy.yml --tags network,vms --ask-become-pass -e vm_name=<vm-name> -e tailscale_auth_key=<key> # OR - Update specific vm ansible-playbook playbooks/vm-deploy.yml --tags network,vms --ask-become-pass -e vm_name=<vm-name>
- Update nftables (required only for network changes, including VM creation/destruction)
ansible-playbook playbooks/host-setup.yml --ask-become-pass --tags nftables
- Verify VM(s) are reachable via Tailscale SSH after boot
- Ensure
inventory/hosts.ymlincludes VM host entries you want to configure with VM-level playbooks
If vcpus or memory_mib are modified and the VM was already running, the definition is updated and the VM is restarted so the new CPU/memory values take effect.
The disk size must be manually increased once the VM is created.
# Stop the VM
virsh shutdown <vm-name>
# Find the disk image path
virsh domblklist <vm-name>
# Create a backup of the image
cp /path/to/disk.qcow2 /path/to/disk.qcow2.bak
# Check the current size
qemu-img info /path/to/disk.qcow2
qemu-img resize /path/to/disk.qcow2 +20G # add 20GB
qemu-img resize /path/to/disk.qcow2 50G # set absolute size to 50GB
# Start the VM
virsh start <vm-name>
# Delete backup
rm -rf /path/to/disk.qcow2.bakSee Hermes Agent Setup for how to configure a VM for Hermes.
See Yoke Server Setup for deploying Yoke.
- Destroy a VM (domain + disk + seed ISO + cloud-init files):
ansible-playbook playbooks/vm-deploy.yml -e vm_state=absent -e vm_name=<vm-name> --ask-become-pass
Each VM gets its own libvirt network (<name>-net) with a dedicated bridge (vmbrNN) and /30 subnet (10.200.N.0/30). This ensures inter-VM traffic must traverse the host's FORWARD chain where nftables can filter it.
Inter-VM traffic is denied by default. To allow a specific flow, add an entry to vm_interconnects:
vm_interconnects:
- from: agent-vm-1 # source VM name (must match vms[].name)
to: agent-vm-2 # destination VM name
port: 8080 # TCP port to allowNetwork bridge and subnet assignments are derived from the VM's position in the vms list:
- Bridge:
vmbr{offset + index}(default offset: 10, so vmbr10, vmbr11, ...) - Subnet:
10.200.{base + index}.0/30(default base: 0, so 10.200.0.0/30, 10.200.1.0/30, ...)
It is strongly recommended to ensure your Tailscale ACL configuration is setup to deny-by-default (no ACL rules => allow-by-default).
Create two tags - personal and agents. Assign personal to devices you use to access the host/VM. Assign agents to the host/VM (can be done when generating auth key).
Apply an ACL rule:
{
"src": ["tag:personal"],
"dst": ["tag:agents"],
"ip": ["7777", "443"] // 7777 - non standard SSH port, 443 - Caddy HTTPS
}
Create one tag (eg. agents), and assign it to the Host/VM (can be done when generating auth key).
{
"src": ["*"],
"dst": ["tag:agents"],
"ip": ["7777", "443"] // 7777 - non standard SSH port, 443 - Caddy HTTPS
}