Skip to content

Instantly share code, notes, and snippets.

@ChristopherA
Last active October 25, 2025 22:36
Show Gist options
  • Save ChristopherA/96232f85893054b0ac4b4a04d08d8821 to your computer and use it in GitHub Desktop.
Save ChristopherA/96232f85893054b0ac4b4a04d08d8821 to your computer and use it in GitHub Desktop.

Automating UTM VMs on macOS

A comprehensive, distribution-agnostic guide to automating VM creation and management with UTM on macOS.


Document Information

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


Support This Work

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


Quick Tips

Just want to get started? Here's are minimal tips on automation that were my "gotchas":

  1. VM creation via AppleScript works!UPDATE: While utmctl create doesn'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 use utmctl clone for subsequent VMs.

  2. Understand the critical limitation: UTM caches VM configurations in memory. After editing config.plist, you must quit and restart UTM.

  3. 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).

  4. 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.

  5. 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.


Table of Contents


Known Limitations & Prerequisites

VM Creation: AppleScript Breakthrough ⚡ NEW

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 AppleScriptNEW
  • 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

The Boot Order Problem (Historical Context)

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.

How to Get Started

Option 1: Complete Automation via AppleScriptNEW - Recommended for CI/CD

  1. Create VM via AppleScript with ISO and disk
  2. Configure serial console for automation (TCP mode)
  3. Restart UTM (required for config changes)
  4. Automate installation via answer file + expect scripts
  5. 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)

  1. Use UTM GUI to create your first VM
  2. Boot from ISO and install Linux manually
  3. Apply fixes from this guide (GRUB, networking, SSH)
  4. Use utmctl clone for all subsequent VMs

Option 3: Manual UEFI Boot (Historical - no longer recommended) This approach is superseded by AppleScript automation.

What This Guide Provides

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.

For Your Use Case

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 Architecture

Key Components

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

File Locations

~/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

Critical Limitation: Ephemeral UEFI NVRAM

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.


VM Control via utmctl

UTM provides utmctl for command-line VM control. Essential commands:

  • utmctl list - List all VMs
  • utmctl start/stop <vm> - Control VM state
  • utmctl clone <source> <new> - Clone VMs
  • utmctl ip-address <vm> - Get IP (requires guest agent)

Complete utmctl reference: utm-alpine-kit UTM Fundamentals

Critical Behavior: Configuration Caching

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.


VM Configuration (config.plist)

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

Critical Insights for Automation

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:

  1. Create VM via AppleScript (proper boot order)
  2. Configure serial console via PlistBuddy
  3. Quit/restart UTM (load new config)
  4. Automate installation
  5. Remove ISO via PlistBuddy
  6. Quit/restart UTM (finalize config)

UEFI Boot (ARM64 vs x86_64)

Removable Media Fallback Path

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.

EFI System Partition (ESP) Requirements

Your boot partition must be:

  1. FAT32 filesystem (UEFI firmware can only read FAT)
  2. GPT partition type: EFI System Partition (type code EF00)

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 changes

Format partitions:

mkfs.vfat -F 32 /dev/vda1    # Boot partition - MUST be FAT32
mkfs.ext4 /dev/vda2          # Root partition - any filesystem

Bootloader Installation

Your 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/vda

The --removable flag is critical—it installs to the fallback path.


Installation Automation

NEW (2025-10-19) - Complete ISO-to-template automation is now possible!

The Breakthrough: AppleScript VM Creation

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.

AppleScript VM Creation Example

#!/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 propertiesbackend: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 tell

What 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)

Serial Console Automation

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 5

Without this restart, your serial console configuration won't be loaded.

Answer File Automation (Distribution-Specific)

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:

  1. Create answer file with installation parameters
  2. Serve answer file via HTTP (Python: python3 -m http.server 8888)
  3. Boot from ISO
  4. Fetch and apply answer file via serial console automation

Example Alpine automation: See Alpine UTM Guide for complete answer file details.

Expect Script Automation

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 eof

Key points:

  • nc localhost 4444 connects to serial console TCP server
  • expect patterns match prompts
  • send provides responses
  • exp_continue loops for multiple matches

Complete Automation Workflow

  1. Create VM via AppleScript (ISO + disk, proper boot order)
  2. Configure serial console (PlistBuddy → TCP mode)
  3. Restart UTM (required for config changes!)
  4. Start HTTP server (serve answer file)
  5. Run expect script (automate installation via serial console)
  6. Remove ISO (PlistBuddy or utmctl after installation)
  7. Set password via SSH (if answer file can't set it)
  8. 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).

Critical Discoveries

UTM Configuration Caching:

  • UTM loads config.plist at 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:

  • TcpServer mode works for automation (nc/expect)
  • Ptty mode 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

GRUB Bootloader Automation

The Problem: Why GRUB Drops to a Shell

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.

What's happening:

  1. UEFI loads /boot/EFI/BOOT/BOOTAA64.EFI (GRUB)
  2. GRUB starts but doesn't know where its configuration lives (the "prefix" path)
  3. GRUB's default prefix /EFI/BOOT/grub/ doesn't contain grub.cfg
  4. GRUB falls back to interactive shell

Debugging at the GRUB prompt:

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.

Solution: Shim Configuration (Recommended)

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.

⚠️ DISTRIBUTION-SPECIFIC WARNING:

Different distributions have different GRUB issues on UTM:

  • Alpine Linux: grub-install auto-created shim uses commands (if [, echo) that require modules not available at early boot. Additionally, Alpine's /boot/grub/grub.cfg is missing critical insmod commands. 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.

Directory Structure

/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)

Shim Config Content

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.cfg

Replace 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 core
  • set - Built into GRUB core
  • configfile - Built into GRUB core

Do NOT use if, [, echo, or other commands - they require modules GRUB hasn't loaded yet.

Why This Works

  1. UEFI firmware loads /boot/EFI/BOOT/BOOTAA64.EFI
  2. GRUB looks for /boot/EFI/BOOT/grub.cfg (at directory level, NOT in grub/ subdirectory)
  3. GRUB executes our minimal shim (all commands are built-in, no errors)
  4. Shim searches for boot partition by UUID (works even if device names change)
  5. Shim sets $prefix to ($root)/grub (where real config lives)
  6. Shim loads /boot/grub/grub.cfg (your distro's real config with full modules)
  7. Real config displays boot menu and boots normally

Creating the Shim (Inside Your VM)

# 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.cfg

Note: 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.


Alternative: Standalone GRUB Binary

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.cfg

This 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)

Common GRUB Issues

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)

Manual Boot for Testing

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 appear

If this works, your config is correct—you just need the shim in place.


QEMU Guest Agent

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.


Networking

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: ⚠️ Bridged mode exposes VMs on LAN. Use on trusted networks only.

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))

Common Pitfalls

1. Configuration Changes Don't Take Effect

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.

2. VM Boots from CD-ROM After Installation

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=0

Solution:

  1. Stop VM
  2. Quit UTM
  3. Edit config.plist - remove entire CD-ROM <dict> from <key>Drive</key> array
  4. Restart UTM
  5. Start VM

3. GRUB Drops to Minimal Shell

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 disk

Common causes:

  • GRUB's prefix points to wrong location
  • Booting from CD-ROM ($prefix shows (cd0)/...)
  • /boot/EFI/BOOT/grub.cfg shim missing or has errors

Solution: Create minimal shim at /boot/EFI/BOOT/grub.cfg (see GRUB Bootloader Automation)

4. utmctl ip-address Returns Error

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)

5. Files Don't Persist After Reboot

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:

  1. Ensure GRUB config includes root=UUID=... in kernel command line
  2. Remove CD-ROM from VM (see pitfall #2)
  3. Verify OS was installed in persistent mode (not "diskless" or "live")

6. SSH Connection Refused

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 22

Common causes & fixes:

  1. SSH not installed

    apt install openssh-server   # Debian/Ubuntu
    apk add openssh              # Alpine
  2. SSH not running

    systemctl enable ssh
    systemctl start ssh
  3. SSH not enabled on boot

    systemctl enable ssh         # systemd
    rc-update add sshd default   # OpenRC
  4. Wrong username - try root if configured

  5. 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

7. Bridged Network Requires Permissions

Symptom: VM starts but no network connectivity with bridged mode.

Cause: macOS hasn't granted UTM network permissions.

Solution:

  1. First VM start with bridged networking, macOS will prompt
  2. Grant permission
  3. Restart UTM and VM

If you missed the prompt:

  1. System Settings → Privacy & Security → Network
  2. Ensure UTM is allowed

8. Cloned VMs Share Same MAC Address

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.plist

Solution: 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.


Complete Workflow

Prerequisites

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

Step-by-Step: Creating a Template VM

This creates a clean, minimal template VM suitable for cloning.

Step 1: Create VM Structure

# 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" 20G

Step 2: Create config.plist

cat > "${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"

Step 3: Attach ISO and Start Installation

# 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:

  1. Partition disk:
    • /dev/vda1: 160MB, FAT32, mount at /boot (EFI System Partition)
    • /dev/vda2: Remaining space, ext4, mount at /
  2. Install bootloader to /dev/vda with --removable flag
  3. Set up root password or create user
  4. Do NOT reboot yet

Step 4: Configure for Automation (Before First Reboot)

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
reboot

Step 5: Remove Installation Media

After 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

Step 6: Test Template

# 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

Cloning Template VMs

#!/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"
fi

Advanced Topics

AppleScript Automation

UTM provides an AppleScript API for automation beyond utmctl. This can be useful for GUI interactions or integrating with other macOS automation tools.

Basic AppleScript Usage

Get UTM version and properties:

tell application "UTM"
    get properties
    -- Returns: name, frontmost, class, UTM version
end tell

List all VMs:

tell application "UTM"
    set vmList to name of every virtual machine
    return vmList
end tell

From 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"'

Practical Use Cases

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"
fi

Restart 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"

Limitations

  • AppleScript API is limited compared to utmctl
  • Cannot start/stop VMs directly via AppleScript (use utmctl instead)
  • Primarily useful for GUI queries and app lifecycle management

Recommendation: Use utmctl for VM operations, AppleScript for app-level automation.


Detecting VM IP Without Guest Agent

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}'

Serial Console Configuration

UTM supports multiple serial console modes. For automation and development, TCP Server mode is recommended over Ptty mode.

Why TCP 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

Configuration via config.plist

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 (not TCPPort)
  • Invalid Mode values cause VM to disappear from UTM registry
  • Port must be >1024 (privileged ports require root)

Automation with PlistBuddy

# 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>

Usage

Connect to serial console:

nc localhost 4444

Features:

  • 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

Automation with Expect

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 eof

Troubleshooting

VM 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 UTM

Permission 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 process

Alternative: Ptty Mode

If 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.

Automated VM Lifecycle Management

#!/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
        ;;
esac

Usage:

./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

Changelog

v1.0.0 (2025-10-25)

  • 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)

v0.3.00 (2025-10-19)

  • Added: Installation Automation sectionMAJOR 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)

v0.2.00 (2025-10-09)

  • 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)

v0.1.02 (2025-10-06)

  • 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

v0.1.01 (2025-10-05)

  • 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

v0.1.00 (2025-10-04)

  • Initial release
  • UTM automation basics
  • UEFI boot configuration
  • QEMU guest agent setup

Resources

Official Documentation

Related Guides

  • Alpine UTM Guide - Alpine Linux-specific automation
  • CLOUD_IMAGES_EVALUATION.md - Cloud images testing (Alpine, local reference document)

Related Technologies


Contributing

🚧 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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment