README
¶
Tank
Deterministic VM images. Disposable machines. Built for libvirt.
Tank is an opinionated, Unix-style tool for building and running virtual machines locally using libvirt and KVM.
If you want VMs that feel as cheap and repeatable as containers—but remain real machines—Tank is for you.
What Tank does
Tank has two responsibilities:
- Build immutable VM images from files and shell scripts
- Run disposable virtual machines from those images using libvirt
Command reference
tank init <base-url>— Initialize a new project with BASE, cloud-init, and a starter layertank start [name] [--no-cache]— Build image (if needed) and start the VM (skip cached build stages)tank stop [name]— Stop the VMtank destroy [name]— Stop and remove the VM completelytank ssh [name]— Connect to the VM over SSHtank status [name] [--json]— Show project status: instance state, IP, build cache, image freshness, layers, and volumestank list [--json]— List all instances with status and IP (tank lsandtank psare aliases)tank build [--no-cache]— Build the VM image without starting (skip cached build stages)tank prune [--apply] [--explain <hash>]— Show or remove unreachable cached build artifactstank pin <hash>— Keep a cached build even if nothing currently uses ittank unpin <hash>— Remove a build pintank layers [--json]— List layers with content hashestank volume list [--instance <name> | --all] [--json]— List persistent volumes (tank volume lsis an alias)tank volume rm <name>— Remove a persistent volume
--json output is currently experimental. The available fields and schema may change between releases.
Run multiple instances from the same image:
tank start # uses directory name, e.g. "myproject"
tank start secondary --cpus 4 # custom name "secondary"
tank start dev --memory 8192 # custom name "dev"
Optional arguments to tank start:
--cpus N— CPU count (default: 2)--memory MB— RAM in MB (default: 4096)--disk SIZE— Disk size (default: 40G)
The filesystem is the interface
Tank projects are driven entirely by the filesystem.
A minimal project:
myproject/
├── BASE
├── layers/
│ ├── 10-common/
│ ├── 20-devtools/
│ └── 90-project/
└── cloud-init.yaml
Base images (explicit and pinned)
Every image has a BASE layer.
The BASE file can be:
- a qcow2 file (or symlink)
- a text file containing a remote URL (
https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img)`
Bases are:
- downloaded or imported once
- cached locally
- immutable
Layers: composable, ordered, filesystem-driven
Layers are directories under layers/.
Each layer may contain:
preboot— executed on the host before instance creation (can edit cloud-init)install— executed during image build (executes directly if it has a shebang; otherwise defaults to/bin/sh)firstboot— executed on first VM boot (executable)files/— filesystem overlay copied verbatimvolumes/— persistent storage and network mount declarations
Example:
layers/
├── 10-common/
│ ├── install
│ └── files/
│ └── etc/
│ └── motd
├── 20-devtools/
│ ├── install
│ └── files/
│ └── usr/
│ └── local/
│ └── bin/
└── 90-project/
└── install
Composition rules
- Layers are applied in lexicographic order
- Later layers override earlier files
- Scripts execute in the same order
- Everything is deterministic
There is no hidden merge logic—just filesystem semantics.
Prerequisites
Tank requires libvirt, QEMU/KVM, and uses qemu:///system for VM management.
System packages
libvirt(providesvirsh)qemu-full(or equivalent, providesqemu-img)guestfs-tools(providesvirt-customizefor applying layers)virt-install(deb:virtinst, rpm:virt-install)genisoimage(ormkisofs/xorrisofor cloud-init ISOs)
Guestfs appliance cache
Tank relies on virt-customize (libguestfs). On some distros (notably Ubuntu),
libguestfs cannot build its supermin appliance without a readable host kernel.
Tank will try to use a fixed appliance in this order:
- Cached appliance in
/var/lib/tank/guestfs/ - System appliance (eg.
/usr/lib/libguestfs/appliance) - Build a fixed appliance with
libguestfs-make-fixed-appliance - Download a prebuilt appliance from
download.libguestfs.org
You can override the appliance path by setting LIBGUESTFS_PATH before running
tank.
Build defaults
Tank defaults to a larger build appliance memory size and root disk size to avoid surprises during heavy installs:
TANK_BUILD_MEM_MBsets the libguestfs appliance memory used byvirt-customize(defaults to 8192). This is applied viaLIBGUESTFS_MEMSIZEwhen not already set.TANK_BUILD_ROOT_SIZEsets the fallback root disk size for builds (defaults to 50G). Layervolumes/rootdeclarations still take precedence.
Groups
Your user must be in the libvirt and kvm groups:
sudo usermod -aG libvirt,kvm $USER
Log out and back in for the groups to take effect.
The libvirt group grants access to virsh and the system connection. The kvm
group grants access to /dev/kvm for hardware-accelerated virtualization — without
it, QEMU falls back to software emulation (TCG), which is dramatically slower.
Storage directory
Tank stores images and instances in /var/lib/tank. Create it with:
sudo mkdir -p /var/lib/tank
sudo chown root:libvirt /var/lib/tank
sudo chmod 2775 /var/lib/tank
This gives:
rootownership (conventional for/var/lib)libvirtgroup with write access (so anylibvirtgroup member can runtank)- Setgid bit so new files/directories inherit the
libvirtgroup libvirt-qemu(the user QEMU runs as under system mode) can read images via world-readable permissions
Networking + firewall
Tank uses libvirt's default network (virbr0) for DHCP, DNS, and NAT. If your host
firewall blocks DHCP or routed traffic, VMs will boot but never get an IP address.
The Debian/RPM packages apply the following on install:
- ensure libvirt's
defaultnetwork is started and autostarted - add a UFW profile allowing DHCP/DNS on
virbr0 - add a routed UFW rule so guests can reach the outside world
If you use UFW manually, the equivalent commands are:
sudo ufw app update Tank
sudo ufw allow in on virbr0 to any app Tank
sudo ufw route allow in on virbr0 out on <uplink>
Replace <uplink> with your host's outbound interface (e.g., eth0 or wlan0).
Storage model (qcow2 backing chains)
Tank stores everything under /var/lib/tank/.
/var/lib/tank/
├── images/
│ └── <base-image-name>.img
├── builds/
│ └── <project-hash>.qcow2
└── instances/
└── <instance-name>/
├── disk.qcow2
└── cloud-init.iso
Instance disks
Each instance gets a copy-on-write overlay:
builds/<project-hash>.qcow2 (immutable, shared)
↑
instances/<name>/disk.qcow2 (mutable, per-instance)
This allows:
- multiple instances from the same build
- fast instance creation
- changes isolated per instance
Automatic cache cleanup
Tank automatically prunes unreachable, unpinned cached builds after a
successful tank build or tank start.
Tank keeps cached build artifacts that are still reachable from:
- the latest recorded build for an existing project
- any instance disk still backed by that build chain
- any build you explicitly pinned with
tank pin <hash>
Anything else in builds/ is eligible for removal.
You can inspect or manage this directly:
tank statusshows whether reclaimable build cache existstank pruneshows what would be reclaimedtank prune --applyremoves unreachable cached builds immediatelytank prune --explain <hash>explains why a build is kept or reclaimabletank pin <hash>/tank unpin <hash>override automatic cleanup
See docs/GC.md for the full garbage-collection model.
Volumes: persistent storage that survives rebuilds
Layers can declare persistent volumes that are created, formatted, and mounted automatically. Destroy a VM, rebuild it, start it again — your data is still there.
tank prune and automatic build pruning do not delete persistent volumes.
Layer volumes
Add a volumes/ directory to any layer. Each file declares one volume:
layers/50-postgres/
├── install
├── firstboot
└── volumes/
└── pgdata
layers/50-postgres/volumes/pgdata:
mount: /var/lib/postgresql
size: 20G
That's it. Tank will:
- create the qcow2 volume if it doesn't exist
- attach it to the VM
- format and mount it before your
firstbootscript runs
The postgres layer can be symlinked into any project and it brings its storage requirement with it.
Root disk sizing
Any layer can declare a root disk size requirement:
layers/50-big-models/
└── volumes/
└── root
layers/50-big-models/volumes/root:
size: 200G
When multiple layers declare root sizes, Tank uses the largest value. A machine-learning layer that needs 200G just says so — any project that includes it gets the right disk size.
During builds, Tank also grows the root filesystem inside the resized image
using libguestfs. This keeps virt-customize from running out of space when
install scripts expect the larger disk. If the filesystem type is unsupported
or can't be detected, the resize step is skipped and the build may still fail.
By default, builds assume a 50G root disk even when no layer declares a root
volume. Override this default with TANK_BUILD_ROOT_SIZE or add a
volumes/root declaration in any layer.
Network mounts
Network filesystems use the same volumes/ directory:
layers/90-nfs/volumes/shared
mount: /mnt/shared
source: 192.168.1.10:/export/data
type: nfs
options: rw,soft
If the file has a source:, it's a network mount. If it has a size:, it's
a block volume.
The rebuild experience
$ tank destroy
▸ Force stopping VM tank-myproject
▸ Removing instance files
✓ Instance myproject destroyed
Persistent volumes retained: pgdata (20G)
$ tank start
▸ Creating overlay disk
▸ Reattaching volume pgdata (20G) → /var/lib/postgresql
▸ Starting VM tank-myproject
✓ Instance myproject started
Volume management
tank volume list # volumes for instances in this project
tank volume list --instance myproject-secondary
tank volume list --all # all volumes, including orphaned
tank volume rm myproject-pgdata # delete a volume (with confirmation)
See docs/VOLUMES.md for the full volume reference.
Optional: cloud-init
Cloud-init is supported only for first-boot identity:
- SSH keys
- hostnames
- per-instance users
Layers can also include a preboot host hook to edit the generated cloud-init
before the VM boots (for example, to inject short-lived secrets).
Preboot hooks
The preboot script runs on the host before instance creation. It receives:
TANK_PROJECT_ROOT— absolute path to project rootTANK_INSTANCE_NAME— resolved instance nameTANK_LAYER_PATH— absolute path to the current layerTANK_CLOUD_INIT— writable path to the cloud-init user-data fileTANK_WORK_DIR— temporary directory for hook scratch files
Hooks run in layer order and can edit TANK_CLOUD_INIT in place. If a hook exits
non-zero, tank start aborts with an error.
Images remain reusable. Instances remain unique.
Claude Code
Tank includes a Claude Code skill. Install it with:
npx skills add https://github.com/rhettg/tank
Philosophy
Machines are cheap. Images are intentional. Rebuild instead of repair.
Tank brings container-style ergonomics to virtual machines—without pretending VMs are containers.