A comprehensive, distribution-agnostic guide to automating VM creation and management with UTM on macOS.
Abstract: This guide documents hard-won lessons from real-world UTM automation, focusing on the challenges unique to UTM/QEMU on macOS rather than any specific Linux distribution.
Version: 1.0.0 (2025-10-25) - Production-ready automation patterns validated For information on this versioning scheme, see Status & Versioning
Status: ✅ Production - This guide documents generic UTM/QEMU automation mechanics applicable to any Linux distribution. Extensively validated with Alpine Linux automation. Distribution-specific details (Alpine, etc.) are in separate guides.
Tested Environment: UTM 4.x, macOS 26.x, ARM64 (M-series)
Origin: Based on hands-on work automating Alpine Linux VMs. Alpine-specific content moved to Alpine UTM Guide.
Copyright & License:
- Text: Copyright © 2025 by Christopher Allen, licensed under CC-BY-SA-4.0
- Code: Released to the public domain under CC0-1.0
Tags: #utm · #macos · #virtualmachine · #scripting · #cli · #automation · #guide
If you find this guide valuable, consider supporting my open source and digital civil rights advocacy efforts.
I work to represent smaller developers in a vendor-neutral, platform-neutral way, advancing the open web, digital civil liberties, and human rights. Your sponsorship helps sustain this work and ensures I can continue creating resources like this guide.
Become a sponsor: GitHub Sponsors (from $5/month)
This isn't just a transaction—it's an opportunity to plug into a network advancing the digital commons. Let's collaborate!
-- Christopher Allen
Just want to get started? Here's are minimal tips on automation that were my "gotchas":
-
VM creation via AppleScript works! ⚡ UPDATE: While
utmctl createdoesn't exist, you can create VMs programmatically via AppleScript. See Installation Automation for the complete breakthrough. For quick start: create your first template manually via UTM GUI or use AppleScript automation, then useutmctl clonefor subsequent VMs. -
Understand the critical limitation: UTM caches VM configurations in memory. After editing
config.plist, you must quit and restart UTM. -
Use the removable boot path: UEFI NVRAM doesn't persist on UTM. Your bootloader must live at
/boot/EFI/BOOT/BOOTAA64.EFI(ARM64) or/boot/EFI/BOOT/BOOTX64.EFI(x86_64). -
Fix GRUB with a shim: If GRUB drops to a minimal shell, create
/boot/EFI/BOOT/grub.cfg(directly in/boot/EFI/BOOT/, NOT in a grub/ subdirectory) that points to your real config. -
Install QEMU guest agent: This enables
utmctl ip-address <vm-name>for automatic IP detection.
Still confused? Read the Complete Workflow section for a working example.
- Quick Tips
- Known Limitations & Prerequisites
- UTM Architecture
- VM Control via utmctl
- VM Configuration (config.plist)
- UEFI Boot (ARM64 vs x86_64)
- Installation Automation ⚡ NEW
- GRUB Bootloader Automation
- QEMU Guest Agent
- Networking
- Common Pitfalls
- Complete Workflow
- Advanced Topics
- Resources
The honest truth about UTM automation (updated 2025-10-19):
While utmctl create doesn't exist, VM creation via AppleScript now works! This breakthrough enables complete ISO-to-template automation. See Installation Automation section for details.
What works reliably:
- ✅ Creating VMs programmatically via AppleScript ⚡ NEW
- ✅ Serial console automation (TCP mode for expect scripts) ⚡ NEW
- ✅ Cloning existing VMs (
utmctl clone) - ✅ Modifying VM configurations (plist editing)
- ✅ Automating post-boot tasks (SSH, serial console)
- ✅ Template-based workflows (clone → customize → destroy)
Historical limitations (now solved):
❌ Creating bootable VM from ISO programmatically→ ✅ AppleScript solution❌ Automated fresh installation without manual steps→ ✅ Answer file + serial automation
Note: This section documents the challenges that led to the AppleScript solution. If you're just getting started, skip to Installation Automation.
When creating a VM with both an empty disk and a CD-ROM via plist manipulation alone:
- Empty VirtIO disk may boot to UEFI shell instead of CD-ROM
- USB CD-ROM interface doesn't create bootable filesystem in UEFI
- Boot order configuration via plist is unreliable
We tried:
- Different CD-ROM interfaces (USB, VirtIO, NVMe)
- Various plist configurations
- Boot order manipulation
Result: Couldn't achieve reliable boot from ISO via plist alone.
Solution: Use AppleScript to create VMs with proper drive configuration. UTM's AppleScript interface handles boot order correctly where manual plist editing fails.
Option 1: Complete Automation via AppleScript ⚡ NEW - Recommended for CI/CD
- Create VM via AppleScript with ISO and disk
- Configure serial console for automation (TCP mode)
- Restart UTM (required for config changes)
- Automate installation via answer file + expect scripts
- Result: Fully automated ISO-to-template workflow (~5 minutes)
See Installation Automation for complete example.
Option 2: Create Initial Template via GUI (Recommended for learning)
- Use UTM GUI to create your first VM
- Boot from ISO and install Linux manually
- Apply fixes from this guide (GRUB, networking, SSH)
- Use
utmctl clonefor all subsequent VMs
Option 3: Manual UEFI Boot (Historical - no longer recommended)
This approach is superseded by AppleScript automation.
After you have a working template:
- Complete automation for cloning and customization
- Configuration management via plist editing
- Serial console automation (login, configuration, testing)
- Network and service management
- Deploy-test-destroy workflows
Think of it as: Infrastructure as Code for existing VMs, not VM provisioning from bare metal.
If you're:
- Testing applications → Template cloning works great
- Running CI/CD → Template cloning works great
- Managing multiple VMs → Template cloning works great
- Bootstrapping from nothing → You'll need GUI or manual steps for the first VM
UTM is fundamentally a macOS GUI wrapper around QEMU. Understanding this relationship is critical for automation:
- UTM GUI: User interface and VM management layer
- QEMU: The actual hypervisor performing virtualization
- config.plist: Apple Property List (XML) configuration for each VM
- utmctl: Command-line tool for scriptable VM control
- EDK2/OVMF: UEFI firmware used for booting VMs
~/Library/Containers/com.utmapp.UTM/Data/Documents/
└── <vm-name>.utm/
├── config.plist # VM configuration (XML)
├── Data/
│ ├── <disk-uuid>.qcow2 # Virtual disk image
│ ├── efi_vars.fd # UEFI NVRAM (ephemeral!)
│ └── Images/ # Optional: ISO files
└── <vm-name>.png # Optional: VM icon
This is the #1 source of confusion when automating UTM:
UTM's UEFI NVRAM (efi_vars.fd) does not persist reliably across UTM restarts. Boot entries created with efibootmgr will work until you quit UTM, then vanish.
Implication: You cannot rely on persistent UEFI boot entries. Always use the removable media fallback path:
- ARM64:
\EFI\BOOT\BOOTAA64.EFI - x86_64:
\EFI\BOOT\BOOTX64.EFI
This is not a bug—it's how UTM implements UEFI. Design your automation accordingly.
UTM provides utmctl for command-line VM control. Essential commands:
utmctl list- List all VMsutmctl start/stop <vm>- Control VM stateutmctl clone <source> <new>- Clone VMsutmctl ip-address <vm>- Get IP (requires guest agent)
Complete utmctl reference: utm-alpine-kit UTM Fundamentals
The most important thing to understand about UTM automation:
UTM loads config.plist into memory at startup. Editing while UTM runs has NO effect until you quit and restart UTM.
Always use this workflow:
utmctl stop <vm-name>
osascript -e 'quit app "UTM"'
sleep 3
# Edit config.plist here
open -a UTM
sleep 5
utmctl start <vm-name>This is the #1 automation stumbling block. See utm-alpine-kit UTM Fundamentals for detailed explanation.
Each VM has a config.plist file storing all configuration in Apple Property List (XML) format.
Location:
~/Library/Containers/com.utmapp.UTM/Data/Documents/<vm-name>.utm/config.plist
Why it matters for automation:
- Modify RAM, CPU, drives, networking via plist editing
- Use
/usr/libexec/PlistBuddy(built into macOS) - Must quit/restart UTM after changes (configuration caching!)
Complete config.plist reference: utm-alpine-kit UTM Fundamentals
Boot order inspection:
ps aux | grep qemu | grep <vm-name>
# Look for: bootindex=0, bootindex=1, etc.Shows actual QEMU configuration including drive boot order. If CD-ROM shows bootindex=0, VM boots from ISO before disk—remove CD-ROM entry from config.plist after installation.
Common automation pattern:
- Create VM via AppleScript (proper boot order)
- Configure serial console via PlistBuddy
- Quit/restart UTM (load new config)
- Automate installation
- Remove ISO via PlistBuddy
- Quit/restart UTM (finalize config)
UTM/QEMU uses UEFI firmware (EDK2/OVMF) for booting. When no persistent boot entry exists (which is always, on UTM), firmware looks for:
| Architecture | Fallback Boot Path |
|---|---|
| ARM64 | \EFI\BOOT\BOOTAA64.EFI |
| x86_64 | \EFI\BOOT\BOOTX64.EFI |
This is the only reliable boot method on UTM.
Your boot partition must be:
- FAT32 filesystem (UEFI firmware can only read FAT)
- GPT partition type:
EFI System Partition(type codeEF00)
Example partitioning (GPT with fdisk):
# In VM, after booting installer
fdisk /dev/vda
Command: g # Create GPT partition table
Command: n # New partition
Partition number: 1
First sector: (default)
Last sector: +160M # 160MB for /boot
Command: t # Change partition type
Partition number: 1
Partition type: 1 # EFI System Partition
Command: n # New partition for root
Partition number: 2
First sector: (default)
Last sector: (default - use remaining space)
Command: w # Write changesFormat partitions:
mkfs.vfat -F 32 /dev/vda1 # Boot partition - MUST be FAT32
mkfs.ext4 /dev/vda2 # Root partition - any filesystemYour bootloader (GRUB, systemd-boot, etc.) must be installed at:
/boot/EFI/BOOT/BOOTAA64.EFI (ARM64)
# or
/boot/EFI/BOOT/BOOTX64.EFI (x86_64)
Where /boot is your mounted FAT32 ESP.
For GRUB:
mount /dev/vda1 /boot
grub-install --target=arm64-efi \
--efi-directory=/boot \
--bootloader-id=mylinux \
--removable \
/dev/vdaThe --removable flag is critical—it installs to the fallback path.
⚡ NEW (2025-10-19) - Complete ISO-to-template automation is now possible!
While utmctl create doesn't exist, UTM provides an AppleScript interface that enables programmatic VM creation.
Key Discovery: AppleScript correctly handles boot order where manual plist editing fails. Creating a VM with ISO + disk via AppleScript produces a bootable configuration.
#!/usr/bin/osascript
tell application "UTM"
# Define paths
set isoPath to POSIX file "/path/to/alpine.iso"
# Create VM with ISO and disk
set newVM to make new virtual machine with properties {¬
backend:qemu, ¬
configuration:{¬
name:"alpine-template", ¬
notes:"Alpine Linux template for automation", ¬
architecture:"aarch64", ¬
memory:2048, ¬
cpu cores:2, ¬
drives:{¬
{removable:true, source:isoPath}, ¬
{guest size:20480}¬
}, ¬
network interfaces:{{mode:bridged}}, ¬
displays:{{hardware:"virtio-gpu-gl-pci"}}¬
}¬
}
end tellWhat this creates:
- VM with 2GB RAM, 2 CPUs
- 20GB virtual disk
- CD-ROM with Alpine ISO
- Bridged networking (gets IP from your network)
- Proper boot order (ISO boots first)
For unattended installation, configure serial console in TCP server mode:
Why serial console?
- SSH isn't available until after installation
- VNC requires graphical interaction
- Serial console provides text-based automation interface
Configuration via PlistBuddy:
VM_PATH="$HOME/Library/Containers/com.utmapp.UTM/Data/Documents/alpine-template.utm"
CONFIG="$VM_PATH/config.plist"
# Add serial console configuration
/usr/libexec/PlistBuddy -c "Add :SerialPorts array" "$CONFIG" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SerialPorts:0 dict" "$CONFIG"
/usr/libexec/PlistBuddy -c "Add :SerialPorts:0:Mode string TcpServer" "$CONFIG"
/usr/libexec/PlistBuddy -c "Add :SerialPorts:0:TcpPort integer 4444" "$CONFIG"CRITICAL: UTM caches configuration in memory. After editing config.plist:
# Quit UTM
osascript -e 'quit app "UTM"'
sleep 3
# Restart UTM
open -a UTM
sleep 5Without this restart, your serial console configuration won't be loaded.
Many distributions support automated installation via answer files:
- Alpine Linux:
setup-alpine -f answerfile - Debian/Ubuntu: Preseed files
- Red Hat/Fedora: Kickstart files
- SUSE: AutoYaST
General pattern:
- Create answer file with installation parameters
- Serve answer file via HTTP (Python:
python3 -m http.server 8888) - Boot from ISO
- Fetch and apply answer file via serial console automation
Example Alpine automation: See Alpine UTM Guide for complete answer file details.
Use expect to automate interactive prompts via serial console:
#!/usr/bin/expect -f
set timeout 300
# Connect to serial console (TCP mode)
spawn nc localhost 4444
# Wait for login prompt
expect "localhost login:"
send "root\r"
expect "Password:"
send "yourpassword\r"
# Run installation commands
expect "# "
send "setup-alpine -f http://host.docker.internal:8888/answers\r"
# Handle prompts that answer file doesn't cover
expect {
"password" {
send "\r"
exp_continue
}
"Done" {
# Installation complete
}
}
# Reboot
send "reboot\r"
expect eofKey points:
nc localhost 4444connects to serial console TCP serverexpectpatterns match promptssendprovides responsesexp_continueloops for multiple matches
- Create VM via AppleScript (ISO + disk, proper boot order)
- Configure serial console (PlistBuddy → TCP mode)
- Restart UTM (required for config changes!)
- Start HTTP server (serve answer file)
- Run expect script (automate installation via serial console)
- Remove ISO (PlistBuddy or
utmctlafter installation) - Set password via SSH (if answer file can't set it)
- Template ready for cloning!
Time: ~5 minutes for full automation
Production implementation: See utm-alpine-kit for complete working example (450+ lines of tested automation).
UTM Configuration Caching:
- UTM loads
config.plistat startup only - Editing config while UTM runs has NO effect
- Must quit → edit → restart UTM for changes
AppleScript vs Plist:
- AppleScript handles boot order correctly
- Manual plist editing often fails to boot from ISO
- Use AppleScript for VM creation, plist for modifications
Serial Console Modes:
TcpServermode works for automation (nc/expect)Pttymode unreliable for scripting- Mode is case-sensitive in plist!
Answer File Limitations (Alpine-specific):
- Not all parameters work as documented
- Some prompts appear even with answer file
- Handle unexpected prompts in expect scripts
When you boot a freshly-installed VM, you might see:
GNU GRUB version 2.12
Minimal BASH-like line editing is supported...
grub>
Instead of your boot menu. This is the most common automation stumbling block.
- UEFI loads
/boot/EFI/BOOT/BOOTAA64.EFI(GRUB) - GRUB starts but doesn't know where its configuration lives (the "prefix" path)
- GRUB's default prefix
/EFI/BOOT/grub/doesn't containgrub.cfg - GRUB falls back to interactive shell
grub> echo $prefix
(cd0)/boot/grub # Wrong! It's looking at CD-ROM
grub> ls
(hd0) (hd0,gpt1) (hd0,gpt2) (cd0) ... # Shows available devices
grub> ls (hd0,gpt1)/
EFI/ grub/ vmlinuz-virt initramfs-virt # Your boot partition
grub> ls (hd0,gpt1)/grub/
grub.cfg ... # Your config is here!
grub> ls (hd0,gpt1)/EFI/BOOT/grub/
error: file '/EFI/BOOT/grub/' not found. # But GRUB is looking here!The fix: Create a shim config where GRUB expects to find it.
The most reliable approach is a minimal shim configuration that GRUB can execute without needing additional modules.
CRITICAL: grub-install auto-creates /boot/EFI/BOOT/grub.cfg with bash-like conditionals (if [, echo) that GRUB cannot execute without loading additional modules, causing "can't find command" errors at boot. You must replace this file with a minimal shim.
Different distributions have different GRUB issues on UTM:
-
Alpine Linux:
grub-installauto-created shim uses commands (if [,echo) that require modules not available at early boot. Additionally, Alpine's/boot/grub/grub.cfgis missing criticalinsmodcommands. See Alpine UTM Guide for the complete fix (prepend insmod to grub.cfg). -
Debian/Ubuntu: Usually works with standard shim approach below.
-
Fedora/RHEL: May need additional module loading.
Always test your specific distribution. The shim below works for most cases but may need distribution-specific adjustments.
/boot/ (FAT32 ESP, mounted here)
├── EFI/
│ └── BOOT/
│ ├── BOOTAA64.EFI (GRUB bootloader binary)
│ └── grub.cfg ← SHIM config (GRUB loads THIS first!)
├── grub/
│ └── grub.cfg ← REAL config (distro creates this)
├── vmlinuz-virt (kernel)
└── initramfs-virt (initramfs)
Replace /boot/EFI/BOOT/grub.cfg (NOT in a grub/ subdirectory):
search --no-floppy --fs-uuid --set=root E56A-A2FD
set prefix=($root)/grub
configfile ($root)/grub/grub.cfgReplace E56A-A2FD with your boot partition's UUID:
# Get boot partition UUID
blkid /dev/vda1
# Output: /dev/vda1: UUID="E56A-A2FD" TYPE="vfat" ...Why these three commands only:
search- Built into GRUB coreset- Built into GRUB coreconfigfile- Built into GRUB core
Do NOT use if, [, echo, or other commands - they require modules GRUB hasn't loaded yet.
- UEFI firmware loads
/boot/EFI/BOOT/BOOTAA64.EFI - GRUB looks for
/boot/EFI/BOOT/grub.cfg(at directory level, NOT in grub/ subdirectory) - GRUB executes our minimal shim (all commands are built-in, no errors)
- Shim searches for boot partition by UUID (works even if device names change)
- Shim sets
$prefixto($root)/grub(where real config lives) - Shim loads
/boot/grub/grub.cfg(your distro's real config with full modules) - Real config displays boot menu and boots normally
# Mount boot partition if not already mounted
mount /dev/vda1 /boot
# Get boot partition UUID
BOOT_UUID=$(blkid /dev/vda1 | grep -o 'UUID="[^"]*"' | cut -d'"' -f2)
# IMPORTANT: Replace grub-install's auto-generated file
# (grub-install creates this with bash-isms that cause boot errors)
cat > /boot/EFI/BOOT/grub.cfg << EOF
search --no-floppy --fs-uuid --set=root ${BOOT_UUID}
set prefix=(\$root)/grub
configfile (\$root)/grub/grub.cfg
EOF
# Verify - should be exactly 3 lines, no 'if' or '[' or 'echo'
cat /boot/EFI/BOOT/grub.cfgNote: Use \$root (escaped) in the heredoc to prevent shell expansion.
Note on distribution-specific GRUB behavior: Some distributions (notably Alpine Linux) create BOOTAA64.EFI with embedded configurations that bypass the shim file entirely. If the shim approach doesn't work for your distribution, consult distribution-specific guides. See Alpine UTM Guide for Alpine's specific GRUB module loading requirements.
You can also build a single GRUB EFI binary with embedded config:
grub-mkstandalone \
-O arm64-efi \
-o /boot/EFI/BOOT/BOOTAA64.EFI \
--modules="part_gpt part_msdos fat ext2 normal configfile linux boot search search_fs_uuid" \
/boot/grub/grub.cfg=/boot/grub/grub.cfgThis embeds your real config directly into the EFI binary. No shim needed.
Pros: Self-contained, no shim needed Cons: Must rebuild EFI binary when config changes (less flexible)
| Symptom | Cause | Fix |
|---|---|---|
| GRUB shell instead of menu | GRUB can't find grub.cfg |
Create shim at /boot/EFI/BOOT/grub.cfg |
echo $prefix shows (cd0)/boot/grub |
Booting from CD-ROM, not disk | Remove CD-ROM from config.plist, restart UTM |
| Boot menu appears but kernel fails | Missing root=UUID=... parameter |
Check real grub.cfg includes root= in linux line |
error: file '/grub/' not found |
Wrong filesystem or path | Verify boot partition is FAT32, mounted at /boot |
Boot errors: can't find command '[' |
grub-install created file with bash-isms | Replace /boot/EFI/BOOT/grub.cfg with minimal shim (3 lines only) |
At the grub> prompt, you can manually boot to test your setup:
grub> set root=(hd0,gpt1)
grub> set prefix=($root)/grub
grub> configfile ($root)/grub/grub.cfg
# Boot menu should appearIf this works, your config is correct—you just need the shim in place.
The QEMU guest agent enables host↔VM communication, most critically for automatic IP detection.
What it provides:
- IP address detection (
utmctl ip-address <vm>) - The killer feature for automation - Graceful shutdown from host
- Time synchronization
Why it matters for automation:
- No network scanning or manual IP lookups
- Works with DHCP and dynamic IPs
- Essential for template-based workflows
Usage:
VM_IP=$(utmctl ip-address my-vm | head -1)
ssh root@${VM_IP} 'command'Complete guest agent reference: utm-alpine-kit UTM Fundamentals
Common issue: utmctl ip-address fails → Guest agent not installed/running in VM. Install via package manager and enable on boot.
UTM supports multiple network modes. For automation, bridged mode is recommended.
Network modes:
| Mode | Use Case | IP Range | Key Feature |
|---|---|---|---|
| Bridged | Automation, multi-VM | Host LAN | Direct SSH access |
| Shared | Isolated testing | 10.0.2.x | No admin privileges needed |
| Host-only | Secure testing | Private | No internet |
Why bridged for automation:
- VM gets real IP from your network's DHCP
- Direct SSH access from anywhere on LAN
- Works with QEMU guest agent for IP detection
- Each cloned VM gets unique IP (via unique MAC)
Security note:
Complete networking reference: utm-alpine-kit UTM Fundamentals
MAC address generation:
# Each clone needs unique MAC for DHCP
printf '52:54:00:%02x:%02x:%02x\n' $((RANDOM%256)) $((RANDOM%256)) $((RANDOM%256))Symptom: You edit config.plist, restart the VM, but changes don't apply.
Cause: UTM caches VM configuration in memory when the app starts.
Solution:
utmctl stop <vm-name>
osascript -e 'quit app "UTM"'
sleep 3
open -a UTM
sleep 5
utmctl start <vm-name>Remember: Stopping/starting the VM is not enough. You must quit and restart UTM.
Symptom: OS installed successfully, but VM keeps booting installer.
Cause: CD-ROM still attached with bootindex=0 (higher boot priority than disk).
Diagnosis:
ps aux | grep qemu | grep <vm-name>
# Look for: -device usb-storage,drive=driveXXX,bootindex=0Solution:
- Stop VM
- Quit UTM
- Edit
config.plist- remove entire CD-ROM<dict>from<key>Drive</key>array - Restart UTM
- Start VM
Symptom: After booting, see grub> prompt instead of boot menu.
Diagnosis:
# At grub> prompt
echo $prefix # Shows where GRUB is looking for config
ls ($root)/ # List what's on the root device
ls (hd0,gpt1)/grub/ # Check for config on diskCommon causes:
- GRUB's prefix points to wrong location
- Booting from CD-ROM (
$prefixshows(cd0)/...) /boot/EFI/BOOT/grub.cfgshim missing or has errors
Solution: Create minimal shim at /boot/EFI/BOOT/grub.cfg (see GRUB Bootloader Automation)
Symptom:
Error: The QEMU guest agent is not running or not installed on the guest.
Diagnosis checklist:
# 1. Inside VM - is guest agent installed?
which qemu-ga
# 2. Is it running?
systemctl status qemu-guest-agent # systemd
rc-service qemu-guest-agent status # OpenRC
ps aux | grep qemu-ga
# 3. Does VM have network?
ip addr show eth0
# Should show an IP, not just "BROADCAST,MULTICAST"
# 4. Wait longer - agent takes time to start
sleep 10
utmctl ip-address <vm-name>Solution: Install and enable guest agent (see QEMU Guest Agent)
Symptom: Create files in VM, reboot, files gone.
Causes:
- Still booting from live CD/installer
- Root filesystem not mounting from disk
- OS in RAM-only mode (Alpine: diskless mode)
Diagnosis:
# Inside VM
cat /proc/cmdline
# Should show: root=UUID=... or root=/dev/vda2
mount | grep vda
# Should show: /dev/vda2 on / type ext4 ...
df -h
# Should show disk partitions, not tmpfs for /Solution:
- Ensure GRUB config includes
root=UUID=...in kernel command line - Remove CD-ROM from VM (see pitfall #2)
- Verify OS was installed in persistent mode (not "diskless" or "live")
Symptom: VM has IP address but ssh user@vm-ip fails with "Connection refused".
Diagnosis:
# From host - is port 22 reachable?
nc -zv <vm-ip> 22
# or
nmap -p 22 <vm-ip>
# Inside VM - is SSH running?
systemctl status ssh # or sshd
ss -tlnp | grep :22 # Should show sshd listening
# Is firewall blocking?
iptables -L INPUT -n -v | grep 22Common causes & fixes:
-
SSH not installed
apt install openssh-server # Debian/Ubuntu apk add openssh # Alpine
-
SSH not running
systemctl enable ssh systemctl start ssh -
SSH not enabled on boot
systemctl enable ssh # systemd rc-update add sshd default # OpenRC
-
Wrong username - try
rootif configured -
Key authentication required but no key added
# Inside VM mkdir -p ~/.ssh chmod 700 ~/.ssh echo "ssh-ed25519 AAAA... your-key" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
Symptom: VM starts but no network connectivity with bridged mode.
Cause: macOS hasn't granted UTM network permissions.
Solution:
- First VM start with bridged networking, macOS will prompt
- Grant permission
- Restart UTM and VM
If you missed the prompt:
- System Settings → Privacy & Security → Network
- Ensure UTM is allowed
Symptom: Two cloned VMs conflict on the network, connection drops, or one VM can't get DHCP.
Cause: utmctl clone copies the entire config.plist including the MAC address.
Diagnosis:
# Check MAC address in config.plist
grep -A1 MacAddress ~/Library/Containers/com.utmapp.UTM/Data/Documents/vm-name.utm/config.plistSolution: Regenerate unique MAC address after cloning. Critical: UTM must be completely quit before modifying config.plist, then restarted to load the new MAC.
# 1. Clone the VM
utmctl clone "template-vm" "new-vm"
# 2. Generate new MAC in QEMU range (52:54:00:xx:xx:xx)
NEW_MAC=$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM%256)) $((RANDOM%256)) $((RANDOM%256)))
# 3. CRITICAL: Quit UTM completely (config is cached in memory!)
osascript -e 'quit app "UTM"'
sleep 2
# 4. Update MAC in config.plist (Network is an array, index 0)
CONFIG_PLIST=~/Library/Containers/com.utmapp.UTM/Data/Documents/new-vm.utm/config.plist
plutil -replace 'Network.0.MacAddress' -string "$NEW_MAC" "$CONFIG_PLIST"
# 5. Restart UTM to load new configuration
open -a UTM
sleep 3
# 6. Start VM - it will now use the new MAC
utmctl start "new-vm"Why this is necessary: Simply editing config.plist while UTM is running has no effect because UTM caches all VM configurations in memory when it starts. The VM will continue using the template's MAC address until you quit and restart UTM.
Before starting, have ready:
- Installation ISO for your Linux distribution
- SSH public key for passwordless access
- 10-20GB free disk space
- Familiarity with your chosen distribution's installer
This creates a clean, minimal template VM suitable for cloning.
# Variables
VM_NAME="linux-template"
VM_DIR=~/Library/Containers/com.utmapp.UTM/Data/Documents/${VM_NAME}.utm
ISO_URL="https://example.com/installer.iso" # Replace with your distro
ISO_PATH=~/.cache/vms/installer.iso
# Download installer ISO
mkdir -p ~/.cache/vms
curl -L "$ISO_URL" -o "$ISO_PATH"
# Create VM directory
mkdir -p "${VM_DIR}/Data"
# Generate UUIDs
CD_UUID=$(uuidgen)
DISK_UUID=$(uuidgen)
VM_UUID=$(uuidgen)
# Create virtual disk
qemu-img create -f qcow2 "${VM_DIR}/Data/${DISK_UUID}.qcow2" 20Gcat > "${VM_DIR}/config.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Backend</key>
<string>QEMU</string>
<key>ConfigurationVersion</key>
<integer>4</integer>
<key>System</key>
<dict>
<key>Architecture</key>
<string>aarch64</string>
<key>CPU</key>
<string>default</string>
<key>CPUCount</key>
<integer>2</integer>
<key>MemorySize</key>
<integer>2048</integer>
<key>Target</key>
<string>virt</string>
</dict>
<key>QEMU</key>
<dict>
<key>Hypervisor</key>
<true/>
<key>UEFIBoot</key>
<true/>
</dict>
<key>Display</key>
<array>
<dict>
<key>Hardware</key>
<string>virtio-gpu-gl-pci</string>
<key>DynamicResolution</key>
<true/>
</dict>
</array>
<key>Input</key>
<dict>
<key>UsbSharing</key>
<false/>
</dict>
<key>Drive</key>
<array>
<dict>
<key>Identifier</key>
<string>${CD_UUID}</string>
<key>ImageType</key>
<string>CD</string>
<key>Interface</key>
<string>USB</string>
<key>InterfaceVersion</key>
<integer>1</integer>
<key>ReadOnly</key>
<true/>
</dict>
<dict>
<key>Identifier</key>
<string>${DISK_UUID}</string>
<key>ImageName</key>
<string>${DISK_UUID}.qcow2</string>
<key>ImageType</key>
<string>Disk</string>
<key>Interface</key>
<string>VirtIO</string>
<key>InterfaceVersion</key>
<integer>1</integer>
<key>ReadOnly</key>
<false/>
</dict>
</array>
<key>Network</key>
<array>
<dict>
<key>Hardware</key>
<string>virtio-net-pci</string>
<key>Mode</key>
<string>Bridged</string>
<key>IsolateFromHost</key>
<false/>
<key>MacAddress</key>
<string>$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM%256)) $((RANDOM%256)) $((RANDOM%256)))</string>
</dict>
</array>
<key>Information</key>
<dict>
<key>Name</key>
<string>${VM_NAME}</string>
<key>UUID</key>
<string>${VM_UUID}</string>
<key>IconCustom</key>
<false/>
</dict>
</dict>
</plist>
EOF
# Validate
plutil -lint "${VM_DIR}/config.plist"# Link ISO to VM
ln -s "$ISO_PATH" "${VM_DIR}/Data/${CD_UUID}.iso"
# Open UTM (it will discover the new VM)
open -a UTM
sleep 5
# Start VM
utmctl start "$VM_NAME"Complete installation via UTM console:
- Partition disk:
/dev/vda1: 160MB, FAT32, mount at/boot(EFI System Partition)/dev/vda2: Remaining space, ext4, mount at/
- Install bootloader to
/dev/vdawith--removableflag - Set up root password or create user
- Do NOT reboot yet
While still in installer environment (or chroot):
# 1. Install essentials
apt install qemu-guest-agent openssh-server # Debian/Ubuntu
# or
apk add qemu-guest-agent openssh bash # Alpine
# 2. Enable services
systemctl enable qemu-guest-agent ssh # systemd
# or
rc-update add qemu-guest-agent default # OpenRC
rc-update add sshd default
rc-update add networking boot
# 3. Configure network for DHCP
cat > /etc/network/interfaces << 'EOF'
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
EOF
# 4. Get boot partition UUID
BOOT_UUID=$(blkid /dev/vda1 | grep -o 'UUID="[^"]*"' | cut -d'"' -f2)
# 5. Create GRUB shim
cat > /boot/EFI/BOOT/grub.cfg << EOF
search --no-floppy --fs-uuid --set=root ${BOOT_UUID}
set prefix=(\$root)/grub
configfile (\$root)/grub/grub.cfg
EOF
# 6. Add your SSH key
mkdir -p /root/.ssh
chmod 700 /root/.ssh
cat > /root/.ssh/authorized_keys << 'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-key-here
EOF
chmod 600 /root/.ssh/authorized_keys
# 7. Ensure GRUB config has root parameter
grep "root=UUID=" /boot/grub/grub.cfg
# If missing, add to GRUB_CMDLINE_LINUX in /etc/default/grub and regenerate
# Now safe to reboot
rebootAfter successful first boot:
# Stop VM
utmctl stop "$VM_NAME"
# Quit UTM
osascript -e 'quit app "UTM"'
sleep 3
# Remove CD-ROM from config
# WARNING: The plutil command below replaces the entire Drive array with empty array
# This removes ALL drives including your disk! Only use if you'll recreate the disk entry.
# plutil -replace "Drive" -array "${VM_DIR}/config.plist"
# RECOMMENDED: Manually edit with text editor to remove only CD-ROM <dict>
# Find the <dict> with <key>ImageType</key><string>CD</string> and delete it
# Restart UTM
open -a UTM
sleep 5# Start VM
utmctl start "$VM_NAME"
# Wait for boot (adjust timing as needed)
sleep 30
# Get IP
VM_IP=$(utmctl ip-address "$VM_NAME" | head -1)
echo "VM IP: $VM_IP"
# Test SSH
ssh root@${VM_IP} 'uname -a'
# Test persistence
ssh root@${VM_IP} 'touch /persist-test && reboot'
sleep 30
VM_IP=$(utmctl ip-address "$VM_NAME" | head -1)
ssh root@${VM_IP} 'ls -l /persist-test' # Should exist
# Success! Template is ready#!/bin/bash
# clone-vm.sh - Clone template and auto-configure
set -euo pipefail
TEMPLATE="linux-template"
NEW_VM="$1"
if [ -z "$NEW_VM" ]; then
echo "Usage: $0 <new-vm-name>"
exit 1
fi
echo "Cloning $TEMPLATE to $NEW_VM..."
utmctl clone "$TEMPLATE" "$NEW_VM"
echo "Generating unique MAC address..."
NEW_MAC=$(printf '52:54:00:%02x:%02x:%02x' $((RANDOM%256)) $((RANDOM%256)) $((RANDOM%256)))
echo "Quitting UTM to modify configuration..."
osascript -e 'quit app "UTM"'
sleep 2
echo "Updating MAC address..."
CONFIG_PLIST=~/Library/Containers/com.utmapp.UTM/Data/Documents/${NEW_VM}.utm/config.plist
plutil -replace 'Network.0.MacAddress' -string "$NEW_MAC" "$CONFIG_PLIST"
echo "Restarting UTM..."
open -a UTM
sleep 3
echo "Starting $NEW_VM..."
utmctl start "$NEW_VM"
echo "Waiting for boot..."
sleep 30
echo "Getting IP address..."
for i in {1..10}; do
NEW_IP=$(utmctl ip-address "$NEW_VM" 2>/dev/null | head -1 || echo "")
if [ -n "$NEW_IP" ]; then
break
fi
sleep 3
done
if [ -z "$NEW_IP" ]; then
echo "❌ Failed to get IP address. Check QEMU guest agent."
exit 1
fi
echo "✅ New VM ready:"
echo " Name: $NEW_VM"
echo " IP: $NEW_IP"
echo " SSH: ssh root@${NEW_IP}"
# Optional: Test SSH connectivity
if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@${NEW_IP} 'hostname' &>/dev/null; then
echo " SSH: ✅ Working"
else
echo " SSH: ❌ Connection failed"
fiUTM provides an AppleScript API for automation beyond utmctl. This can be useful for GUI interactions or integrating with other macOS automation tools.
Get UTM version and properties:
tell application "UTM"
get properties
-- Returns: name, frontmost, class, UTM version
end tellList all VMs:
tell application "UTM"
set vmList to name of every virtual machine
return vmList
end tellFrom bash:
# List all VMs
osascript -e 'tell application "UTM" to get name of every virtual machine'
# Get UTM version
osascript -e 'tell application "UTM" to get UTM version'
# Quit UTM (useful after config.plist edits)
osascript -e 'quit app "UTM"'Check if specific VM exists:
#!/bin/bash
VM_NAME="alpine-template"
VM_EXISTS=$(osascript << EOF
tell application "UTM"
set vmList to name of every virtual machine
if vmList contains "$VM_NAME" then
return "yes"
else
return "no"
end if
end tell
EOF
)
if [ "$VM_EXISTS" = "yes" ]; then
echo "✅ VM $VM_NAME exists"
else
echo "❌ VM $VM_NAME not found"
fiRestart UTM (for config changes):
#!/bin/bash
# restart-utm.sh - Restart UTM to reload configs
echo "Quitting UTM..."
osascript -e 'quit app "UTM"'
sleep 3
echo "Starting UTM..."
open -a UTM
sleep 5
echo "✅ UTM restarted"- AppleScript API is limited compared to
utmctl - Cannot start/stop VMs directly via AppleScript (use
utmctlinstead) - Primarily useful for GUI queries and app lifecycle management
Recommendation: Use utmctl for VM operations, AppleScript for app-level automation.
If QEMU guest agent isn't available or desired:
#!/bin/bash
# find-vm-ip.sh - Find VM by MAC address
VM_MAC="52:54:00:12:34:56" # From config.plist
NETWORK="192.168.1" # Your network
# Method 1: ARP scan (slower, works everywhere)
echo "Scanning network..."
for i in {1..254}; do
ping -c 1 -W 1 ${NETWORK}.${i} >/dev/null 2>&1 &
done
wait
arp -a | grep -i "$VM_MAC" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}'
# Method 2: nmap (faster, requires nmap)
# nmap -sn ${NETWORK}.0/24 >/dev/null 2>&1
# nmap -sn ${NETWORK}.0/24 | grep -B 2 "$VM_MAC" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}'UTM supports multiple serial console modes. For automation and development, TCP Server mode is recommended over Ptty mode.
- Copy/paste support: Full macOS clipboard integration via telnet/nc
- No PTY discovery needed: Fixed port, no need to find
/dev/ttysXXX - Reliable: Works during boot, before network/SSH available
- Safe for automation: No risk of CLI lockups (unlike Ptty mode on macOS)
- Multiple connections: Can connect/disconnect without stopping VM
Correct syntax (case-sensitive!):
<key>Serial</key>
<array>
<dict>
<key>Mode</key>
<string>TcpServer</string> <!-- NOT "tcpServer" -->
<key>Target</key>
<string>Auto</string>
<key>TcpPort</key> <!-- NOT "TCPPort" -->
<integer>4444</integer>
</dict>
</array>CRITICAL:
- Mode must be
TcpServer(capital T, capital S) - Port field is
TcpPort(notTCPPort) - Invalid Mode values cause VM to disappear from UTM registry
- Port must be >1024 (privileged ports require root)
# Stop VM and quit UTM first
utmctl stop <vm-name>
osascript -e 'quit app "UTM"'
sleep 3
# Configure TCP serial console
CONFIG_PLIST=~/Library/Containers/com.utmapp.UTM/Data/Documents/<vm-name>.utm/config.plist
/usr/libexec/PlistBuddy -c "Set :Serial:0:Mode TcpServer" "$CONFIG_PLIST"
/usr/libexec/PlistBuddy -c "Add :Serial:0:TcpPort integer 4444" "$CONFIG_PLIST" 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :Serial:0:TcpPort 4444" "$CONFIG_PLIST"
# Restart UTM to load new config
open -a UTM
sleep 5
# Start VM
utmctl start <vm-name>Connect to serial console:
nc localhost 4444Features:
- Full copy/paste support (works with macOS clipboard)
- Available during boot (before SSH)
- Useful for installation, debugging, configuration
Disconnect:
Ctrl+C # or just close the terminal window
Serial console automation using expect scripts:
#!/usr/bin/expect -f
set timeout 10
spawn nc localhost 4444
# Login
send "root\r"
expect "Password:"
send "your_password\r"
expect "#"
# Execute commands
send "uname -a\r"
expect "#"
# Copy/paste multiline commands
send "cat > /tmp/config.txt << 'EOF'\r"
expect ">"
send "Line 1\r"
expect ">"
send "Line 2\r"
expect ">"
send "EOF\r"
expect "#"
send "exit\r"
expect eofVM disappears from UTM list:
- Cause: Invalid Mode value (e.g., "tcpServer" instead of "TcpServer")
- Fix: Revert to Ptty mode, restart UTM
/usr/libexec/PlistBuddy -c "Set :Serial:0:Mode Ptty" "$CONFIG_PLIST"
/usr/libexec/PlistBuddy -c "Delete :Serial:0:TcpPort" "$CONFIG_PLIST"
osascript -e 'quit app "UTM"' && sleep 3 && open -a UTMPermission denied on port bind:
- Cause: Port <1024 requires root
- Fix: Use port ≥1024 (recommended: 4444, 5555, 6666)
UTM still uses old port:
- Cause: UTM caches config until restarted
- Fix: Always quit and restart UTM after plist changes
Port already in use:
# Check what's using the port
lsof -iTCP:4444 -sTCP:LISTEN
# Choose different port or stop conflicting processIf TCP mode is not needed, Ptty mode works but has limitations:
<key>Serial</key>
<array>
<dict>
<key>Mode</key>
<string>Ptty</string>
<key>Target</key>
<string>Auto</string>
</dict>
</array>Warning: Finding PTY path programmatically is difficult on macOS and can cause CLI lockups. TCP mode is recommended for automation.
#!/bin/bash
# vm-lifecycle.sh - Complete VM lifecycle automation
set -euo pipefail
TEMPLATE="linux-template"
VM_NAME="$1"
ACTION="${2:-create}"
create_vm() {
echo "Creating VM: $VM_NAME"
utmctl clone "$TEMPLATE" "$VM_NAME"
utmctl start "$VM_NAME"
sleep 30
VM_IP=$(utmctl ip-address "$VM_NAME" | head -1)
echo "VM created: $VM_NAME @ $VM_IP"
echo "$VM_IP" > "/tmp/${VM_NAME}.ip"
}
destroy_vm() {
echo "Destroying VM: $VM_NAME"
utmctl stop "$VM_NAME" 2>/dev/null || true
sleep 2
utmctl delete "$VM_NAME"
rm -f "/tmp/${VM_NAME}.ip"
echo "VM destroyed: $VM_NAME"
}
get_ip() {
if [ -f "/tmp/${VM_NAME}.ip" ]; then
cat "/tmp/${VM_NAME}.ip"
else
utmctl ip-address "$VM_NAME" | head -1
fi
}
case "$ACTION" in
create)
create_vm
;;
destroy)
destroy_vm
;;
ip)
get_ip
;;
ssh)
VM_IP=$(get_ip)
shift 2
ssh root@${VM_IP} "$@"
;;
*)
echo "Usage: $0 <vm-name> {create|destroy|ip|ssh [command]}"
exit 1
;;
esacUsage:
./vm-lifecycle.sh test-vm create
./vm-lifecycle.sh test-vm ip
./vm-lifecycle.sh test-vm ssh 'uname -a'
./vm-lifecycle.sh test-vm destroy- Validated: Production readiness ⚡
- Extensively tested with Alpine Linux automation (utm-alpine-kit v1.0.0)
- 100% success rate across multiple complete end-to-end workflow runs
- All documented patterns proven in production use
- Confirmed: Automation patterns work
- AppleScript VM creation validated
- Serial console automation validated
- Answer file automation validated
- Configuration caching workflow validated
- Clone-based workflows validated
- Status: Production (proven automation patterns, extensively validated)
- Added: Installation Automation section ⚡ MAJOR UPDATE
- Complete ISO-to-template automation now possible!
- AppleScript VM creation (the breakthrough that unlocks automation)
- Serial console configuration and automation patterns
- Answer file automation (distribution-agnostic)
- Expect script examples for interactive prompt handling
- Complete automation workflow documented
- Critical discoveries section (UTM config caching, boot order, etc.)
- Updated: Known Limitations section
- Changed from "can't be done" to "now solved via AppleScript"
- Marked historical limitations as solved
- Added new "How to Get Started" with AppleScript option
- Updated: Quick Tips
- Added AppleScript VM creation as tip #1
- Cross-referenced to Installation Automation section
- Added: GRUB distribution warning
- Alpine-specific issues documented
- Cross-reference to Alpine UTM Guide for complete fix
- Multi-distro considerations
- Cross-reference: utm-alpine-kit repository
- Production-ready implementation (450+ lines)
- Complete working example of all techniques
- Status: Production (proven in real-world use)
- Refactored: Split generic UTM automation from distribution-specific content
- Alpine content moved to ALPINE_UTM_GUIDE.md
- Focus: Distribution-agnostic UTM/QEMU mechanics and automation patterns
- Status updated: Changed from "Work in Progress" to "Production"
- Cross-references added: Links to Alpine guide for distro-specific details
- Removed:
- Alpine-specific GRUB module loading fix (now in Alpine guide)
- Alpine-specific examples (replaced with multi-distro examples where appropriate)
- Added "Known Limitations & Prerequisites" section
- Honest assessment: Template cloning works, fresh ISO creation doesn't
- Documented boot order problem and what we tried
- Clear guidance on getting started with initial template
- Updated Quick Tips to mention template prerequisite
- Added comprehensive serial console TCP configuration section
- Why TCP mode is recommended over Ptty mode
- Correct case-sensitive plist syntax for TcpServer mode
- Automation with PlistBuddy
- Usage examples with nc and expect scripts
- Complete troubleshooting guide
- Validated complete Alpine workflow
- Serial console connectivity tested
- Login and copy/paste functionality verified
- SSH key installation via serial console demonstrated
- QEMU guest agent IP detection working
- Network persistence confirmed
- Fixed MAC address cloning issue (utmctl clone copies MAC addresses)
- Documented GRUB boot flow and module loading fix
- Added GRUB module loading workaround for Alpine
- Updated with working MAC randomization code
- Initial release
- UTM automation basics
- UEFI boot configuration
- QEMU guest agent setup
- UTM Documentation - Official UTM guide
- UTM on GitHub - Source code and issues
- QEMU Documentation - Underlying hypervisor documentation
- Alpine UTM Guide - Alpine Linux-specific automation
- CLOUD_IMAGES_EVALUATION.md - Cloud images testing (Alpine, local reference document)
- GRUB Manual - GRUB bootloader documentation
- EDK2/OVMF - UEFI firmware for QEMU
🚧 This guide is a work in progress. I'm sharing what I've learned so far, but there's still more to discover about UTM/QEMU automation.
Found an error? Have improvements? Know additional tips? Contributions are welcome!
This guide is based on real-world automation work (specifically with Alpine Linux), but the techniques are distribution-agnostic. If you've automated other distributions or discovered additional gotchas, please share your findings.
How to contribute:
- Comment on the gist with corrections or additions
- Share your own automation scripts and discoveries
- Report issues or edge cases you've encountered
Last Updated: 2025-10-19 Tested Environment: UTM 4.x, macOS 26.x, ARM64 (M-series)