Vagrant: Linux VMs on macOS
Provision Linux VMs on Apple Silicon (ARM64) with Ansible
Previously, we explored container-based development with Colima, then moved closer to the underlying infrastructure by managing virtual machines directly with Lima and Ansible. Now, we return to Vagrant, using VirtualBox to manage virtual machines on MacBooks powered by Apple Silicon (ARM64) processors.
We’ll follow a familiar approach: use Vagrant to manage the virtual machine lifecycle and Ansible to provision the guest operating system. The difference is that this time, VirtualBox provides the virtualization backend, while Vagrant coordinates the overall workflow.
What Made Vagrant so Popular?
Vagrant’s strength lies in its modular, plugin-based architecture. Each component handles a different layer of complexity, bringing the pieces together through a consistent interface for building and managing development environments.
Virtual machine automation. Provider plugins handle creating machines from reusable boxes, allocating CPU and memory, attaching storage, and configuring networking and port forwarding. Developers can follow a familiar workflow across different virtualization backends without having to manage each one independently.
Ready-to-use access and file sharing. Vagrant manages SSH credentials and connection details, making it straightforward to access guest machines. It also provides mechanisms for sharing project files between the host and guest through technologies such as NFS, SMB, rsync, and VirtualBox shared folders (vboxsf), depending on the host operating system and provider.
Configuration management integrated into the lifecycle. Provisioner plugins coordinate configuration management tools such as Ansible, Chef, CFEngine, Puppet, and Salt. Depending on the provisioner, Vagrant can install guest-side tooling where necessary and invoke the appropriate configuration process to bring the machine into its desired state. Developers can modify their configuration code, provision the machine again, inspect the results, or recreate the entire environment from scratch.
A cross-platform workflow. Vagrant’s availability across Linux, Windows, and macOS made it accessible to developers working on different platforms. VirtualBox played an important role in this ecosystem by providing a familiar virtualization backend across these operating systems.
Together, these capabilities made Vagrant a practical tool for creating reproducible development environments and testing infrastructure changes locally before applying them in a data center.
Requirements
- Vagrant [
vagrant]: creates and manages virtual machines. - Virtualbox [
vboxmanage]: runs the virtual machine. - Ansible [
ansible]: configures the guest operating system - Git [
git]: downloads the project files.
Install the Tools
On macOS, install the tools using Homebrew:
brew install --cask vagrant virtualbox
brew install ansibleProject Files
Clone the repository with a sparse checkout to retrieve only the files for this example:
cd $HOME
git clone --depth 1 --filter=blob:none \
--sparse https://github.com/darkn3rd/blog_tutorials.git
cd blog_tutorials
git sparse-checkout set devx/vm/vagrant/virtualbox/ansible
cd devx/vm/vagrant/virtualbox/ansibleThe project directory contains:
.
├── ansible.cfg # Ansible configuration
├── inventory.yaml # Ansible inventory
├── Makefile # Workflow shortcuts
├── mise.toml # Optional mise configuration
├── playbook.yaml # Ansible Playbook
├── README.md # Project documentation
├── site
│ ├── index.html # Custom web page
│ └── nginx.conf # Nginx site configuration
└── Vagrantfile # Vagrant configurationRunning the Project
The project uses Vagrant and Virtualbox to run an Ubuntu 26.04 virtual machine, then Ansible to configure it.
Step 1: Launch the Virtual Machine
The Vagrantfile defines the virtual machine and its Ansible provisioner:
Vagrant.configure("2") do |config|
config.vm.box = "bento/ubuntu-26.04"
config.vm.define "dev"
config.vm.network "forwarded_port", guest: 80, host: 8080
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.synced_folder ".", "/workspace"
config.vm.provider "virtualbox" do |vb|
vb.cpus = 2
vb.memory = 4096
end
config.vm.provision "ansible" do |ansible|
ansible.compatibility_mode = "2.0"
ansible.playbook = "playbook.yaml"
ansible.groups = { "web" => ["dev"] }
end
endThis configuration:
- Names the virtual machine
devand assigns it two CPUs and 4 GB of memory. - Forwards port
8080on your computer’s loopback address to port80in the guest. - Shares the project directory at
/workspace, with the default/vagrantshare disabled. - Configures Ansible to run
playbook.yaml, placingdevin thewebinventory group.
From the project directory, start the virtual machine:
vagrant up --no-provisionThe --no-provision option skips Ansible for now, allowing you to launch the virtual machine and configure it in separate steps.
After Vagrant finishes launching the system, you can view its presence in VirtualBox Manager application:
Step 2: Provision VM with Ansible
The Ansible playbook, playbook.yaml, describes the desired configuration of the web server:
- Nginx is installed.
- The site’s document root and configuration exist.
- The custom web page is deployed.
- The default Nginx site is disabled.
- The custom site is enabled.
- Nginx is running and starts automatically at boot.
The following excerpt shows the package installation and service tasks:
#!/usr/bin/env ansible-playbook
---
- name: Configure the development web server
hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
lock_timeout: 120
# Additional tasks deploy the page and configure the site.
- name: Start nginx on boot
ansible.builtin.service:
name: nginx
state: started
enabled: trueThe hosts: web setting targets the inventory group defined in the Vagrantfile. The become: true setting runs tasks with elevated privileges, which are required to install packages and manage system services.
Apply the complete playbook:
vagrant provisionVagrant runs Ansible on your host computer and connects to the virtual machine over SSH to configure the web server.
Step 4: Verify Service Works
With Nginx configured, test the web service from your host computer on port 8080:
curl --fail --include http://localhost:8080You should see something that looks like this:
Open http://localhost:8080 in a browser to view the page.
Step 5: Inspecting the System
From the project directory on your host, open a shell inside the virtual machine:
vagrant sshInside the guest, inspect the service and its configuration:
# Display the guest's fully qualified hostname
hostname -f
# Check the Nginx service
systemctl status nginx --no-pager
# Test the web service from inside the guest
curl --fail http://localhost:80
# View the deployed web page
cat /var/www/example/index.html
# Inspect the available and enabled sites
ls -l /etc/nginx/sites-available/
ls -l /etc/nginx/sites-enabled/
# View the enabled site configuration
cat /etc/nginx/sites-enabled/exampleFor the systemctl status command, you should see something that looks something similar to this.
Inside the guest, Nginx listens on port 80. From the host, you access it through port 8080, which VirtualBox forwards to the guest.
Get Joaquín Menchaca (智裕)’s stories in your inbox
Join Medium for free to get updates from this writer.
When finished, return to your host shell:
exitStep 6: Make a Change and Provision Again
From the project directory on your host, replace the contents of site/index.html:
cat <<'EOF' >| site/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vagrant example</title>
</head>
<body>
<h1>The classic workflow, running on Virtualbox.</h1>
<p>Served by nginx on Ubuntu LTS, provisioned by Ansible.</p>
</body>
</html>
EOFApply the playbook again:
vagrant provisionVerify the updated page:
curl --include --fail http://localhost:8080You should see something that looks like the following:
Refresh your browser to see the new heading.
Ansible copies the updated page into the site’s document root. Changing a static HTML file does not require restarting Nginx.
You can repeat this process without rebuilding the VM. The running machine provides the development environment, while the playbook records its desired configuration.
Run provisioning once more without changing any files:
vagrant provisionThe page deployment task should report ok instead of changed, because the deployed file already matches the source. This illustrates Ansible’s idempotence: applying the same desired configuration again should leave an already configured resource unchanged.
This edit → provision → verify cycle is the core development workflow for this example.
Step 7: Halting the System
When you want to stop the environment and keep it for later, shut down the virtual machine:
vagrant haltThis preserves the guest’s disk and configuration. You san start it up again with vagrant up.
Step 8: Destroying the System
When you no longer need the environment, delete the virtual machine:
vagrant destroyVagrant asks for confirmation before removing the VM and its disk. Files stored only on the guest’s disk are deleted; the project files shared from your Mac remain on the host.
To recreate the environment and test provisioning from a fresh VM, run vagrant up. Vagrant creates the virtual machine and runs the Ansible provisioner automatically.
Addendum: Run Ansible Directly
You can also run ansible-playbook directly instead of invoking it through vagrant provision.
With the virtual machine running, execute these commands from the project directory:
# Write the virtual machine's SSH connection settings
vagrant ssh-config > .vagrant/ssh.config
# Run playbook
ansible-playbook playbook.yamlAnsible uses the settings in the project’s ansible.cfg:
[defaults]
inventory = inventory.yaml
interpreter_python = auto_silent
[ssh_connection]
pipelining = trueThe inventory setting points to inventory.yaml, which places the virtual machine in the web group:
web:
hosts:
dev:
ansible_ssh_common_args: >-
'-F "{{ inventory_dir }}/.vagrant/ssh.config"'The -F option tells SSH to use the configuration generated by Vagrant. That file supplies the connection details for dev, including its hostname, port, user, and private key. The inventory_dir variable resolves to the directory containing the inventory file.
Regenerate .vagrant/ssh.config after recreating the VM so that its connection details stay current.
When you run vagrant provision, Vagrant supplies the inventory and SSH connection settings automatically. This local configuration lets you run the same playbook independently.
Links
Virtualization Tools
- Virtualbox (Type 2 Hypervisor) virtual machine manager
- Hashicorp Vagrant tool for managing virtual machines
- Vagrant Ansible Provisioner
- Lima (Linux Machines) virtualization management tool
Vagrant Box Images Templates
- Bento: Packer templates for building minimal Vagrant baseboxes for multiple platforms.
- Robox: The tools needed to robotically create/configure/provision a large number of operating systems, for a variety of hypervisors, using packer.
Configuration Managment Tools
- Ansible configuration management tool
Container Runtime Tools
- Colima (Containers on Lima) container runtime
Previous Article
Final Thoughts
VirtualBox 7.1 brought an official macOS release for Apple Silicon, adding ARM virtualization for Linux and BSD guests. This extended the familiar Virtualbox workflow beyond Intel and AMD x86 systems to ARM64 Macs. (VirtualBox 7.1 release notes)
The Bento project provides VirtualBox boxes for both x86–64 and ARM64, making it a useful source of base images for this workflow. Choose a box that supports your host’s architecture and the Virtualbox provider.
With Vagrant defining the virtual machine and Ansible configuring its services, the development cycle stays straightforward: edit → provision → verify. You can keep the environment between sessions or recreate it from the project files when you need a fresh start.









