Hello everyone!

We have been investing plenty of personal time and energy for many years to share our knowledge with you all. However, we now need your help to keep this blog running. All you have to do is just click one of the adverts on the site, otherwise it will sadly be taken down due to hosting etc. costs. Thank you.

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)