Vagrant is well-known among developers, and Ansible is well-known among DevOps. Together, these two guys can be a great setup for nearly every situation.

Why Vagrant

If you are like me, there's the chance you like to experiment with languages and frameworks. The problem is that all this stuff requires you to install additional software on your main machine, and not all pieces of software play well together. With Vagrant you can have a different machine for every project. You can install everything you need (and even something you don't need), and when you're finished, you can trash the machine with no regrets. Cool, uh?

Installing Vagrant

If you don't have it installed yet, you can download Vagrant from the official website as a pre-packaged installer.

You will also need a virtualization software of choice: Vagrant integrates seamlessly with VirtualBox and VMWare Fusion or Workstation.

Why Ansible

Most of the times, your Vagrant boxes will be new and minimal instances of the Linux/Unix system of choice. Though even the simplest of projects requires a base stack of dependencies to run. You can install all these things manually, but it's not recommended, since... well, we're humans, and humans are not well suited for this kind of tasks. An automated provisioning tool is a better solution. With Vagrant you can use a bunch of different provisioning tools, from a "simple" shell script to specific provisioning software such as Chef, Puppet, and our best friend, Ansible.

I've used all the three kids, and recently I chose Ansible as my favorite, because:

  • it's the simplest of the three,
  • it relies on YAML, an easy to read markup language,
  • it does not require to install agents on your servers, only Python and some of its modules.

Installing Ansible

Ansible can be run from any machine with Python 2.6 or 2.7 installed. This includes Red Hat, Debian, CentOS, OS X, any of the BSDs, but not Windows.

The best way to install it is using your OS package manager. If you're on a Mac I'd recommend using Homebrew: it's as simple as running brew install ansible.

Let them work together

It's been a few years since I've started using Vagrant, and due to mainly tight deadlines and – I have to admit it — a bit of laziness, I've always relied on shell scripts and "cut and paste" to build my development boxes. The result was a mess of scattered Vagrantfiles and bootstrap.sh scripts to be searched for when a new idea came to mind. This lasted until recently I felt the need to make order in all this and have something ready to use whenever I decided to start a new project.

This frenzy is what led me to make a Vagrant template repository. In this repo I've included:

  • a couple of database-only boxes, for MongoDB and MySQL,
  • some boxes for common web development in PHP, Node.js, and Ruby,
  • and, last but not least, a Docker box.

The project is still in progress, I'm planning to add more to this repo, for example, a Postgres box and a Go dev box would be nice to have.

Directory structure

Every machine has a similar directory structure, here is an extract from the PHP box:

/path/to/your/workdir/
  ansible/
    etc/
      apache2/
        vhost.conf
      other-package/
    roles/
      apache2/
      base/
      mysql/
      php/
      php-apache2/
      ...
    php-apache2.yml
  src/
    Your PHP app files here...
  Vagrantfile

The ansible directory contains all the stuff needed by Ansible to provision our machine; we'll see it in detail later.

The Vagrantfile contains settings for your VM and is almost the same for all the boxes, let's try to dissect a basic PHP box.

Vagrant - Dissecting a box

A Vagrantfile is written in Ruby; it can be auto-generated with the command vagrant init and then customised at our will. All the configuration stuff takes place in the Vagrant.configure block:

# -*- mode: ruby -*-
# vi: set ft=ruby :

# A name for your project
PROJECT = 'php-box'

VAGRANTFILE_API_VERSION = 2
Vagrant.require_version ">= 1.8.0"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

    # Your box settings here...
    
end

Before that block, I have defined two handy constants for the project name and the Vagrant API version we are using.

Vagrant can be extended with plugins, in the first configuration block I'm checking for a plugin named vagrant-cachier:

# Cache software packages
if Vagrant.has_plugin?("vagrant-cachier")
 config.cache.enable :apt
 config.cache.enable :apt_lists
 config.cache.enable :composer
end

If we have the plugin installed, we can enable the corresponding settings. In this case, vagrant-cachier enhances Vagrant with a cache for package installers such as apt and Composer.

Then we define our virtual machine with the config.vm.define block:

# VM definition
config.vm.define PROJECT do |jessie|
end

This block configures a virtual machine named after our PROJECTconstant, referenced inside the block with the variable jessie. I chose this name because I'm using a Debian 8 "Jessie" GNU/Linux box, you can choose whatever you want. By giving a name to our VM, we can recognize it among the other VMs installed and running on our system.

When we start the box with vagrant up, we see our machine name:

Bringing machine 'php-box' up with 'virtualbox' provider...
==> php-box: Checking if box 'debian/jessie64' is up to date...
==> php-box: Clearing any previously set forwarded ports...
==> php-box: Clearing any previously set network interfaces...
==> php-box: Preparing network interfaces based on configuration...
...

And we can identify the same machine among the other boxes present in our system with vagrant global-status:

id       name      provider   state    directory
-----------------------------------------------------------------
25c2e55  php-box   virtualbox running  /path/to/php-box/homedir
0d93935  mysql     virtualbox poweroff /path/to/mysql/homedir

The first step in VM configuration is decide the box we want.

# VM definition
config.vm.define PROJECT do |jessie|

    # Box details
    jessie.vm.box      = 'debian/jessie64'
    jessie.vm.box_url  = 'https://atlas.hashicorp.com/debian/boxes/jessie64'
    jessie.vm.hostname = PROJECT

end

In the first block, I declare that I want to use a box named debian/jessie64, which is the official Debian box, which is stored in a repository at the URL https://atlas.hashicorp.com/debian/boxes/jessie64 (Atlas is a public repository hosted by Vagrant maker, HashiCorp). I use our PROJECT value as the hostname for the box.

Next step is configuring the box network. In this case, I've set up a private_network between my machine and the box using DHCP, so that the VirtualBox provider will take care of IPs and other stuff.

# VM definition
config.vm.define PROJECT do |jessie|
    # Box details
    # ...
    
    # Networking setup
    jessie.vm.network "private_network", type: "dhcp"

    # Port forwarding (MySQL, HTTP and HTTPS)
    jessie.vm.network :forwarded_port, guest: 80, host: 8000, auto_correct: true
    jessie.vm.network :forwarded_port, guest: 443, host: 8443, auto_correct: true
    jessie.vm.network :forwarded_port, guest: 3306, host: 3306, auto_correct: true
end

With a network in place, we need a way to "see" the virtual machine services from our host system so that we can configure port forwarding between the host and the guest operating system. Vagrant maps by default port 22 of the VM to 2222 on our machine, if available. I am mapping HTTP, HTTPS and MySQL ports, so that I can lazily manage MySQL with my GUI tool of choice.

Another important thing now is configuring synced folders. This allows us to keep our application code on our host machine and have the VM see it just like it were a local directory. By default, the Vagrantfile directory is shared as /vagrant on the virtual machine and can be disabled if we want.

# VM definition
config.vm.define PROJECT do |jessie|
    # Box details
    # ...
    
    # Networking setup
    # ...

    # Synced folders
    jessie.vm.synced_folder "./src", "/app",
      disabled: false,
      owner: "www-data",
      group: "vagrant"
end

Relative paths are relative to the Vagrantfile location; here I'm sharing local ./src directory as /app in the VM context. I'm also defining optional owner and group settings to Apache's user and vagrant group. You can then share a combination of other directories.

The section jessie.vm.provider contains settings specific to the VirtualBox provider.

# VM definition
config.vm.define PROJECT do |jessie|
    # Box details
    # ...
    
    # Networking setup
    # ...

    # Synced folders
    # ...

    # VM Provider specific settings for VirtualBox
    jessie.vm.provider "virtualbox" do |vb|

      # Share VPN connections
      vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]

      vb.name = PROJECT

      # Customise the amount of memory on the VM:
      vb.memory = "512"
    end
end

In short, I'm defining a name for the VM (you'll see it in VirtualBox UI), a memory amount, and with the option --natdnshostresolver1 set to on; I'm also sharing my host's VPN connections.

The last step is the provisioning of our Vagrant box.

# VM definition
config.vm.define PROJECT do |jessie|
    # Box details
    # ...
    
    # Networking setup
    # ...

    # Synced folders
    # ...

    # VM Provider specific settings for VirtualBox
    # ...

    # Add custom key
    jessie.vm.provision :file, :source => "#{ENV['HOME']}/.ssh/id_rsa.pub", :destination => "/tmp/vagrantfile-pubkey"
    jessie.vm.provision :shell, :privileged => false, :inline => <<-SHELL
      cat /tmp/vagrantfile-pubkey >> $HOME/.ssh/authorized_keys
      SHELL

    # Provisioning with Ansible
    jessie.vm.provision "ansible" do |ansible|
      # Vagrant auto generates the inventory file, uncomment below to use yours
      # ansible.inventory_path = "hosts"

      # Custom config
      # ENV['ANSIBLE_CONFIG'] = "/path/to/custom/ansible.cfg"
      # Example:
      # ENV['ANSIBLE_CONFIG'] = "#{ENV['HOME']}/Dev/Ansible/ansible.cfg"

      # Add the box to mysql group and use custom group vars
      ansible.groups = {
        "php" => PROJECT
        "php:vars" => {
          "variable1" => 9,
          "variable2" => "example"
        }
      }

      # Custom host vars
      ansible.host_vars = {
        PROJECT => {
        }
      }

      # ansible.verbose = "vvvv"

      # Use local playbook that has access to shared roles, with custom vars above
      ansible.playbook = "ansible/php-apache2.yml"
    end
end

I'm using three provisioners here: file, shell, and Ansible. With the file provisioner, I'm simply copying my SSH public key to a temporary file. Then, with the shell provisioner, I execute an inline command with the user vagrant (the default non-privileged working user) that copies the content of the key to the user's authorized_keys file. Once the VM is provisioned, I can add the line config.ssh.private_key_path = "#{ENV['HOME']}/.ssh/id_rsa" that uses my SSH key instead of Vagrant default insecure one.

Ansible - The Provisioning

Inside the "ansible" provisioning block I define the settings for the Ansible provisioner that will be called by Vagrant. First, it's important to notice that Vagrant generates some basic settings for us, such as the inventory file (the list of machines that Ansible can control). We could customise ansible.inventory_path settings and set the ANSIBLE_CONFIG environment variable to point to a custom ansible.cfg file, but we're happy with the defaults here.

What we need instead is ansible.groups directive. In this file, I've created a machine group called "php" that contains the host name of our virtual machine. Our playbook will use the hosts: php directive, telling Ansible that that playbook must be executed only on the hosts that belong to the "php" group. Inside the ansible.groups directive, we can also set some group variables, global settings that will be available to our playbook for all the machines inside the group.

We can also use the ansible.host_vars directive to specify some settings that will be available only to a specific host. The last directive will set the playbook file that we are going to run on our VM: ansible.playbook = "ansible/php-apache2.yml".

Roles, Tasks and Playbooks

An Ansible playbook is a YAML file that contains a list of actions that Ansible will perform on a host or group of hosts. The details on how to reach hosts and groups are stored in the inventory file that, in our case, is already provided and injected by Vagrant. Within the playbook, we can apply "roles" and execute "tasks" on the target machines. Let's see how.

---
# A sample PHP playbook

  - hosts: php

    become: yes
    become_user: "root"
    become_method: "sudo"
    
    # Custom vars
    vars:
    
    # Apply roles
    roles:
    
    # Execute tasks
    tasks:
    

Every YAML object inside a playbook is a play. We can define and execute more than one play, but in this case, one is enough. The hosts directive defines the machine, or group of machines, that will be targeted by the playbook, in this case, the php group. The become* directive tells Ansible to execute the playbook as a different user the one used for SSH connection. In this case, it is root, and the method used to switch the user is sudo.

The vars directive can be used to define variables to use within tasks or to override the default roles settings. We are not using it since we can define them inside the Vagrantfile.

The role directive is one of the most important here: it applies one or more "roles" to our machine. An Ansible role is a shareable group of tasks, variables, and handlers with a known file structure.

---
    # ...
    roles:
      - { role: php-apache2, tags: ['php','apache2'] }
    # ...

Here we are applying the php-apache2 role that, as the name suggests, will install Apache with PHP support, but the really cool stuff is that roles can depend and install other roles. A role's directory must be located inside a roles parent directory, relative to the playbook file. It is structured more or less like this:

ansible/roles/php-apache2/
  defaults/
    main.yml
  meta/
    main.yml
  tasks/
    main.yml

The file defaults/main.yml contains the default settings for this role, that can be overridden by a playbook vars directive and/or by specific group and host variables. The tasks directory contains a list of task files for the role, while the meta directory contains the role metadata inside meta/main.yml. It is in this file that we declare the dependencies for the role:

---
dependencies:
  - { role: php }
  - { role: apache2 }

As you can see, our php-apache2 role depends on other two roles: apache2 and php, which in turn depends recursively on the base role. You can write the roles yourself or get them from the Ansible Galaxy, which is where some of these roles have been taken from and customised.

Hence, by applying the single php-apache2 role, Ansible will first run on our machine the actions of the base role (i.e install vim, curl, setup timezone, locale, etc.). Then it will execute the php role, which installs PHP, Composer, and other useful packages. Next the apache2 role will install Apache, and at last the php-apache2 role will install the libapache2-mod-php5 and restart the web server.

Each task is idempotent, so you can execute the playbook 100 times on the same machine, and the final state of the system will be the same.

Ansible gives us a lot of power on our machine, for example we can define a list of packages in defaults/main.yml as a YAML array:

---
php_packages:
    - mysql-client
    - bzip2
    - zip
    - unzip
    - ...

And then install the packages from the main tasks file by using a loop:

# roles/php-apache2/tasks/main.yml
---
- name: Debian | install packages
  apt: pkg={{ item }} state=installed
  with_items: "{{ php_packages }}"
  register: php_install

- name: Install composer
  include: composer.yml
  tags:
    - composer

The first action above, for example, executes the apt module with the pkg and state arguments. This means that Ansible uses apt commands (on Debian/Ubuntu) to ensure that a given package is installed on the system. The with_items directive allows to loop through the php_packages array and use every item as an argument of the module. The optional register directive saves the output of the task into a variable that can be used in other tasks.

We can also group some tasks into a file and include it other files, such as composer.yml in this example.

In short, the goal of each task is to execute a module, with specific arguments. And variables can be used as arguments to modules.

The action format is the same for role tasks and playbook tasks. Looking back to our playbook, once we have applied our roles, we can execute other specific tasks on our machine.

With all the roles in place we can copy an Apache vhost file to our destination machine with some options like owner and mode (the source path is relative to the playbook file):

- name: copy app vhost file
  copy:
    src: etc/apache2/vhost.conf
    dest: /etc/apache2/sites-available/000-app.conf
    owner: root
    group: root
    mode: 0644

We can then execute a shell command to enable our virtual host:

- name: enable website
  command: "a2ensite 000-app"
  args:
    creates: "/etc/apache2/sites-enabled/000-app.conf"
  notify:
    - reload apache2

The command is executed using the default shell for the user. The creates directive defines the result of the command: if the file already exists the command is not run. The notify directive enqueues a call to a handler called reload apache defined as:

handlers:
  - name: reload apache2
    action: service name=apache2 state=reloaded

Handlers are processed at the end of all tasks. This handler ensures that the service named apache2 reloads its configuration.

Finally, since we are on a PHP box, Ansible can also run Composer for us:

- name: run composer install
  composer:
    command: install
    working_dir: /app

Conclusions

We've just scratched the surface of what Vagrant and Ansible can do to enhance our development workflow. And don't forget that Ansible can — and should — be used on our production machines too!

Experiment with these templates, and feel free to fork, comment, share and contribute. Happy coding!