Vagrant allows you to set up individual development environments for each of your projects, mimic your production environment, and keep your projects from interfering with each other. Here's how we set up our Wordpress projects in Vagrant.

I was slow to see the value in Vagrant. To me, it was yet another layer of complexity to maintain when all I want to do is write some code. Couldn’t I just run my project out of a subdirectory or create a virtualhost? Couldn’t I just create a new database on my local server?

But that was before I started working for an agency where we develop and maintain dozens of sites. Having independent, reproducible development environments was suddenly much more appealing.

So I tried Vagrant. And I really liked it.

If you are unfamiliar with Vagrant, it is a configuration and control layer above a virtual machine layer. The virtual machine layer can be one of a few different things, but VirtualBox comes with Vagrant, is free, and works fine for local development. The basic idea is that you can create disposable virtual machines for each project. Each project can use its own stack, its own configuration, and remains in its own sandbox.

Historically, I have used a typical LAMP stack for local Wordpress development. But with Vagrant you can setup any development environment you want. So why not match production? We at Adept, like many, have moved on from Apache and mod_php for Wordpress. My target stack is a CentOS-like Linux OS, nginx, php-fpm, and mysql.

For any Wordpress project using Vagrant, we create a vagrant subdirectory like the one described below. This subdirectory will be mounted at /vagrant on the guest OS. The etc/ subdirectory mimics a real etc subdirectory and contains the config files we will need to copy into the guest OS.

Here is a directory diagram for one of our Wordpress projects.

+ - wp-content/
+ - vagrant/
  + - Vagrantfile
  + - provision.sh
  + - provision-mysql.sh
  + - etc/
    + - yum.repos.d/
      + - nginx.repo
    + - nginx/
      + - conf.d/
        + - vagrant.conf
     + - php-fpm.d/
       + - vagrant.conf
  + - wordpress/
     + - wp-config.php
  + - var/
     + db_dump.sql

As part of the project setup, a mysql dump file should be placed in vagrant/var/. It can be named anything as long as it has the .sql suffix.

In addition, place a wp-config.php in vagrant/wordpress/wp-config.php. The provision-mysql.sh script will use the database information in this file to setup the mysql database.

Our Wordpress development typically includes theme and plugin work. Our project repos reflect this and include only a wp-content/ subdirectory in addition to the vagrant/ subdirectory. This convention is assumed in the configuration below.

A *nix environment is also assumed. Sorry Windows users! We are syncing our files between the host OS (OS X or Linux) and the guest OS using NFS.

Vagrantfile


Vagrant.configure(2) do |config|

  config.vm.hostname = "wordpresslocal"

  # this centos Vagrant box from Chef Software is bare bones - just the way we want it!
  config.vm.box = "bento/centos-7.1"

  # Create a forwarded port mapping which allows access to a specific port
  # within the machine from a port on the host machine.
  config.vm.network "forwarded_port", guest: 8000, host: 8000

  # Create a private network, which allows host-only access to the machine
  # using a specific IP.
  config.vm.network "private_network", ip: "192.168.33.10"

  # This is where we sync our project files from the host OS to the Vagrant guest OS.
  # This means that we can edit files using our text editor or IDE of choice and see those
  # changes reflected immediately.
  config.vm.synced_folder "../wp-content/", "/var/www/html/wordpress/wp-content/", type: "nfs"

  config.vm.provision "shell", path: "provision.sh"
  config.vm.provision "shell", path: "provision-mysql.sh"
End

etc/yum.repos.d/nginx.repo


[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=0
enabled=1

provision.sh


#!/bin/bash

# Time Zone
# We’re eastern; adjust for your needs or comment it out
ln -sf /usr/share/zoneinfo/US/Eastern /etc/localtime

# restart logger to pick up the timezone change
systemctl restart rsyslog

# install yum repos needed to install php 5.6
wget --no-verbose https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
wget --no-verbose http://rpms.famillecollet.com/enterprise/remi-release-7.rpm
rpm -Uvh remi-release-7*.rpm epel-release-7*.rpm

# the vagrant subdirectory from our project is mounted at /vagrant on the guest machine
cp -f /vagrant/etc/yum.repos.d/nginx.repo /etc/yum.repos.d/

# the bento/centos-7.1 Vagrant box is so minimal it doesn’t even have perl!
yum -q -y install perl

# enable remi repo
# -00 processes file in paragraph mode
perl -00 -i -ne 'if (/^\[remi\]/) { $_ =~ s/enabled=0/enabled=1/g; } print $_;' /etc/yum.repos.d/remi.repo

yum -q -y install man man-pages unzip git vim-enhanced php nginx php-fpm php-gd php-mysql php-pear php-mcrypt php-soap php-mbstring mariadb-server

# get the latest version of wordpress and install it at /var/www/html/wordpress; our wp-content directory from our project is already mounted there
wget --no-verbose https://wordpress.org/latest.zip
unzip -d /var/www/html/ latest.zip

ln -sf /var/www/html/wordpress /home/vagrant/wordpress

# make the log files more accessible
chgrp -R vagrant /var/log/php-fpm
chmod g+r /var/log/php-fpm/*
chgrp -R vagrant /var/log/nginx

# copy our wp-config.php file
cp -f /vagrant/wordpress/* /var/www/html/wordpress/

chown -R vagrant:nginx /var/www/html/wordpress 2> /dev/null

# start up mariadb (mysql)
systemctl enable mariadb
systemctl start mariadb

# copy over our php-fpm config and start php-fpm
cp -f /vagrant/etc/php-fpm.d/vagrant.conf /etc/php-fpm.d/
systemctl enable php-fpm
systemctl start php-fpm

# copy over our nginx config and start nginx
mv -f /etc/nginx/conf.d/default.conf /etc/nginx/
cp -f /vagrant/etc/nginx/conf.d/vagrant.conf /etc/nginx/conf.d/
systemctl enable nginx
systemctl start nginx

provision-mysql.sh


#!/bin/bash

# use SEARCH and REPLACE to change hostname in dump file if necessary;
# e.g. replace www.example.com with 127.0.0.1:8000
SEARCH=""
REPLACE=""

CONFIG_FILE="/var/www/html/wordpress/wp-config.php"
DUMP_FILE=$([ -e /vagrant/var ] && ls -t /vagrant/var/*.sql | head -1)

if [ ! -e $CONFIG_FILE ]; then
    echo "No config file"
    exit 2
fi
if [ ! -e $DUMP_FILE ]; then
    echo "No dump file"
    exit 2
fi

DB_HOST=$(grep "'DB_HOST'" $CONFIG_FILE | perl -ne '/,\s*\W(.+?)\W\s*\);/; print $1')
DB_NAME=$(grep "'DB_NAME'" $CONFIG_FILE | perl -ne '/,\s*\W(.+?)\W\s*\);/; print $1')
DB_USER=$(grep "'DB_USER'" $CONFIG_FILE | perl -ne '/,\s*\W(.+?)\W\s*\);/; print $1')
DB_PASSWORD=$(grep "'DB_PASSWORD'" $CONFIG_FILE | perl -ne '/,\s*\W(.+?)\W\s*\);/; print $1')

# create DB and user as described in wp-config.php

echo "drop database if exists $DB_NAME;" | mysql -u root -v
echo "drop user '$DB_USER'@'$BD_HOST'" | mysql -u root -v
echo "create user '$DB_USER'@'$DB_HOST' identified by '$DB_PASSWORD'"  | mysql -u root
echo "grant all privileges on $DB_NAME.* to '$DB_USER'@'$DB_HOST'" | mysql -u root -v
echo "create database $DB_NAME" | mysql -u root

if [ $SEARCH ] && [ $REPLACE ]; then
    perl -i -pe "s/$SEARCH/$REPLACE/g" $DUMP_FILE
fi

echo "Restoring database $DB_NAME from $DUMP_FILE"
mysql -h $DB_HOST -u $DB_USER -p$DB_PASSWORD $DB_NAME < $DUMP_FILE

etc/nginx/conf.d/vagrant.conf


client_max_body_size 13m;

index index.php index.html index.htm;

# this is how we connect to php-fpm
# based on https://codex.wordpress.org/Nginx
upstream php {
    server 127.0.0.1:8411;
}

# rewrite_log on;

# catchall redirect to main site
server {
    server_name  _;
    return 302 $scheme://127.0.0.1:8000$request_uri;
}

server {
    listen              8000;
    root                /var/www/html/wordpress/;

    index index.php;

    # Restrictions
    # ----------------------------------------
    location = /favicon.ico {
        log_not_found off;
        access_log off;
    }

    location = /robots.txt {
        allow all;
        log_not_found off;
        access_log off;
    }

    # Deny all attempts to access hidden files such as .htaccess, .htpasswd, .DS_Store (Mac).
    # Keep logging the requests to parse later (or to pass to firewall utilities such as fail2ban)
    location ~ /\. {
        deny all;
    }

    # Deny access to any files with a .php extension in the uploads directory
    # Works in sub-directory installs and also in multisite network
    # Keep logging the requests to parse later (or to pass to firewall utilities such as fail2ban)
    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
    }
    # End Restrictions
    # ----------------------------------------

    # This order might seem weird - this is attempted to match last if rules below fail.
    # http://wiki.nginx.org/HttpCoreModule
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # Add trailing slash to */wp-admin requests.
    rewrite /wp-admin$ $scheme://$host$uri/ permanent;

    # Directives to send expires headers and turn off 404 error logging.
    location ~* ^.+\.(ogg|ogv|svg|svgz|eot|otf|woff|mp4|ttf|rss|atom|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
        access_log off; log_not_found off; expires max;
    }

    # Pass all .php files onto a php-fpm/php-fcgi server.
    location ~ [^/]\.php(/|$) {
        fastcgi_split_path_info ^(.+?\.php)(/.*)$;
        if (!-f $document_root$fastcgi_script_name) {
            return 404;
        }
        # This is a robust solution for path info security issue and works with "cgi.fix_pathinfo = 1" in /etc/php.ini (default)

        include fastcgi_params;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        # fastcgi_intercept_errors on;
        fastcgi_pass php;
    }
}

etc/php-fpm.d/vagrant.conf


[vagrant]
listen = 127.0.0.1:8411
listen.allowed_clients = 127.0.0.1

; Unix user/group of processes
user = vagrant
group = nginx

listen.backlog = 65535
;listen.owner = nobody
;listen.group = nobody
;listen.mode = 0666

pm = dynamic
pm.max_children = 30
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
pm.max_requests = 500
pm.status_path = /php-fpm-status
ping.path = /php-fpm-ping
ping.response = pong
;request_terminate_timeout = 0
request_slowlog_timeout = 10
slowlog = /var/log/php-fpm/$pool-slow.log

access.log = /var/log/php-fpm/$pool-access.log
access.format = '{"@timestamp": "%{%Y-%m-%dT%H:%M:%S%z}T", "uuid": "%{UNIQUE_ID}e", "script":"%f", "request_time": "%{mili}d", "cpu": "%C", "mem": "%M", "pid": "%p", "request": "%r", "status": "%s"}'

; Set open file descriptor rlimit.
rlimit_files = 10240
; Set max core size rlimit.
;rlimit_core = 0

catch_workers_output = yes

; Limits the extensions of the main script FPM will allow to parse.
security.limit_extensions = .php .phtml

php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm/$pool-error.log

Once you have these files in place, along with your wp-config.php and db_dump.sql files, cd into the vagrant directory and run

vagrant up
This will download the Vagrant box and provision it. The first time will take much longer than subsequent runs.

After the box is ready to go, you should find your wordpress site at http://127.0.0.1:8000/. You can also ssh into the vagrant box with:

vagrant ssh

 Because of the NFS file share, you can edit the files in wp-content on your host OS with your favorite text editor or IDE and the vagrant guest OS sees the updates immediately.

In addition, if you want to use a newer database dump, just drop it into vagrant/var/ (and get rid of the original). Then

vagrant ssh

to connect to the vagrant OS and run

/vagrant/provision-mysql.sh
This will drop the existing database and recreate it using the newer dump file.

Closing Thoughts

Vagrant has several appealing characteristics for agencies or shops who develop and maintain dozens of sites. Follow this set up process, and you'll have a nice modern Wordpress set up in Vagrant.