Integrating with launchd

July 31, 2026 · View on GitHub

Overview

This guide shows how to run persistent virtual machines on macOS using vmnet-run and launchd.

vmnet-run supervises both vmnet-helper and the VM process as children, making it a natural fit for launchd — launchctl stop cleanly terminates everything, and launchctl start brings it back up. No PID files or wrapper scripts are needed.

By the end of this guide you will have a Fedora VM managed by launchd that you can start and stop with launchctl.

Requirements

Install using Homebrew:

brew tap nirs/vmnet-helper
brew trust nirs/vmnet-helper
brew install vmnet-helper vfkit cdrtools qemu
  • vmnet-helper v0.11.0+ — provides vmnet-run
  • vfkit — lightweight macOS virtual machine
  • cdrtools — provides mkisofs for creating cloud-init ISOs
  • qemu — provides qemu-img for image conversion and resizing

Preparing the image cache

Download a cloud image once and convert it to raw format. The cached image can be reused for multiple VMs.

curl --fail --location --output fedora-44.qcow2 \
    https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2
mkdir -p ~/.cache/vm-images
qemu-img convert -f qcow2 -O raw fedora-44.qcow2 \
    ~/.cache/vm-images/fedora-44.img

Creating a VM

Choose a VM name that is a valid hostname. The name is used as the mDNS hostname for SSH access (e.g., ssh fedora@my-vm.local).

VM_NAME=my-vm

VM directory

Create the VM directory, copy the cached image using copy-on-write, and resize it:

mkdir -p ~/vms/$VM_NAME
cp -c ~/.cache/vm-images/fedora-44.img ~/vms/$VM_NAME/disk.img
qemu-img resize -q -f raw ~/vms/$VM_NAME/disk.img 20g

MAC address

Generate a locally administered unicast MAC address. Use the same address in the cloud-init network-config and the vfkit command to get a stable DHCP IP across restarts.

MAC_ADDRESS=$(python3 -c "
import os
b = bytearray(os.urandom(6))
b[0] = (b[0] | 2) & 0xFE
print(':'.join(f'{x:02x}' for x in b))
")
echo $MAC_ADDRESS

Cloud-init

Create the cloud-init files in ~/vms/$VM_NAME/. These examples are for Fedora; see the cloud-init documentation for other distributions.

user-data:

cat > ~/vms/$VM_NAME/user-data << EOF
#cloud-config
password: password
chpasswd:
  expire: false
ssh_authorized_keys:
  - "$(cat ~/.ssh/id_ed25519.pub)"
packages:
  - avahi
runcmd:
  - systemctl enable --now avahi-daemon
EOF

meta-data:

cat > ~/vms/$VM_NAME/meta-data << EOF
instance-id: $(uuidgen)
local-hostname: $VM_NAME
EOF

network-config:

cat > ~/vms/$VM_NAME/network-config << EOF
version: 2
ethernets:
  eth0:
    match:
      macaddress: $MAC_ADDRESS
    dhcp4: true
    dhcp-identifier: mac
    dhcp4-overrides:
      use-dns: false
    nameservers:
      addresses:
        - 8.8.8.8
        - 1.1.1.1
EOF

Note

The nameservers override DHCP-provided DNS to avoid DNS issues on managed macOS machines. Adjust to your preferred DNS servers.

Create the cloud-init ISO from the files:

(cd ~/vms/$VM_NAME && mkisofs -output cidata.iso -volid cidata -joliet -rock \
    user-data meta-data network-config)

Creating the plist

Note

On macOS 15, replace the vmnet-run path in the plist with /opt/vmnet-helper/bin/vmnet-run.

Create the plist:

cat > ~/Library/LaunchAgents/local.$VM_NAME.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>Label</key>
    <string>local.$VM_NAME</string>
    <key>ProgramArguments</key>
    <array>
        <string>$(brew --prefix vmnet-helper)/libexec/vmnet-run</string>
        <string>--</string>
        <string>$(brew --prefix)/bin/vfkit</string>
        <string>--cpus</string>
        <string>2</string>
        <string>--memory</string>
        <string>2048</string>
        <string>--bootloader</string>
        <string>efi,variable-store=$HOME/vms/$VM_NAME/efi-variable-store,create</string>
        <string>--device</string>
        <string>usb-mass-storage,path=$HOME/vms/$VM_NAME/cidata.iso,readonly</string>
        <string>--device</string>
        <string>virtio-blk,path=$HOME/vms/$VM_NAME/disk.img</string>
        <string>--device</string>
        <string>virtio-serial,logFilePath=$HOME/vms/$VM_NAME/serial.log</string>
        <string>--device</string>
        <string>virtio-net,fd=4,mac=$MAC_ADDRESS</string>
        <string>--device</string>
        <string>virtio-rng</string>
    </array>
    <key>RunAtLoad</key>
    <false/>
    <key>StandardErrorPath</key>
    <string>$HOME/vms/$VM_NAME/vm.log</string>
</dict>
</plist>
EOF

Load the agent:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.$VM_NAME.plist

Plist options

Add these keys to the plist <dict> for additional behavior:

  • KeepAlive: Set to <true/> to automatically restart the VM if it crashes.
  • RunAtLoad: Set to <true/> to start the VM automatically when the agent is loaded (e.g., on login).

See the launchd.plist documentation for all available options.

Managing the VM

Start the VM:

launchctl start local.$VM_NAME

Stop the VM (cleanly terminates vmnet-helper and vfkit):

launchctl stop local.$VM_NAME

Checking status

launchctl print gui/$(id -u)/local.$VM_NAME | grep "^\tstate"

When the VM is running:

	state = running

When the VM is stopped:

	state = not running

Connecting with SSH

After starting the VM, wait a few seconds for it to boot and then connect using the mDNS hostname:

ssh fedora@my-vm.local

The username is fedora for Fedora cloud images. The VM is accessible via mDNS thanks to the avahi package installed by cloud-init.

SSH config

To avoid specifying the username every time, create an SSH config for the VM:

cat > ~/vms/$VM_NAME/ssh.config << EOF
Host $VM_NAME.local
    User fedora
EOF

Then add this to your ~/.ssh/config:

Include ~/vms/*/ssh.config

Now you can connect without specifying the username:

ssh my-vm.local

Deleting a VM

Warning

This deletes all VM data including the disk image.

launchctl stop local.$VM_NAME
launchctl bootout gui/$(id -u)/local.$VM_NAME
rm ~/Library/LaunchAgents/local.$VM_NAME.plist
rm -r ~/vms/$VM_NAME