Harness the Combinatoric Power of Command-Line Tools and Utilities

../Tutorials

Create Windows and Linux Virtual Machines on macOS with UTM and Vagrant

vagrant macOS tools

Published July 15, 2025

Virtual machines let you run another operating system on top of your existing OS. They’re great for testing applications in isolation or for testing web sites on different browsers.

UTM is a free, open source virtualization platform for macOS that runs on Apple’s Silicon chips. It can run Linux, Windows, and macOS operating systems using a variety of methods.

Vagrant is a developer tool that lets you automate and manage virtual machines. One of Vagrant’s strongest advantages is that you can download pre-made “boxes” that work with the virtualization platform you’re using. With Vagrant, you can bring up a working Windows 11 or Ubuntu machine in far less time that it would take to do a manual installation.

In this guide you’ll install Vagrant and the UTM plugin and then configure two virtual machines using pre-made images.

What You Need

To complete this tutorial, you’ll need Homebrew installed, which you can do by following the Install Homebrew tutorial.

Installing UTM and Vagrant

While you can install UTM from the official download, you can also install it using Homebrew. Run the following command to install UTM:

brew install --cask utm

This installs UTM and also makes the command-line tool utmctl available on your PATH:

==> Installing Cask utm
==> Moving App 'UTM.app' to '/Applications/UTM.app'
==> Linking Binary 'utmctl' to '/opt/homebrew/bin/utmctl'

utmctl lets you script UTM. Vagrant needs this to communicate with UTM and manipulate components of your virtual machine. If you installed UTM manually, ensure utmctl is available on your system PATH with the following command:

sudo ln -sf /Applications/UTM.app/Contents/MacOS/utmctl /usr/local/bin/utmctl

Next install Vagrant through Homebrew. First, add the HashiCorp package repository:

brew tap hashicorp/tap

Then install Vagrant:

brew install hashicorp/tap/hashicorp-vagrant

Once this completes, install the UTM plugin for Vagrant which tells Vagrant how to communicate with UTM:

vagrant plugin install vagrant_utm

You’ll get the following output confirming the plugin installed:

Installing the 'vagrant_utm' plugin. This can take a few minutes...
Fetching vagrant_utm-0.1.3.gem
Installed the plugin 'vagrant_utm (0.1.3)'!

With Vagrant and components installed, you can build your first virtual machine.

Creating an Ubuntu Machine

To create a virtual machine with Vagrant, you create a configuration file called a Vagrantfile. In this file you specify the “box” you want to use to build your machine. A box is specific to the virtualization environment you’re using, which means if you want to run Windows 11 on UTM, you need a Vagrant box that’s already configured for this. Fortunately, you don’t have to make these yourself; various vendors and community members have created boxes you can use.

You can find compatible boxes in the HashiCorp Vagrant Registry under Vagrant UTM Boxes. You’ll use the utm/ubuntu-24.04 base box.

Create a new directory to hold your project and switch to it:

mkdir -p ~/vms/ubuntu && cd ~/vms/ubuntu

Then create the file Vagrantfile and add the following code to define your virtual machine:

Vagrant.configure("2") do |vagrant|
  hostname = "ubuntu24"

  vagrant.vm.define hostname, primary: true do |machine|
    machine.vm.box = "utm/ubuntu-24.04"
    machine.vm.hostname = hostname

    machine.vm.provider "utm" do |u|
      u.name = hostname
      u.cpus = 1
      u.memory = 4096
      u.notes = "Vagrant: For testing Linux client and emulating services"
      u.directory_share_mode = "virtFS"
    end

  end
end

The Vagrantfile defines all the attributes for the virtual machine, including its name, the image used, the amount of RAM, how networking works, and how many CPUs the VM should use. The code in a Vagrantfile is in the Ruby programming language, so you can use Ruby variables, loops, and other constructs if you need more complex setups.

In this example, the Ruby variable hostname holds the value ubuntu24. You can then use this variable to set the name of the Vagrant VM, the label in UTM, and the host name of the guest machine.

Save the file. Now run Vagrant to launch the machine:

vagrant up

Vagrant downloads the base box from the HashiCorp registry if it’s not already on your machine:

Bringing machine 'ubuntu24' up with 'utm' provider...
==> ubuntu24: Box 'utm/ubuntu-24.04' could not be found. Attempting to find and install...
    ubuntu24: Box Provider: utm
    ubuntu24: Box Version: >= 0
==> ubuntu24: Loading metadata for box 'utm/ubuntu-24.04'
    ubuntu24: URL: https://vagrantcloud.com/api/v2/vagrant/utm/ubuntu-24.04
==> ubuntu24: Adding box 'utm/ubuntu-24.04' (v0.0.1) for provider: utm (arm64)
    ubuntu24: Downloading: https://vagrantcloud.com/utm/boxes/ubuntu-24.04/versions/0.0.1/providers/utm/arm64/vagrant.box

Once the box downloads, Vagrant creates the machine and waits for it to boot. It also maps the SSH port to port 2222 so you can access it from your host:

==> ubuntu24: Successfully added box 'utm/ubuntu-24.04' (v0.0.1) for 'utm (arm64)'!
==> ubuntu24: Importing base box 'utm/ubuntu-24.04'...
==> ubuntu24: Generating MAC address for NAT networking...
==> ubuntu24: Checking if box 'utm/ubuntu-24.04' version '0.0.1' is up to date...
==> ubuntu24: Setting the name of the VM: ubuntu24
==> ubuntu24: Fixed port collision for 22 => 2222. Now on port 2200.
==> ubuntu24: Forwarding ports...
    ubuntu24: 22 (guest) => 2222 (host) (adapter 1)
==> ubuntu24: Running 'pre-boot' VM customizations...
==> ubuntu24: Booting VM...
==> ubuntu24: Waiting for machine to boot. This may take a few minutes...

Vagrant then sets up SSH and mounts the current working directory to a directory called /vagrant on the VM:

    ubuntu24: SSH address: 127.0.0.1:2200
    ubuntu24: SSH username: vagrant
    ubuntu24: SSH auth method: private key
    ubuntu24:
    ubuntu24: Vagrant insecure key detected. Vagrant will automatically replace
    ubuntu24: this with a newly generated keypair for better security.
    ubuntu24:
    ubuntu24: Inserting generated public key within guest...
    ubuntu24: Removing insecure key from the guest if it's present...
    ubuntu24: Key inserted! Disconnecting and reconnecting using new SSH key...
==> ubuntu24: Machine booted and ready!
==> ubuntu24: Checking for guest additions in VM...
    ubuntu24: Guest additions detected
==> ubuntu24: Setting hostname...
==> ubuntu24: Mounting shared folders...
    ubuntu24: /Users/brianhogan/vm/ubuntu-vm => /vagrant

This box launches as a headless virtual machine, meaning there’s no display connected in UTM. To access the machine, connect to it with SSH. Run the following command to do that:

vagrant ssh

You can also use your normal SSH client, but this way you don’t need to specify the login name or IP address for the connection.

Controlling the Virtual Machine

You can use UTM’s user interface to stop and start your machines, but you can also control things with Vagrant.

To stop the machine, use vagrant halt:

vagrant halt

This attempts to shut the machine down gracefully to prvent data loss:

==> ubuntu24: Attempting graceful shutdown of VM...

To rebuild the machine, use vagrant relaod.

vagrant relaod

This shuts down the machine if it’s running and then reloads the Vagrantfile, applying any changes:

==> ubuntu24: Attempting graceful shutdown of VM...
==> ubuntu24: Checking if box 'utm/ubuntu-24.04' version '0.0.1' is up to date...
==> ubuntu24: Setting the name of the VM: ubuntu24
==> ubuntu24: Clearing any previously set forwarded ports...
....

Finally, you can remove the machine entirely with vagrant destroy:

vagrant destroy

Vagrant will ask you to confirm this destruction before it continues:

    ubuntu24: Are you sure you want to destroy the 'ubuntu24' VM? [y/N] y
==> ubuntu24: Forcing shutdown of VM...
==> ubuntu24: Destroying VM and associated drives...

The output confirms it removed the virtual machine and its associated drives.

Use vagrant up to build the machine again the next time you need it.

Connecting to the Local Console in UTM

If you’d rather access the virtual machine from within UTM, ensure you stopped the virtual machine. Right-click the ubuntu24 virtual machine and choose Edit. Then add a display and choose virtio-gui-pci as the display, as shown in the following figure:

Add the virtio-gui-pci display to the virtual machine to see the console in UTM.
Add the virtio-gui-pci display to the virtual machine to see the console in UTM.

When you restart the machine, you’ll see a window appear that you can interact with:

The Ubuntu VM showing a login prompt
The Ubuntu VM showing a login prompt

If you destroy the machine and rebuild it, you’ll have to add this display back.

Using Vagrant to Provision Software

You can use Vagrant to run commands on the guest machine during the initial build. This lets you set up more accounts or install software on top of the base image you might need for your project.

For example, to automatically install Tailscale, a secure mesh VPN based on Wireguard, add the following section to your Vagrantfile after the UTM block:

Vagrant.configure("2") do |config|
  hostname = "ubuntu24"
  ...

    machine.vm.provider "utm" do |u|

    ...

    end

    machine.vm.provision "shell", inline: <<-SHELL
      apt-get update
      curl -fsSL https://tailscale.com/install.sh | sh
    SHELL
  end
end

This code uses an inline shell script to run apt-get update to ensure package sources are updated, and then runs Tailscale’s official installation script for Linux on the machine.

With these changes added, destroy the machine:

vagrant destroy

Then rebuild the machine:

vagrant up

The machine creation takes less time now because it doesn’t need to download the image. At the end of the process, you see the provisioning script run:

==> ubuntu24: Running provisioner: shell...
    ubuntu24: Running: inline script
    ubuntu24: Hit:1 http://ports.ubuntu.com/ubuntu-ports noble InRelease
    ubuntu24: Get:2 http://ports.ubuntu.com/ubuntu-ports noble-updates InRelease [126 kB]
    ...
    ubuntu24: Fetched 13.4 MB in 2s (8,636 kB/s)
    ubuntu24: Reading package lists...
    ubuntu24: Installing Tailscale for ubuntu noble, using method apt
    ubuntu24: + mkdir -p --mode=0755 /usr/share/keyrings
    ubuntu24: + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg
    ubuntu24: + tee /usr/share/keyrings/tailscale-archive-keyring.gpg
    ubuntu24: + chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg
    ubuntu24: + + curltee -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.tailscale-keyring.list
    ubuntu24:  /etc/apt/sources.list.d/tailscale.list
    ubuntu24: # Tailscale packages for ubuntu noble
    ubuntu24: deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu noble main
    ubuntu24: + chmod 0644 /etc/apt/sources.list.d/tailscale.list
    ubuntu24: + apt-get update
    ...
    ubuntu24: + apt-get install -y tailscale tailscale-archive-keyring
    ubuntu24: Setting up tailscale (1.84.0) ...

Once packages install, you get confirmation that Tailscale installed on the VM:

    ubuntu24: Installation complete! Log in to start using Tailscale by running:
    ubuntu24:
    ubuntu24: tailscale up

You can use provisioning to install additional software and create the environment you need for your project.

Now try creating a Windows virtual machine with Vagrant

Creating a Windows VM

You can use Vagrant to create a Windows VM using the same approach you used for Ubuntu.

Vagrant’s features, and its UTM plugin, are built for Linux-based systems, so there are a couple of features you won’t be able to use when you launch a Windows VM. First, Vagrant won’t be able to automatically share the current working directory with the Windows machine. Second, some features require that the guest operating system has “guest additions” installed. This is special bridging software that lets the virtualization environment talk to the guest operating system to provide better support for sound, video, and more. The Windows images don’t have guest additions available that are compatible with ARM architecture.

Create a new directory for your project called windows-vm and switch to the directory:

mkdir ~/vm/windows && cd ~/vm/windows

The utm/windows-11 box is a working Windows 11 image that’s free for you to use for testing. You won’t need to provide a license key for Windows.

Use this image to create a Windows 11 virtual machine.

Create a new Vagrantfile that configures the machine:

Vagrant.configure("2") do |vagrant|
  hostname = "windows11"

  vagrant.vm.define hostname, primary: true do |machine|
    machine.vm.box = "utm/windows-11"
    machine.vm.hostname = hostname
    machine.vm.synced_folder ".", "/vagrant", disabled: true

    machine.vm.provider "utm" do |u|
      u.name = hostname
      u.cpus = 1
      u.memory = 4096
      u.notes = "Vagrant: For testing Windows client"
      u.check_guest_additions = false
    end
  end
end

This file uses a similar configuration as the Ubuntu version, but it disables the automatic file sharing, and tells Vagrant not to look for the guest additions since they don’t work under Windows and ARM.

Save the file and then use Vagrant to build the machine:

vagrant up

Since the Windows 11 base box isn’t downloaded yet, Vagrant will grab it for you.

Bringing machine 'windows11' up with 'utm' provider...
==> windows11: Box 'utm/windows-11' could not be found. Attempting to find and install...
    windows11: Box Provider: utm
    windows11: Box Version: >= 0
==> windows11: Loading metadata for box 'utm/windows-11'
    default: URL: https://vagrantcloud.com/api/v2/vagrant/utm/windows-11
==> windows11: Adding box 'utm/windows-11' (v0.0.0) for provider: utm (arm64)
    windows11: Downloading: https://vagrantcloud.com/utm/boxes/windows-11/versions/0.0.0/providers/utm/arm64/vagrant.box
Progress: 80% (Rate: 27.0M/s, Estimated time remaining: 0:00:38)

After the box downloads, Vagrant continues setting up the virtual machine.

==> windows11: Successfully added box 'utm/windows-11' (v0.0.0) for 'utm (arm64)'!
==> windows11: Importing base box 'utm/windows-11'...
==> windows11: Generating MAC address for NAT networking...
==> windows11: Checking if box 'utm/windows-11' version '0.0.0' is up to date...
==> windows11: Setting the name of the VM: windows11

Like on Ubuntu, Vagrant maps ports and waits to connect to the machine:

==> windows11: Forwarding ports...
    windows11: 3389 (guest) => 3389 (host) (adapter 1)
    windows11: 5985 (guest) => 5985 (host) (adapter 1)
    windows11: 5986 (guest) => 55986 (host) (adapter 1)
    windows11: 22 (guest) => 2222 (host) (adapter 1)
==> windows11: Running 'pre-boot' VM customizations...
==> windows11: Booting VM...
==> windows11: Waiting for machine to boot. This may take a few minutes...
    windows11: WinRM address: 127.0.0.1:5985
    windows11: WinRM username: vagrant
    windows11: WinRM execution_time_limit: PT2H
    windows11: WinRM transport: negotiate
==> windows11: Machine booted and ready!

It then sets the host name of the Windows machine to the host name configured in the Vagrantfile and reboots, since Windows requires you to reboot when you change the machine name.

==> windows11: Setting hostname...
==> windows11: Waiting for machine to reboot...

The machine reboots. You can interact with it through UTM:

The Windows 11 machine running in Vagrant
The Windows 11 machine running in Vagrant

Like with your Ubuntu virtual machine, you can modify the settings in UTM to add devices or change configuration settings. You can also use vagrant halt to shut down the machine and vagrant destroy to completely remove it.

Conclusion

Vagrant and UTM offer a quick way to run virtual machines on your Mac for testing. You can use existing base boxes or build your own using the instructions in the official documentation.