Tunnel

Introduction

When prototyping or developing stuffs, it is often a good idea to use Virtual Machines (VMs) in order to isolate everything and avoid messing up your computer. You are then able to test something and recreate the machines when everything is broken.

However, creating VMs can be painful and repeating this process several times can be very long. Using tools and concepts close to Infrastructure as Code (IaC) to automatically deploy the machines is the right idea and Vagrant is here to help you.

What is Vagrant basically ?

Let’s take an example with a Linux Operating System (OS) and KVM/libvirt as hypervisor. Let say that you simply want to test the new major version of your preferred OS and be sure that every tools you are using are still working. You probably won’t take the risk to completely erase your computer and install directly the OS. The best option could be to use a VM. You will then deploy the machine (via CLI with virsh/virt-install commands or via GUI with Virtual Machine Manager or Cockpit), eventuelly follow the installation process, reboot, install your tools and reconfigure your environment before (finally !) being able to test if everything is working as expected… Very long workflow that you have to reiterate for each major version release !

Another solution could be to describe once for all what you want to do and take this recipe each time you need it. This is basically what Vagrant is made for : you describe almost evrything in one file (called Vagrantfile) and you are done ! It is finally a simple abstraction layer above your hypervisor which takes over VM management and configuration.

We are talking about KVM hypervisor in our example but we could have choosen whichever one. Vagrant has a concept of providers which is basically a plugin that Vagrant can use to communicate with an hypervisor. VirtualBox is supported out of the box but you will have to install a custom provider to let Vagrant manage a KVM hypervisor via libvirt.

First step : install Vagrant

Depending on your OS, there are multiple ways to install Vagrant. You can follow the official documentation or an OS specific documentation. We will show how to do this with Fedora.

If libvirt is not already installed, there is a package group for that (called Virtualization). Vagrant and the libvirt provider is also shipped in another package group (called Vagrant). As a rusult, you can install both with :

sudo dnf group install virtualization vagrant

You should then enable livirtd to automatically start and add your current user to libvirt group in order to avoid password prompting each time you start a VM. These can be done with the following commands :

sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt ${USER}
newgrp libvirt

Quick start : your first Vagrantfile

As already explained, the power of Vagrant is to simply describe in a single file the whole infrastructure you want to create. The syntax uses Ruby language but don’t worry if you know nothing about this language, you basically just have to declare variables. Let’s have a look with these lines thant you can copy and past in a file named Vagrantfile :

Vagrant.configure("2") do |config|
    config.vm.box = "fedora/41-cloud-base"
end

This Vagrantfile simply declares a VM which uses a Fedora 41 Cloud Base image. Juste note that OS images used by Vagrant are called boxes and are basically packages embedding metadata, configuration files and a VM image specifically configured for Vagrant. Hashicorp (the company behind Vagrant) hosts a repository with many boxes publicly available. As each box has a specific version for each provider (KVM/libvirt, VirtualBox, …), be sure that the box you want is compatbile with the provider(s) you want to use.

To launch the VM, the only thing you have to do is to run the following command in the directory where the Vagrantfile is :

vagrant up

Note : For those already running Fedora, the Vagrant version shipped with the distribution uses libvirt daemon session mode. As a result, Vagrant is running in an unprivileged mode and only has the same rights than the current user which is more safer. However, some functionalities (as network operations) require root permissions and Vagrant needs to run in system mode. This can be done by adding a libvirt option as described below :

Vagrant.configure("2") do |config|
    config.vm.box = "fedora/41-cloud-base"
  
    config.vm.provider :libvirt do |libvirt|
        libvirt.qemu_use_session = false
    end
end

Provisioning the machine

Launching VM is interesting but in our example above we talked about making some tests in our new machine. Automatically do stuffs is what provisioners are made for. Once again, you can observe that Vagrant is very modular as there are many different provisioners available as detailed in Vagrant documentation.

We are going to use Ansible provisioner to do stuffs in our VM. We won’t go too much into details on how Ansible works. The only important thing to know about Ansible is that it allows you to discribe in a file (called a playbook) a sequence of tasks that will be run inside the VM.

We first have to install Ansible on the host alongside Vagrant. On Fedora, this can be done with the following command :

sudo dnf install ansible

Just copy and past in a file called playbook.yml the lines below. This file must be in the same directory as the Vagrantfile. This Ansible playbook simply updates the machine.

---
- hosts: all
  gather_facts: true
  tasks:
  - name: Upgrade all packages
    ansible.builtin.dnf:
      name: "*"
      state: latest
    register: update
    become: true
  - name: Reboot to apply updates
    ansible.builtin.reboot:
      post_reboot_delay: 60
      reboot_command: reboot
    when: update.changed
    become: true

You can then update you Vagrantfile to call the playbook once the VM is started for the first time :

Vagrant.configure("2") do |config|
    config.vm.box = "fedora/41-cloud-base"
  
    config.vm.provider :libvirt do |libvirt|
        libvirt.qemu_use_session = false
    end

    config.vm.provision "ansible" do |ansible|
        ansible.playbook = "playbook.yml"
    end
end

If it is not the first time you launch the VM, you can force provisioning with the following command :

vagrant up --provision

Multi-machine infrastructure

The examples above were only launching a single machine. Obviously, you can declare multiple machines that will be launched at the same time. The syntax is slightly different but the idea is the same : you just have to include each machine configuration variables inside a config.vm.define method.

Vagrant.configure("2") do |config|
  
  config.vm.define "vm1" do |hostconfig|
    hostconfig.vm.box = "fedora/41-cloud-base"

    hostconfig.vm.provider :libvirt do |libvirt|
      libvirt.qemu_use_session = false
    end
  end

  config.vm.define "vm2" do |hostconfig|
    hostconfig.vm.box = "fedora/41-cloud-base"

    hostconfig.vm.provider :libvirt do |libvirt|
      libvirt.qemu_use_session = false
    end
  end
end

You can even factor configuration variables that are the same accross machines. This way, the Vagrantfile just above would become :

Vagrant.configure("2") do |config|

  config.vm.box = "fedora/41-cloud-base"

  config.vm.provider :libvirt do |libvirt|
    libvirt.qemu_use_session = false
  end
  
  config.vm.define "vm1" do |hostconfig|
  end

  config.vm.define "vm2" do |hostconfig|
  end
end

Naturaly, it is still possible to provision the VMs. In case we want to use the same playbook to provision each VM, the first approach would be to declare the playbook at config level :

Vagrant.configure("2") do |config|

  config.vm.box = "fedora/41-cloud-base"

  config.vm.provider :libvirt do |libvirt|
    libvirt.qemu_use_session = false
  end

  # Provisionning
  config.vm.provision "ansible" do |ansible|
    ansible.playbook = "playbook.yml"
  end
  
  config.vm.define "vm1" do |hostconfig|
  end

  config.vm.define "vm2" do |hostconfig|
  end
end

This approach is not wrong but, as explained in the Vagrant documentation, Vagrant will provision VM in sequence so that two different playbooks will be launch. As the power of Ansible is to provision multiple VMs using parallelism (this means lauching a single playbook will provision as many VMs as you want), if you want to keep this behavior, you will have to modify a little bit the syntax. The Vagrantfile just above would thus become :

Vagrant.configure("2") do |config|

  config.vm.box = "fedora/41-cloud-base"

  config.vm.provider :libvirt do |libvirt|
    libvirt.qemu_use_session = false
  end

  N = 2
  (1..N).each do |machine_id|
    config.vm.define "vm#{machine_id}" do |hostconfig|

      # Only execute once the Ansible provisioner,
      # when all the machines are up and ready.
      if machine_id == N
        hostconfig.vm.provision :ansible do |ansible|
          # Disable default limit to connect to all the machines
          ansible.limit = "all"
          ansible.playbook = "playbook.yml"
        end
      end
    end
  end
end

Multi-provider compatibility

Another good thing with Vagrant is that you can share your Vagrantfile with others. If you are developing stuffs and using Vagrant to make tests in one or multiple VMs, you can easily share your test infrastructure with other developers.

In case these others are using Vagrant with VirtualBox provider whereas you are using it with libvirt provider, it is still possible to make your Vagrantfile compatible with both. As described in the documentation, Vagrant has a default provider that will be used to launch VMs. It is generally VirtualBox but it can be another one depending on the distribution (as Fedora is not providing a VirtualBox package, libvirt is the default provider).

As a result, the minimum Vagrantfile below should work exactly the same on different environments.

Vagrant.configure("2") do |config|
    config.vm.box = "fedora/41-cloud-base"
end

As a box is specific to a provider and an architecture, Vagrant will automatically get the box compatible with your own environment (if such a box exists of course).

However, it is sometimes necessary to explicitly write provider specific parameters. Some values are indeed managed by the provider and need to be re-written for all providers that will be used with the Vagrantfile. Let say that we discribe a VM with an amount of 8 Gb of memory and 4 CPUs. If this Vangrantfile will be used with VirtualBox and libvirt providers, it should then looks like :

Vagrant.configure("2") do |config|
    config.vm.box = "fedora/41-cloud-base"

    # VirtualBox specific parameters
    config.vm.provider :virtualbox do |vbox|
      vbox.memory = 8192
      vbox.cpus = 4
    end

    # libvirt specific parameters
    config.vm.provider :libvirt do |libvirt|
      libvirt.memory = 8192
      libvirt.cpus = 4
    end
end

Conclusion

Vagrant is the perfect tool to easily describe, configure and eventually share a VM infrastructure. As shown in this post, you can quickly start to write a Vagrantfile. The Ruby syntax let you create more complicated stuffs. Don’t hesitate to also read Vagrant or associated plugins documentation to see the power of this tool.

Unfortunately, since v3.4.0, Vagrant does not have an open source license anymore as the new Business Source License restricts the use of the software to certain users. Such a change has impacts on how the product is integrated in open-source distribution like Fedora which only supports open-source softwares in their official repositories. The package is then distributed in version before v3.4.0 which does not allow user to easily get an updated version.

Sources