Run a tutorial with Vagrant

You can use Vagrant to run a tutorial inside one (or more) virtual machines. This approach is slow and resource intensive, but ensures maximum isolation and allows you to write and test tutorials that you would not want to run on the local system.

Getting started

Assuming you have Vagrant installed, getting started is very simple.

First, let’s write a trivial tutorial file and tell structured-tutorials to use the Vagrant runner:

tutorial.yaml
configuration:
  run:
    runner:
      path: structured_tutorials.runners.vagrant.VagrantRunner
parts:
  - commands:
      - command: ls

Then, in same folder as your tutorial.yaml, place a simple Vagrantfile:

Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "hashicorp-education/ubuntu-24-04"
  config.vm.box_version = "0.1.0"
end

You can now run your tutorial in Vagrant:

user@host:~$ structured-tutorial tutorial.yaml

Configure Vagrant environment

You can configure environment variables used by Vagrant invocation by using the environment option.

The most common example is to configure a different VAGRANT_CWD. The default is the same directory as your tutorial YAML file. For example, if you have the Vagrantfile in a subdirectory:

example/tutorial.yaml
configuration:
  run:
    runner:
      path: structured_tutorials.runners.vagrant.VagrantRunner
      options:
        environment:
          VAGRANT_CWD: subdir
parts:
  - commands:
      - command: ls
example/subdir/Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "hashicorp-education/ubuntu-24-04"
  config.vm.box_version = "0.1.0"
end

Use multiple VMs

Vagrant allows you to define multiple VMs in a single Vagrantfile. structured-tutorials allows you to specify in which machine a particular part should run in via the options directive:

tutorial.yaml
configuration:
  run:
    runner:
      path: structured_tutorials.runners.vagrant.VagrantRunner
parts:
  - commands:
      - command: echo foo
    run:
      options:
        machine: foo

  - commands:
      - command: echo bar
    run:
      options:
        machine: bar

In this example the Vagrantfile specifies two VMs:

Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.define "foo" do |foo|
    foo.vm.box = "hashicorp-education/ubuntu-24-04"
  end

  config.vm.define "bar" do |bar|
    bar.vm.box = "hashicorp-education/ubuntu-24-04"
  end
end

The first commands block will run in the foo VM:

user@host:~$ echo foo

… while the second block will run in the bar VM:

user@host:~$ echo bar

Prepare a custom base box

Sometimes you need to prepare a custom base box for the main tutorial. Known use cases are preparing a box that already has some tools installed or changing the VM so that it connects using a different user.

The following tutorial is a contrived example showing a user how to set up PostgreSQL on a “db” host and Nginx on a “web” host. VMs are started from a custom-built “base” box that already has some common tools installed and allows SSH access as root:

tutorial.yaml
configuration:
  run:
    runner:
      path: structured_tutorials.runners.vagrant.VagrantRunner
      options:
        # Prepare a custom box as base for boxes in the main Vagrantfile:
        prepare_box:
          path: box/  # where the base box Vagrantfile is
          name: prepared-box
parts:
  - commands:
      # Works, because default user of prepared box is now root
      - command: apt-get install -y nginx
    run:
      options:
        machine: web
  - commands:
      - command: apt-get install -y postgresql
    run:
      options:
        machine: web

As per the prepare_box directive, we have to specify a Vagrantfile for the base box in the box/ folder:

box/Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "hashicorp-education/ubuntu-24-04"

  # Do not update SSH key with generated one, provisioning script copies it to /root to allow SSH access.
  config.ssh.insert_key = false

  # provision base box
  config.vm.provision "shell", name: "provision", path: "provision.sh"
end

And the provisioning script in the same location:

box/provision.sh
#!/bin/sh -ex
# Provisioning script for the base box:
# We want to have some tools on every VM.

apt-get update
apt-get install -y ca-certificates curl vim

# Allow the root user to log in with the same credentials.
# The tutorial assumes root access.
echo "PermitRootLogin Yes" >> /etc/ssh/sshd_config

mkdir -p /root/.ssh
cp /home/vagrant/.ssh/authorized_keys /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys

And finally the main Vagrantfile specifying the db and web VMs from the tutorial configuration file:

Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.define "db" do |db|
    db.vm.box = "prepared-box"
    db.ssh.username = "root"
  end

  config.vm.define "web" do |web|
    web.vm.box = "prepared-box"
    web.ssh.username = "root"
  end
end

The first commands block will run in the web VM:

user@host:~$ apt-get install -y nginx

… while the second block will run in the db VM:

user@host:~$ apt-get install -y postgresql