In this example we are going to use Vagrant and Ansible to build a LEMP (Linux, Nginx, MySQL, PHP-FPM) servers on multiple dedicated boxes. We will make sure that certain versions of Nginx (1.10.*), MySQL (5.7.*) and PHP (7.1.*) installed.


System


I am using Ansible 2.4.3.0 and Vagrant 1.9.5 on MacOS.


Structure


$ tree
.
├── Vagrantfile
├── provisioning
│   ├── group_vars
│   │   └── all
│   ├── host_vars
│   │   ├── mysql
│   │   ├── nginx
│   │   └── php
│   ├── hosts.yml
│   ├── roles
│   │   ├── all
│   │   │   ├── handlers
│   │   │   │   └── main.yml
│   │   │   ├── tasks
│   │   │   │   └── main.yml
│   │   │   └── templates
│   │   │   └── ntp.conf.j2
│   │   ├── mysql
│   │   │   ├── handlers
│   │   │   │   └── main.yml
│   │   │   ├── tasks
│   │   │   │   └── main.yml
│   │   │   └── templates
│   │   │   └── mysqld.cnf.j2
│   │   ├── nginx
│   │   │   ├── handlers
│   │   │   │   └── main.yml
│   │   │   ├── tasks
│   │   │   │   └── main.yml
│   │   │   └── templates
│   │   │   └── site.j2
│   │   └── php
│   │   ├── handlers
│   │   │   └── main.yml
│   │   ├── tasks
│   │   │   └── main.yml
│   │   └── templates
│   │   ├── db.php.j2
│   │   ├── index.php.j2
│   │   └── site.conf.j2
│   └── site.yml
└── vagrant_hosts.yml

Tasks



Files


vagrant_hosts.yml


# Reflect any "hostname" and "ip" changes in "provisioning/hosts.yml" file

- name: This is for PHP box
label: PHP - 192.168.99.31
hostname: php
ip: 192.168.99.31

- name: This is for NGINX box
label: NGINX - 192.168.99.32
hostname: nginx
ip: 192.168.99.32

- name: This is for MYSQL box
label: MYSQL - 192.168.99.33
hostname: mysql
ip: 192.168.99.33

Vagrantfile


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

require "yaml"

boxes = YAML.load_file("vagrant_hosts.yml")

Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/xenial64"

ANSIBLE_RAW_SSH_ARGS = []
i = 0

boxes.each do |box|
ANSIBLE_RAW_SSH_ARGS << "-o IdentityFile=.vagrant/machines/#{box["hostname"]}/virtualbox/private_key"

config.vm.define box["hostname"] do |machine|
machine.vm.hostname = box["hostname"]
machine.vm.network "private_network", ip: box["ip"]
machine.vm.synced_folder box["hostname"], "/srv/www", create: true, nfs: true, mount_options: ["actimeo=2"]
machine.vm.provider :virtualbox do |virtualbox|
virtualbox.name = box["label"]
end
i += 1

if i == boxes.count
machine.vm.provision :ansible do |ansible|
ansible.raw_ssh_args = ANSIBLE_RAW_SSH_ARGS
ansible.verbose = "-vvv"
ansible.limit = "all"
ansible.inventory_path = "provisioning/hosts.yml"
ansible.playbook = "provisioning/site.yml"
end
end
end
end
end

provisioning/hosts.yml


# Reflect any "hostname" and "ip" changes in "../vagrant_hosts.yml" file

all:
hosts:
php:
ansible_host: 192.168.99.31
nginx:
ansible_host: 192.168.99.32
mysql:
ansible_host: 192.168.99.33

provisioning/site.yml


---
# This playbook sets up whole stack.

- name: Apply common configurations to "all" host
hosts: all
remote_user: root
become: yes
roles:
- all

- name: Apply php configurations to "php" host
hosts: php
remote_user: root
become: yes
roles:
- php

- name: Apply nginx configurations to "nginx" host
hosts: nginx
remote_user: root
become: yes
roles:
- nginx

- name: Apply mysql configurations to "mysql" host
hosts: mysql
remote_user: root
become: yes
roles:
- mysql

provisioning/group_vars/all


---
# Variables listed here are applicable to "all" role

ansible_python_interpreter: /usr/bin/python3

locale: en_GB.UTF-8
language: en_GB:en
timezone: Europe/London
localhost: 127.0.0.1

web_root: /srv/www

mysql_database: lemp
mysql_port: 3306
mysql_root_user: root
mysql_root_password: root
mysql_remote_user: php
mysql_remote_password: php

provisioning/host_vars/mysql


---
# Variables listed here are applicable to "nginx" role

mysql_encoding: utf8mb4
mysql_collation: utf8mb4_unicode_ci

provisioning/host_vars/nginx


---
# Variables listed here are applicable to "nginx" role

nginx_root: /etc/nginx


provisioning/host_vars/php


---
# Variables listed here are applicable to "php" role

user: www-data

provisioning/roles/all/handlers/main.yml


---
# This playbook contains handlers that can be called in "all" tasks.

# sudo update-locale
- name: Update locale
shell: update-locale

# sudo service ntp restart (whether running or not)
- name: Restart ntp
service:
name: ntp
state: restarted
enabled: yes

provisioning/roles/all/tasks/main.yml


---
# This playbook contains actions that will be run on "all" hosts.

# sudo apt-get update
- name: Update apt packages
apt:
update_cache: yes
tags:
- system

# sudo locale-gen en_GB.UTF-8
- name: Install GB locale
locale_gen:
name: "{{ locale }}"
state: present
tags:
- locale

# sudo update-locale LANG=en_GB.UTF-8
# sudo update-locale LC_ALL=en_GB.UTF-8
# sudo update-locale LANGUAGE=en_GB:en
- name: Set locale
command: update-locale "{{ item }}"
with_items:
- LANG="{{ locale }}"
- LC_ALL="{{ locale }}"
- LANGUAGE="{{ language }}"
notify: Update locale
tags:
- locale

# sudo timedatectl set-timezone Europe/London
- name: Set time zone to Europe/London
timezone:
name: "{{ timezone }}"
tags:
- time

# sudo apt-get install ntp
- name: Install ntp
apt:
name: ntp
state: present
update_cache: yes
tags:
- ntp

# sudo cp provisioning/all/templates/ntp.conf.j2 /etc/ntp.conf
- name: Configure ntp file and restart
template:
src: ntp.conf.j2
dest: /etc/ntp.conf
notify: Restart ntp
tags:
- ntp

# sudo apt-get install nano
- name: Install nano
apt:
name: nano
state: present
update_cache: yes
tags:
- nano

# sudo apt-get autoclean
- name: Remove useless apt packages from the cache
apt:
autoclean: yes
tags:
- system

# sudo apt-get autoremove
- name: Remove dependencies that are no longer required
apt:
autoremove: yes
tags:
- system

provisioning/roles/all/templates/ntp.conf.j2


driftfile /var/lib/ntp/drift

# Specify UK NTP servers.
server 0.uk.pool.ntp.org
server 1.uk.pool.ntp.org
server 2.uk.pool.ntp.org
server 3.uk.pool.ntp.org

# Use Ubuntu's NTP server as a fallback.
server ntp.ubuntu.com

# Local users may obtain data from NTP servers.
restrict {{ localhost }}
restrict ::1

provisioning/roles/mysql/handlers/main.yml


---
# This playbook contains handlers that can be called in "mysql" tasks.

# sudo service mysql restart (whether running or not)
- name: Restart mysql
service:
name: mysql
state: restarted
enabled: yes

provisioning/roles/mysql/tasks/main.yml


---
# This playbook contains actions that will be run on "mysql" hosts.

# sudo apt-get install *
- name: Install mysql and packages
apt:
name: "{{ item }}"
state: present
update_cache: yes
with_items:
- mysql-server=5.7.*
- python3-mysqldb
tags:
- mysql

# CREATE DATABASE lemp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
- name: Create a new database
mysql_db:
name: "{{ mysql_database }}"
encoding: "{{ mysql_encoding }}"
collation: "{{ mysql_collation }}"
state: present
tags:
- mysql

# Remove all anonymous user accounts
- name: Remove all anonymous user accounts
mysql_user:
name: ''
host_all: yes
state: absent
tags:
- mysql

# Create "root" user with all privileges
- name: Create "root" user with all privileges for only local hosts
mysql_user:
name: "{{ mysql_root_user }}"
password: "{{ mysql_root_password }}"
priv: "*.*:ALL,GRANT"
state: present
tags:
- mysql

# Create "php" user with all privileges for 192.168.99.31 as host
- name: Create "php" user with all privileges on "lemp" database for only 192.168.99.31 host
mysql_user:
login_user: "{{ mysql_root_user }}"
login_password: "{{ mysql_root_password }}"
name: "{{ mysql_remote_user }}"
password: "{{ mysql_remote_password }}"
priv: "{{ mysql_database }}.*:ALL,GRANT"
host: "{{ hostvars['php'].ansible_host }}"
state: present
tags:
- mysql

# sudo cp provisioning/mysql/templates/mysqld.cnf.j2 /etc/mysql/mysql.conf.d/mysqld.cnf
- name: Create mysql configuration file
template:
src: mysqld.cnf.j2
dest: /etc/mysql/mysql.conf.d/mysqld.cnf
notify: Restart mysql
tags:
- mysql

provisioning/roles/mysql/templates/mysqld.cnf.j2


[mysqld_safe]
socket = /var/run/mysqld/mysqld.sock
nice = 0

[mysqld]
user = mysql
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
port = {{ mysql_port }}
basedir = /usr
datadir = /var/lib/mysql
tmpdir = /tmp
lc-messages-dir = /usr/share/mysql

skip-external-locking

bind-address = 0.0.0.0

key_buffer_size = 16M
max_allowed_packet = 16M
thread_stack = 192K
thread_cache_size = 8

myisam-recover-options = BACKUP

query_cache_limit = 1M
query_cache_size = 16M

log_error = /var/log/mysql/error.log

expire_logs_days = 10
max_binlog_size = 100M

provisioning/roles/nginx/handlers/main.yml


---
# This playbook contains handlers that can be called in "nginx" tasks.

# sudo service nginx restart (whether running or not)
- name: Restart nginx
service:
name: nginx
state: restarted
enabled: yes

provisioning/roles/nginx/tasks/main.yml


---
# This playbook contains actions that will be run on "nginx" hosts.

# sudo apt-get install nginx (v.1.10.*)
- name: Install nginx
apt:
name: nginx=1.10.*
state: present
update_cache: yes
tags:
- nginx

# sudo cp provisioning/nginx/templates/site.j2 /etc/nginx/sites-available/site*
- name: Configure dummy site configuration files
template:
src: site.j2
dest: "{{ nginx_root }}/sites-available/{{ item.site }}"
vars:
site: "{{ item.site }}"
port: "{{ item.port }}"
with_items:
- { site: site1, port: 9001 }
- { site: site2, port: 9002 }
tags:
- nginx

# sudo ln -s /etc/nginx/sites-available/site* /etc/nginx/sites-enabled/site*
- name: Create dummy site symbolic links
file:
src: "{{ nginx_root }}/sites-available/{{ item }}"
dest: "{{ nginx_root }}/sites-enabled/{{ item }}"
state: link
with_items:
- site1
- site2
tags:
- nginx

# sudo echo "192.168.99.32 site1.com site2.com" >> /etc/hosts
- name: Create dummy site hosts
lineinfile:
path: /etc/hosts
line: "{{ hostvars['nginx'].ansible_host }} site1.com site2.com"
notify: Restart nginx
tags:
- nginx

provisioning/roles/nginx/templates/site.j2


server {
listen 80;
listen [::]:80;

server_name {{ site }}.com;

root {{ web_root }}/{{ site }};

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_pass {{ hostvars['php'].ansible_host }}:{{ port }};
}

# Deny access to .htaccess files
location ~ /\.ht {
deny all;
}

location / {
try_files $uri $uri/ /index.php?$query_string;
}

error_log /var/log/nginx/{{ site }}_error.log;
access_log /var/log/nginx/{{ site }}_access.log;
}

provisioning/roles/php/handlers/main.yml


---
# This playbook contains handlers that can be called in "php" tasks.

# sudo service php7.1-fpm restart (whether running or not)
- name: Restart php
service:
name: php7.1-fpm
state: restarted
enabled: yes

provisioning/roles/php/tasks/main.yml


---
# This playbook contains actions that will be run on "php" hosts.

# sudo apt-get install software-properties-common
- name: Install software-properties-common
apt:
name: software-properties-common
state: present
tags:
- php

# sudo add-apt-repository ppa:ondrej/php
- name: Add repository into sources list
apt_repository:
repo: ppa:ondrej/php
state: present
update_cache: yes
tags:
- php

# sudo apt-get install *
- name: Install common modules
apt:
name: "{{ item }}"
state: present
with_items:
- php7.1
- php7.1-cli
- php7.1-common
- php7.1-json
- php7.1-opcache
- php7.1-mysql
- php7.1-mbstring
- php7.1-mcrypt
- php7.1-zip
- php7.1-fpm
tags:
- php

# sudo cp provisioning/php/templates/site.conf.j2 /etc/php/7.1/fpm/pool.d/site*.conf
- name: Configure dummy site configuration files
template:
src: site.conf.j2
dest: "/etc/php/7.1/fpm/pool.d/{{ item.site }}.conf"
vars:
site: "{{ item.site }}"
port: "{{ item.port }}"
with_items:
- { site: site1, port: 9001 }
- { site: site2, port: 9002 }
tags:
- php

# sudo mkdir -p /srv/www/site*
- name: Create web root for dummy sites
file:
path: "{{ web_root }}/{{ item }}"
state: directory
with_items:
- site1
- site2
tags:
- php

# sudo cp provisioning/php/templates/index.php.j2 /srv/www/site*/index.php
- name: Create dummy site index files
template:
src: index.php.j2
dest: "{{ web_root }}/{{ item }}/index.php"
vars:
site: "{{ item }}"
with_items:
- site1
- site2
notify: Restart php
tags:
- php

# sudo cp provisioning/php/templates/db.php.j2 /srv/www/site*/db.php
- name: Create dummy site db files
template:
src: db.php.j2
dest: "{{ web_root }}/{{ item }}/db.php"
vars:
site: "{{ item }}"
with_items:
- site1
- site2
notify: Restart php
tags:
- php

provisioning/roles/php/templates/db.php.j2


<?php
if (!$conn = mysqli_connect(
"{{ hostvars['mysql'].ansible_host }}",
"{{ mysql_remote_user }}",
"{{ mysql_remote_password }}",
"{{ mysql_database }}",
"{{ mysql_port }}"
)) {
exit('Connection failed on {{ site }}: '.mysqli_connect_error().PHP_EOL);
}

echo 'Connection succeeded on {{ site }}!'.PHP_EOL;

provisioning/roles/php/templates/index.php.j2


<?php
echo 'Hello from {{ site }}'.PHP_EOL;

provisioning/roles/php/templates/site.conf.j2


[{{ site }}]
user = {{ user }}
group = {{ user }}

listen = {{ port }}
listen.allowed_clients = {{ hostvars['nginx'].ansible_host }}

listen.owner = {{ user }}
listen.group = {{ user }}

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

catch_workers_output = yes
php_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/{{ site }}.log

Build


$ vagrant up
...
PLAY [Apply common configurations to "all" host] *******************************
TASK [Gathering Facts] *********************************************************
ok: [php]
ok: [mysql]
ok: [nginx]
TASK [all : Update apt packages] ***********************************************
changed: [mysql]
changed: [nginx]
changed: [php]
TASK [all : Install GB locale] *************************************************
changed: [nginx]
changed: [mysql]
changed: [php]
TASK [all : Set locale] ********************************************************
changed: [nginx] => (item=LANG="en_GB.UTF-8")
changed: [mysql] => (item=LANG="en_GB.UTF-8")
changed: [php] => (item=LANG="en_GB.UTF-8")
changed: [nginx] => (item=LC_ALL="en_GB.UTF-8")
changed: [mysql] => (item=LC_ALL="en_GB.UTF-8")
changed: [php] => (item=LC_ALL="en_GB.UTF-8")
changed: [nginx] => (item=LANGUAGE="en_GB:en")
changed: [mysql] => (item=LANGUAGE="en_GB:en")
changed: [php] => (item=LANGUAGE="en_GB:en")
TASK [all : Set time zone to Europe/London] ************************************
changed: [mysql]
changed: [nginx]
changed: [php]
TASK [all : Install ntp] *******************************************************
changed: [nginx]
changed: [php]
changed: [mysql]
TASK [all : Configure ntp file and restart] ************************************
changed: [nginx]
changed: [php]
changed: [mysql]
TASK [all : Install nano] ******************************************************
ok: [nginx]
ok: [php]
ok: [mysql]
TASK [all : Remove useless apt packages from the cache] ************************
ok: [php]
ok: [nginx]
ok: [mysql]
TASK [all : Remove dependencies that are no longer required] *******************
ok: [nginx]
ok: [mysql]
ok: [php]
RUNNING HANDLER [all : Update locale] ******************************************
changed: [nginx]
changed: [php]
changed: [mysql]
RUNNING HANDLER [all : Restart ntp] ********************************************
changed: [nginx]
changed: [mysql]
changed: [php]

PLAY [Apply php configurations to "php" host] **********************************
TASK [Gathering Facts] *********************************************************
ok: [php]
TASK [php : Install software-properties-common] ********************************
ok: [php]
TASK [php : Add repository into sources list] **********************************
changed: [php]
TASK [php : Install common modules] ********************************************
changed: [php] => (item=[u'php7.1', u'php7.1-cli', u'php7.1-common', u'php7.1-json', u'php7.1-opcache', u'php7.1-mysql', u'php7.1-mbstring', u'php7.1-mcrypt', u'php7.1-zip', u'php7.1-fpm'])
TASK [php : Configure dummy site configuration files] **************************
changed: [php] => (item={u'site': u'site1', u'port': 9001})
changed: [php] => (item={u'site': u'site2', u'port': 9002})
TASK [php : Create web root for dummy sites] ***********************************
changed: [php] => (item=site1)
changed: [php] => (item=site2)
TASK [php : Create dummy site index files] *************************************
changed: [php] => (item=site1)
changed: [php] => (item=site2)
TASK [php : Create dummy site db files] ****************************************
changed: [php] => (item=site1)
changed: [php] => (item=site2)
RUNNING HANDLER [php : Restart php] ********************************************
changed: [php]

PLAY [Apply nginx configurations to "nginx" host] ******************************
TASK [Gathering Facts] *********************************************************
ok: [nginx]
TASK [nginx : Install nginx] ***************************************************
changed: [nginx]
TASK [nginx : Configure dummy site configuration files] ************************
changed: [nginx] => (item={u'site': u'site1', u'port': 9001})
changed: [nginx] => (item={u'site': u'site2', u'port': 9002})
TASK [nginx : Create dummy site symbolic links] ********************************
changed: [nginx] => (item=site1)
changed: [nginx] => (item=site2)
TASK [nginx : Create dummy site hosts] *****************************************
changed: [nginx]
RUNNING HANDLER [nginx : Restart nginx] ****************************************
changed: [nginx]

PLAY [Apply mysql configurations to "mysql" host] ******************************
TASK [Gathering Facts] *********************************************************
ok: [mysql]
TASK [mysql : Install mysql and packages] **************************************
changed: [mysql] => (item=[u'mysql-server=5.7.*', u'python3-mysqldb'])
TASK [mysql : Create a new database] *******************************************
changed: [mysql]
TASK [mysql : Remove all anonymous user accounts] ******************************
ok: [mysql]
TASK [mysql : Create "root" user with all privileges for only local hosts] *****
changed: [mysql]
TASK [mysql : Create "php" user with all privileges on "lemp" database for only 192.168.99.31 host] ***
changed: [mysql]
TASK [mysql : Create mysql configuration file] *********************************
changed: [mysql]
RUNNING HANDLER [mysql : Restart mysql] ****************************************
changed: [mysql]

PLAY RECAP *********************************************************************
mysql : ok=20 changed=14 unreachable=0 failed=0
nginx : ok=18 changed=13 unreachable=0 failed=0
php : ok=21 changed=15 unreachable=0 failed=0

Results


All


$ timedatectl
Local time: Sun 2018-03-14 10:51:48 GMT
Universal time: Sun 2018-03-14 10:51:48 UTC
RTC time: Sun 2018-03-14 10:51:46
Time zone: Europe/London (GMT, +0000) # This was "Etc/UTC (UTC, +0000)" before
Network time on: yes
NTP synchronized: yes # This was "no" before
RTC in local TZ: no

$ locale
LANG=en_GB.UTF-8 # This was "en_US.UTF-8" before
LANGUAGE=en_GB:en # This was empty before
LC_CTYPE="en_GB.UTF-8"
LC_NUMERIC="en_GB.UTF-8"
LC_TIME="en_GB.UTF-8"
LC_COLLATE="en_GB.UTF-8"
LC_MONETARY="en_GB.UTF-8"
LC_MESSAGES="en_GB.UTF-8"
LC_PAPER="en_GB.UTF-8"
LC_NAME="en_GB.UTF-8"
LC_ADDRESS="en_GB.UTF-8"
LC_TELEPHONE="en_GB.UTF-8"
LC_MEASUREMENT="en_GB.UTF-8"
LC_IDENTIFICATION="en_GB.UTF-8"
LC_ALL=en_GB.UTF-8 # This was empty before

PHP


ubuntu@php:~$ php -v
PHP 7.1.15-1+ubuntu16.04.1+deb.sury.org+2 (cli) (built: Mar 6 2018 11:10:13) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2018 Zend Technologies
with Zend OPcache v7.1.15-1+ubuntu16.04.1+deb.sury.org+2, Copyright (c) 1999-2018, by Zend Technologies

NGINX


ubuntu@nginx:~$ nginx -v
nginx version: nginx/1.10.3 (Ubuntu)

MYSQL


ubuntu@mysql:~$ mysql -uroot -p
Server version: 5.7.21-0ubuntu0.16.04.1 (Ubuntu)

Tests


These tests are done within the NGINX box.


ubuntu@nginx:~$ curl site1.com
ubuntu@nginx:~$ curl site1.com/index.php
Hello from site1
ubuntu@nginx:~$ curl site1.com/db.php
Connection succeeded on site1!

ubuntu@nginx:~$ curl site2.com
ubuntu@nginx:~$ curl site2.com/index.php
Hello from site2
ubuntu@nginx:~$ curl site2.com/db.php
Connection succeeded on site2!

ubuntu@nginx:~$ curl localhost
ubuntu@nginx:~$ curl 127.0.0.1
ubuntu@nginx:~$ curl 192.168.99.32
Welcome to nginx!

These tests are done within the host OS.


$ curl site1.com
Hello from site1

$ curl site2.com
Hello from site2

http://site1.com/
Hello from site1

http://site2.com/
Hello from site2

http://192.168.99.32/
$ curl 192.168.99.32
Welcome to nginx!

http://site1.com/db.php
$ curl site1.com/db.php
Connection succeeded on site1!

http://site2.com/db.php
$ curl site2.com/db.php
Connection succeeded on site2!

These tests are done within the MySQL box.


$ mysql -u root -p
# Allowed access with "root" password from only local host to all databases
mysql>

$ mysql -u php -p
# Denied access with "php" password from localhost. However, allowed from only 192.168.99.31 to "lemp" database.
ERROR 1045 (28000): Access denied for user 'php'@'localhost' (using password: YES)

mysql> SELECT user, host FROM user;
+------------------+---------------+
| user | host |
+------------------+---------------+
| php | 192.168.99.31 |
| debian-sys-maint | localhost |
| mysql.session | localhost |
| mysql.sys | localhost |
| root | localhost |
+------------------+---------------+
5 rows in set (0.00 sec)