One-command Debian VM lifecycle: preseed → install → launch.
- Overview
- Prerequisites
- Quick Start
- Configuration
- Operational Modes
- Interactive Wizard
- VM Config Files
- Project Structure
- License
vmctl is a single Bash entrypoint that drives the full Debian VM lifecycle
under QEMU/KVM — from first configuration to a running, SSH-accessible
machine.
Wizard ──► Preseed ──► Download ──► Inject ──► Install ──► Launch
│ │ │ │ │ │
collect generate fetch prepend unattended boot from
config .cfg file netboot preseed d-i run disk image
options + hash pwd kernel & to initrd
initrd.gz
Input: interactive prompts | --yes | --config FILE | CLI flags
The wizard lets you choose the Debian release, configure multiple port-forward
rules, and set all preseed options. After installation a .vmcfg sidecar is
written next to the disk image so you can re-launch any previously built VM
with ./vmctl.sh --launch and pick from a menu.
Config priority (lowest → highest):
hardcoded defaults → ~/.vmctlrc → --config FILE → CLI flags
| Tool | Package (Debian/Ubuntu) | Purpose |
|---|---|---|
qemu-system-x86_64 |
qemu-system-x86 |
VM emulation — KVM required |
qemu-img |
qemu-utils |
qcow2 disk image creation |
cpio |
cpio |
Preseed injection into initrd |
curl |
curl |
Installer download + Docker install |
awk |
gawk |
Preseed template substitution |
openssl |
openssl |
SHA-512 password hashing |
sha256sum |
coreutils |
Installer integrity verification |
nc |
netcat-openbsd |
SSH port polling in verify_deploy.sh |
sudo apt-get install \
qemu-system-x86 qemu-utils cpio curl gawk openssl netcat-openbsd# Interactive full run — wizard + install
./vmctl.sh
# Fully non-interactive, all defaults accepted
./vmctl.sh --yes
# Load a config file, prompt only for missing values
./vmctl.sh --config my.cfg
# Generate a preseed file only, no VM created
./vmctl.sh --preseed-only
# Use an existing preseed, skip the wizard
./vmctl.sh --deploy-only preseeds/debian-vm.cfg
# Boot a specific disk image directly
./vmctl.sh --launch .vms/debian-vm.qcow2
# Interactive VM picker — choose from all saved VMs
./vmctl.sh --launch
# Full run with SSH readiness polling after install
./vmctl.sh --yes --wait-ssh
# Dry run — print QEMU commands without executing
./vmctl.sh --yes --dry-runCreate a file with KEY=value lines and pass it with --config FILE, or save
it as ~/.vmctlrc to load it automatically on every run.
# ~/.vmctlrc (or any file passed with --config)
# VM identity
VM_NAME="myvm"
DEBIAN_VERSION="bookworm"
# Guest identity (preseed)
HOSTNAME="myhost"
DOMAIN="home.lan"
USERNAME="alice"
FULLNAME="Alice Example"
PASSWORD="secret" # plain text — hashed to SHA-512 before embedding
SUDO_NOPASSWD="y"
# Resources
RAM="4096"
VCPUS="4"
DISK_SIZE="30"
# Networking — multiple port forwards, comma-separated
PORTS="2222:22,8080:80,9090:9090"
NET_MODE="user"
# Localization (preseed)
LOCALE="en_US.UTF-8"
KEYMAP="us"
TIMEZONE="America/New_York"
# Features
INSTALL_DOCKER="y"
ADD_DOCKER_GROUP="y"
CLEANUP_LEVEL="standard"
POWEROFF="true"
# VirtFS shared folder (optional)
SHARE_NAME="hostshare"
SHARE_GUEST_PATH="/home/alice/share"
SHARE_PATH="/home/alice/projects"| Key | Default | CLI flag | Description |
|---|---|---|---|
VM_NAME |
debian-vm |
--name |
VM name and disk image prefix |
RAM |
2048 |
--ram |
Memory in MB |
VCPUS |
2 |
--cpus |
Virtual CPU count |
DISK_SIZE |
20 |
--disk-size |
Disk image size in GB |
DEBIAN_VERSION |
stable |
--version |
Debian release (e.g. bookworm) |
DISPLAY_MODE |
nographic |
--display |
nographic | gtk | sdl |
NET_MODE |
user |
--net-mode |
user | tap | bridge |
PORTS |
(empty) | --ports |
Port map HOST:GUEST,... |
TAP_IF |
tap0 |
--tap-if |
TAP interface (tap mode) |
BRIDGE_NAME |
br0 |
--bridge |
Bridge name (bridge mode) |
MAC_ADDR |
(empty) | --mac |
MAC address (optional) |
CACHE_DIR |
.cache/ |
--cachedir |
Installer file cache directory |
VM_DIR |
.vms/ |
--vmdir |
qcow2 disk images directory |
SHARE_PATH |
./ |
--share-path |
Host directory for VirtFS share |
| Key | Default | CLI flag | Description |
|---|---|---|---|
HOSTNAME |
debian-vm |
--hostname |
Guest hostname |
DOMAIN |
local |
--domain |
Guest domain |
USERNAME |
user |
--username |
Primary user account name |
FULLNAME |
Debian User |
--fullname |
User display name |
PASSWORD |
changeme |
--password |
Plain text password (hashed to SHA-512) |
SUDO_NOPASSWD |
n |
--sudo-nopasswd |
y to enable passwordless sudo |
UIDVAL |
caller's UID | --uid |
Guest user UID |
GIDVAL |
caller's GID | --gid |
Guest user GID |
LOCALE |
en_US.UTF-8 |
--locale |
System locale |
KEYMAP |
us |
--keymap |
Keyboard layout |
TIMEZONE |
UTC |
--timezone |
System timezone |
DISK |
/dev/vda |
--disk-target |
Installer target disk |
PROXY |
(empty) | --proxy |
APT proxy URL |
EXTRA_PACKAGES |
(empty) | --extra-packages |
Space-separated extra packages |
INSTALL_DOCKER |
n |
--docker |
y to install Docker |
ADD_DOCKER_GROUP |
n |
--add-docker-group |
y to add user to docker group (requires Docker) |
SHARE_NAME |
(empty) | --share-name |
VirtFS mount tag |
SHARE_GUEST_PATH |
(empty) | --share-guest-path |
Mount point inside guest |
POST |
(empty) | --post |
Post-install shell command |
POWEROFF |
true |
--poweroff |
Power off guest after install |
CLEANUP_LEVEL |
standard |
--cleanup-level |
none | standard | aggressive |
| Mode | Trigger | What happens |
|---|---|---|
| Full | (default) | Wizard → preseed → cache → disk → install → [boot] |
| Preseed only | --preseed-only |
Wizard → write preseed file → exit |
| Deploy only | --deploy-only PRESEED |
Skip wizard; use supplied preseed → install |
| Launch | --launch DISK |
Boot a specific disk image directly |
| Launch picker | --launch |
Menu of saved VMs; picks one and boots it |
| Dry run | --dry-run |
Print QEMU commands without executing |
| Wait for SSH | --wait-ssh |
After install, poll SSH port until open (up to 5 min) |
The wizard runs 10 sections in order and skips any section whose value was
already supplied via --config or a CLI flag. --yes bypasses all prompts
and accepts defaults.
| Section | Topic | Key variables set |
|---|---|---|
| 0 | Debian release | DEBIAN_VERSION |
| 1 | Identity | HOSTNAME, DOMAIN, USERNAME, FULLNAME, PASSWORD, SUDO_NOPASSWD |
| 2 | Access | SSH key, UIDVAL, GIDVAL |
| 3 | Localization | LOCALE, KEYMAP, TIMEZONE |
| 4 | Storage | DISK |
| 5 | Network (APT) | PROXY |
| 6 | Packages | EXTRA_PACKAGES |
| 7 | Features | INSTALL_DOCKER, ADD_DOCKER_GROUP, SHARE_NAME, SHARE_GUEST_PATH |
| 8 | Post-install | POST, POWEROFF |
| 9 | Optimization | CLEANUP_LEVEL |
The wizard presents a numbered menu. You can pick a number or type any codename directly:
=== 0. Debian Release ===
[1] stable (current stable release — default)
[2] bookworm (Debian 12)
[3] bullseye (Debian 11)
[4] buster (Debian 10)
[5] trixie (testing)
[6] sid (unstable)
Selection or custom codename [stable]:
The chosen version determines the netboot installer URL and the SHA256SUMS
verification URL used to validate the downloaded kernel and initrd.
Port forwards are collected one at a time in a loop. The current list is shown after each addition so you can verify what has been configured. Leave the prompt empty to finish.
=== Port Forwarding ===
No mappings configured yet.
Add mapping HOST:GUEST (e.g. 2222:22), or press Enter to finish: 2222:22
Current mappings:
2222:22
Add mapping HOST:GUEST (e.g. 2222:22), or press Enter to finish: 8080:80
Current mappings:
2222:22
8080:80
Add mapping HOST:GUEST (e.g. 2222:22), or press Enter to finish:
Configured: 2222:22,8080:80
Passing --ports "2222:22,8080:80" on the CLI skips the interactive loop
entirely. Any number of HOST:GUEST pairs are accepted in both styles.
After the disk image is created (before the install starts), vmctl writes a
<VM_NAME>.vmcfg sidecar alongside the .qcow2 in $VM_DIR (default
.vms/). Because it is written before installation begins, the sidecar
persists even if an install fails.
# .vms/myvm.vmcfg (auto-generated)
VM_NAME="myvm"
DISK_IMG="/data/vmctl/.vms/myvm.qcow2"
RAM="4096"
VCPUS="4"
PORTS="2222:22,8080:80"
DISPLAY_MODE="nographic"
NET_MODE="user"
TAP_IF="tap0"
BRIDGE_NAME="br0"
MAC_ADDR=""
SHARE_NAME=""
SHARE_PATH="/data/vmctl"
VIRTFS_ARGS=""
DEBIAN_VERSION="bookworm"
CREATED_AT="2026-03-02 14:30:00"Running ./vmctl.sh --launch without a disk path scans .vms/*.vmcfg,
filters to those whose DISK_IMG still exists, and presents a menu:
=== Available VMs ===
[1] myvm RAM: 4096M Ports: 2222:22,8080:80 Debian: bookworm
Disk : /data/vmctl/.vms/myvm.qcow2
Created: 2026-03-02 14:30:00
[2] testvm RAM: 2048M Ports: (none) Debian: stable
Disk : /data/vmctl/.vms/testvm.qcow2
Created: 2026-03-02 09:15:00
Select VM [1]:
The selected config is sourced into the shell and all settings — RAM, ports, VirtFS, display mode, network mode — are restored exactly as they were during installation.
vmctl/
vmctl.sh ← single entrypoint
lib/
common.sh ← logging, colors, validators, port collector
config.sh ← defaults, CLI parsing, save_vm_config(), select_vm()
preseed.sh ← wizard, preseed generation, injection, poweroff detection
cache.sh ← installer download + SHA256 integrity check
qemu.sh ← QEMU command builder, disk creation, run phases
scripts/
replace_placeholders.awk ← awk-based preseed template substitution engine
templates/
preseed_template.cfg ← Debian preseed template with placeholders
preseeds/ ← generated preseed files (*.cfg)
.vms/ ← qcow2 disk images + VM config sidecars (*.vmcfg)
.cache/ ← downloaded linux + initrd.gz (per Debian version)
logs/ ← per-run log files + latest.log symlink
tools/
verify_deploy.sh ← SSH readiness checker (used by --wait-ssh)
Zero Clause BSD — do whatever you want, no conditions.