LEMP is a variant of the common LAMP (Linux, Apache, MariaDB and PHP) bundle that swaps the Apache server with Nginx.

Many times I’ve used it to test some web application. Usually, you’d want to do this in a clean environment that won’t interfere with any previous configuration.

For this, you’d normally use some kind of virtual machine that you’ve installed and configured from scratch. Maybe, if it’s a common environment, you’d create a snapshot so you can revert to it afterwards. Or maybe you could use one of the many cloud images found in the Internet.

However, a much simpler option is to use Vagrant to handle these cloud images and a configuration management tool to handle their configuration.

Vagrant is an open-source software product for building and maintaining portable virtual development environments.

Wikipedia

Vagrant can use different engines to boot up these cloud images, and also different tools for software provisioning. Here we will use VirtualBox and Ansible for these roles respectively.

Ansible is an open-source automation engine that automates software provisioning, configuration management, and application deployment.

Wikipedia

On our host machine, we will only need to install Vagrant and VirtualBox, since Ansible will run in the guest machine. Therefore, we need to download and install the appropriate software for our operating system:

Configuration of Vagrant

Vagrant’s configuration is stored in a single file named Vagrantfile.

First, we tell Vagrant to use VirtualBox as the default provider:

ENV['VAGRANT_DEFAULT_PROVIDER'] = 'virtualbox'

Then, we start the actual configuration by selecting the base cloud image we will be using. For this example, we use the official Ubuntu Xenial 32-bit image:

config.vm.box = 'ubuntu/xenial32'

To configure the virtual machine hardware (512 MB of RAM and a single CPU capped to 50%), we add the following:

config.vm.provider :virtualbox do |vbox|
  vbox.memory = 512
  vbox.cpus = 1
  vbox.customize ['modifyvm', :id, '--cpuexecutioncap', '50']
end

Now we configure the hostname and IP address of the guest OS:

config.vm.define 'lemp' do |node|
  node.vm.hostname = 'lemp'
  node.vm.network :private_network, ip: '172.28.128.10'
  node.vm.post_up_message = 'Web: http://172.28.128.10'
end

We will also share the local subdirectory vagrant with the guest so it’s mounted at /vagrant:

config.vm.synced_folder 'vagrant', '/vagrant'

Finally, we configure Ansible to be run locally on the guest using the configuration found in /vagrant/cfg. In this directory, it will find the inventory file hosts.ini and the playbook file site.yml. We will also tell it to run all tasks using sudo:

config.vm.provision :ansible_local do |ansible|
  ansible.provisioning_path = '/vagrant/cfg'
  ansible.inventory_path = 'hosts.ini'
  ansible.playbook = 'site.yml'
  ansible.sudo = true
end

In the end, the file should look like this:

ENV['VAGRANT_DEFAULT_PROVIDER'] = 'virtualbox'

Vagrant.configure('2') do |config|
  config.vm.box = 'ubuntu/xenial32'
  config.vm.provider :virtualbox do |vbox|
    vbox.memory = 512
    vbox.cpus = 1
    vbox.customize ['modifyvm', :id, '--cpuexecutioncap', '50']
  end
  config.vm.define 'lemp' do |node|
    node.vm.hostname = 'lemp'
    node.vm.network :private_network, ip: '172.28.128.10'
    node.vm.post_up_message = 'Web: http://172.28.128.10'
  end
  config.vm.synced_folder 'vagrant', '/vagrant'
  config.vm.provision :ansible_local do |ansible|
    ansible.provisioning_path = '/vagrant/cfg'
    ansible.inventory_path = 'hosts.ini'
    ansible.playbook = 'site.yml'
    ansible.sudo = true
  end
end

Configuration of Ansible

Since we are sharing the subdirectory vagrant with the guest machine, we need to place all configuration files for Ansible inside vagrant/cfg as specified in Vagrantfile.

Ansible’s inventory file contains the machines in which it will run. In this case, it will only run locally on one machine so we add it:

lemp        ansible_connection=local

Also, Ansible’s playbooks store the steps to be taken on the machines. We could put everything in this file, but Ansible’s Best Practices recommend using roles:

---
- name: Configure LEMP server
  hosts: lemp
  roles:
    - mariadb
    - php
    - nginx

Here we specify that this task will apply to the machine named lemp and that it will execute the roles mariadb, php and nginx.

MariaDB role

This role will install and configure MariaDB. Its configuration lives in the subdirectory vagrant/cfg/roles/mariadb:

vagrant/cfg/roles/mariadb
├── handlers
│   └── main.yml
├── tasks
│   └── main.yml
└── vars
    └── main.yml

The tasks to be run are saved in tasks/main.yml:

---
- name: Install server
  package: name={{ item }} state=present
  with_items:
    - mariadb-server
    - python-mysqldb
  notify:
    - start mysql

- name: Change root password
  mysql_user:
    name: root
    host: localhost
    password: '{{ mysql_root_password }}'
    state: present

- name: Change bind-address
  replace:
    dest: /etc/mysql/mariadb.conf.d/50-server.cnf
    regexp: '^bind-address'
    replace: 'bind-address = {{ mysql_bind_address }}'
  notify:
    - restart mysql

- name: Create test database
  mysql_db: name={{ mysql_db_name }} state=present

- name: Create test user
  mysql_user:
    name: '{{ mysql_db_user }}'
    host: '%'
    password: '{{ mysql_db_password }}'
    priv: '{{ mysql_db_name }}.*:ALL'
    state: present

These are the steps taken:

  1. First, using the package module, we install the necessary software.
  2. Then, using the mysql_user module, we change MariaDB’s root password to the one in the variable mysql_root_password.
  3. To be able to access the server from our host machine, we use the replace module to modify MariaDB’s configuration file and change its bind-address to the one in the variable mysql_bind_address.
  4. We then create a test database, using the mysql_db module.
  5. And finally we create a test user, again, using the mysql_user module.

All the variables we use in this role can be set in vars/main.yml:

---
mysql_root_password:    'root'
mysql_bind_address:     '0.0.0.0'
mysql_db_name:          'test'
mysql_db_user:          'test'
mysql_db_password:      'test'

Also, we define some handlers which are basic tasks that are run when another task changes something and notifies the handler. We use them to make sure the server is enabled and to restart it when we change the configuration:

---
- name: start mysql
  service: name=mysql enabled=yes state=started

- name: restart mysql
  service: name=mysql state=restarted

PHP role

This role will install PHP. Its configuration lives in the subdirectory vagrant/cfg/roles/php:

vagrant/cfg/roles/php
├── handlers
│   └── main.yml
└── tasks
    └── main.yml

Using the package module, it installs PHP-FPM (FastCGI Process Manager) and the module to communicate with MySQL:

---
- name: Install PHP
  package: name={{ item }} state=present
  with_items:
    - php-fpm
    - php-mysql
  notify:
    - start php-fpm

We also define the handler that will make sure the service is enabled:

---
- name: start php-fpm
  service: name=php7.0-fpm enabled=yes state=started

Nginx role

This role will install and configure Nginx. Its configuration lives in the subdirectory vagrant/cfg/roles/nginx:

vagrant/cfg/roles/nginx
├── handlers
│   └── main.yml
├── tasks
│   └── main.yml
├── templates
│   └── default
└── vars
    └── main.yml

The tasks in tasks/main.yml are:

---
- name: Install server
  package: name={{ item }} state=present
  with_items:
    - nginx
  notify:
    - start nginx

- name: Change default configuration
  template:
    src: default
    dest: /etc/nginx/sites-available/default
  notify:
    - reload nginx

Once again, using the package module, it will install the necessary software. Then, using the template module, it changes the default’s site configuration by copying our template from templates/default:

# Default server configuration

server {
  listen {{ http_port }} default_server;
  listen [::]:{{ http_port }} default_server;

  root {{ base_dir }};
  index index.html index.htm index.php;
  server_name _;

  location / {
    try_files $uri $uri/ =404;
  }

  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.0-fpm.sock;
  }

  location ~ /\.ht {
    deny all;
  }
}

The variables used in the template can be set in vars/main.yml:

---
http_port:              80
base_dir:               '/vagrant/www'

This way, any file we save in the subdirectory vagrant/www of our host machine will be accessible in the guest machine’s web server. We can work with our favorite development tools locally and see all changes immediately in the web server.

Finally, we define the handlers that will make sure the server is enabled and the configuration reloaded when we make any change:

---
- name: start nginx
  service: name=nginx enabled=yes state=started

- name: reload nginx
  service: name=nginx state=reloaded

Using Vagrant

Once everything is set up, we just have to start the guest machine with the command vagrant up lemp.

The first time we run it, it will download the necessary cloud image, so it might take a while. Subsequent boots will only check whether we have an updated version of the image.

Once it finishes booting up, we can connect to the web server in http://172.28.128.10. For testing purposes, let’s say we’ve saved this in vagrant/www/index.php:

<?php phpinfo(); ?>

When we connect to the server with our web browser, we will see something like this:

PHP information

We can also connect to the database server by running:

mysql --host=172.28.128.10 --user=test --password test

And, after providing our password, we will be able to enter SQL commands:

Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 32
Server version: 10.0.29-MariaDB-0ubuntu0.16.04.1 Ubuntu 16.04

Copyright (c) 2000, 2016, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [test]> 

To control the guest machine, here are the most important Vagrant commands:

Action Command
boot guest machine vagrant up lemp
reboot guest machine vagrant reload lemp
shutdown guest machine vagrant halt lemp
boot and reconfigure guest machine vagrant up lemp --provision
connect to guest machine with SSH vagrant ssh lemp
destroy guest machine vagrant destroy lemp

Conclusion

Coupling Vagrant with Ansible (or any other SCM tool) allows for a portable reproducible system, contained in just a few text files:

.
├── [ 66K]  vagrant
│   ├── [ 58K]  cfg
│   │   ├── [  37]  hosts.ini
│   │   ├── [ 54K]  roles
│   │   │   ├── [ 17K]  mariadb
│   │   │   │   ├── [4.1K]  handlers
│   │   │   │   │   └── [ 133]  main.yml
│   │   │   │   ├── [4.7K]  tasks
│   │   │   │   │   └── [ 759]  main.yml
│   │   │   │   └── [4.2K]  vars
│   │   │   │       └── [ 162]  main.yml
│   │   │   ├── [ 21K]  nginx
│   │   │   │   ├── [4.1K]  handlers
│   │   │   │   │   └── [ 131]  main.yml
│   │   │   │   ├── [4.3K]  tasks
│   │   │   │   │   └── [ 263]  main.yml
│   │   │   │   ├── [4.4K]  templates
│   │   │   │   │   └── [ 397]  default
│   │   │   │   └── [4.1K]  vars
│   │   │   │       └── [  70]  main.yml
│   │   │   └── [ 12K]  php
│   │   │       ├── [4.1K]  handlers
│   │   │       │   └── [  79]  main.yml
│   │   │       └── [4.1K]  tasks
│   │   │           └── [ 139]  main.yml
│   │   └── [  93]  site.yml
│   └── [4.1K]  www
│       └── [ 144]  index.php
└── [ 748]  Vagrantfile

No more messing with installers, restoring snapshots or reconfiguring stuff. You can boot up a fresh system, mess it up, destroy it and boot it up brand new again in a few minutes. You can even use a version control system to store these files and share them with others.

Also, official images for many operating systems can be found in Vagrant’s website. Not just for Ubuntu but also for Debian, Fedora, CentOS and FreeBSD. You can even specify your own box with the setting config.vm.box_url.

At the same time, Ansible’s myriad of modules let us configure the guest OS automatically in almost any way possible, even though we may need to adapt many of the tasks to specific Linux distros or operating systems.

In the end, this method greatly simplifies the process of creating and managing test environments.

Further reading