DigitalOcean+LEMP+WordPress

Hey folks!

I’m hoping that my site has been feeling a little snappier in the past few weeks. Recently, I migrated away from Bluehost’s shared hosting to a DigitalOcean VPS. This post will be about that transition and how to setup WordPress on a DigitalOcean droplet.

Shameless plug: If you use my Hover or DigitalOcean referral code, we’ll both get some credit if/when you sign up!

Justification

Domain name registration

I honestly have nothing terrible to say about Bluehost. They made registering a domain name easy and quick. In hindsight, it was a little on the expensive side. However, my main put-off was the lack of two-factor authentication on their Control Panel. I tweeted them about this on two occasions, which were never acknowledged, and finally opened a support ticket. They didn’t answer my question about future support for two-factor authentication, but only said they don’t currently support it.

I settled on Hover as my domain registrar, but was considering Gandi as well. Both have two-factor authentication, and received good reviews on r/VPS, r/webhosting,  and similar subreddits.

Hosting

Being new to hosting, my favorite features of Bluehost were the one-click install of WordPress and access to cPanel. Needless to say, I lived in the GUI. However, since it was shared hosting, my CPU/RAM was limited, and the server always just felt sluggish. In addition, I seemed to experience an outage of a few minutes at least once a month.

I chose DigitalOcean as my VPS provider, but was considering Linode as well. Again, both have two-factor authentication, and received good reviews on r/VPS, r/webhosting,  and similar subreddits.

LAMP vs LEMP

I chose to forgo a LAMP stack for a LEMP stack. In this case:

  • Linux
  • Apache
  • MySQL
  • PHP

vs

  • Linux
  • (E)nginx
  • MariaDB
  • PHP.

There are plenty of comparisons between Apache and Nginx, but I believe it is best summed up below.

Apache is like Microsoft Word, it has a million options but you only need six. Nginx does those six things, and it does five of them 50 times faster than Apache. – Chris Lea

It is known that Apache can be a memory hog. Working in a VPS with limited RAM, I wanted to minimize that as much as possible, which is why I chose Nginx.

In 2009, Sun (who produced the open source database known as MySQL) was acquired by Oracle. Afraid that Oracle would cripple or kill MySQL, the lead developers forked MySQL to create MariaDB. MariaDB is drop-in replacement for MySQL, updates come quicker to MariaDB than MySQL, and it has some slight speed advantages over MySQL (though, I will probably never make use of them at this scale). Since then, companies like RedHat, Google, and Wikipedia, have all moved from Oracle MySQL to MariaDB.

Lightness

My idea was to make this new system as light and fast as possible. Since I used Bluehost’s one-click install, I was pretty hands-off when it came to the backend of the system. With DigitalOcean, I had the chance to rebuild my system from the ground up. My goals were to increase speed, increase security, decrease overhead, and minimize WordPress plugin use.

Security through obscurity

I debated whether or not I wanted to make my full configs publicly available. At first, I was reluctant. However, I soon realized a few things:

  • The information on this site is all public knowledge. If my site was hacked, the attacker wouldn’t have any of my personal information, passwords, etc… The worst they could accomplish would be taking my site offline for a few hours.
  • There may be a few attackers who are helped by having my configs, but I don’t think that outweighs the benefit of posting my configs here to help others.
  • I realize that no system is 100% secure, and that my configs aren’t perfect. I should be able to stop attacks by bots/script kiddies/amateurs, but probably wouldn’t stand a chance against any experts, regardless of whether my configs were public or not.

Assumptions

Your local machine (laptop or desktop):

  • Is running Linux (to generate SSH keys, use PuTTYgen for Windows)
  • Has a SFTP client installed (FileZilla, the gold standard for Windows)
  • Has a SSH client installed (use PuTTY for Windows)

Your old host:

  • Is shared hosting (which introduces the possibility of: abnormal table names, limited access, no root/sudo access, etc…)
  • Is accessible via SSH
  • Has cPanel installed (which usually includes FileManager, phpMyAdmin, etc…) in the event you can’t do something from the command line

Setup your droplet

Create droplet

In the DigitalOcean Control Panel:

  1. Click Create Droplet
  2. Name your droplet (e.g., webserver1, wordpress1, etc…)
  3. Select a size (I recommend 1GB RAM with 1 CPU, but you could get away with a smaller instance and size-up if needed)
  4. Select a region*
  5. Select any necessary options (I chose to use IPv6)
  6. Select an image**
  7. Skip SSH keys for now, as we’ll set these up later.
  8. Click Create Droplet and wait 55 seconds! In a minute, you’ll have an email with your droplet’s IPv4 address, IPv6 address, and root password.

*To improve load times, select a region closest to the majority of your target audience. If you’re paranoid of the government, you may want to stay out of the US/UK (remember, DigitalOcean is still registered in the US and must abide by US laws).

**I chose Ubuntu 14.04 x64. Ubuntu LTS relases are supported for 5 years, as opposed to 1.5 years for regular releases.

Create SSH keys

Do this on each machine you’ll be using to access your droplet. Once we setup SSH authentication, we won’t be able to login from a machine without a SSH key (other than the console in the Control Panel). Change the comment as necessary.

ssh-keygen -b 4096 -t rsa -C "Logan on Arch"

Then, cat out the newly created public key for use in the next step.

cat ~/.ssh/id_rsa.pub

Note – In this scenario, I’m creating multiple public/private key pairs. However, you could copy your private key onto a flash drive and use that on multiple machines, instead of having a different key pair for each machine.

Create admin user

Start by logging into your droplet.

ssh root@xx.xx.xx.xx

When you login, you’ll be prompted to change the root password. You should use one of the many available password generators to create a secure password. Chances are, you won’t be logging into the root account very often, so this should be long and complex. Later on, we’re going to disable root SSH access, so you’ll only ever be able to su – to root after you’re already logged in as a standard user.

Speaking of that, it is recommended to create a user other than root to perform most tasks. This user will have sudo access if they need temporary root privileges. Change the username as necessary and set a password for the user.

useradd -m -G sudo,adm -s /bin/bash testuser
passwd testuser

This password will also need to be complex, but you’re going to be using it often.

Setup SSH authentication

Now, su – from root to your new account, create the .ssh directory, then paste in your public key(s). Obviously, subsititute your own public key(s).

su - testuser
mkdir ~/.ssh
chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
cat >> ~/.ssh/authorized_keys << EOF
ssh-rsa AAAB3NzaC1yc2EAAAADAQABAAACAQC1sIaRVcN9hHIf+5YBOgK2l27JwrVBFC0KYTqmjyvcGEqX2Gd1H8tjeK+9eGOXGSMhylJgNjQzemgXckMjpDlvHimFcVKN+j2Ch/i1uKKIk6upqnmcyE+XwEKAt7UlIoKTQfW0+BVf4MEk58Qi2zqJherKlJLrSfjgpVygOLJQEL3fAuGm2ZGiArWSv/R+1WmKWmdgQF1Xy2oXogMZg9PMuFwTX/9ggSRDEvEXil99WanEwoCSLAdhhGGfIrGBDCmV/CFZezxdx08qjFb37r5Yc/g3ffqZMiPDSKqOf6ZzqqVpfeEshASXFrsz6eZ6mWm3C4k9ufqS97cValLWUtZjmY5OJG3m+VKlz5JlNGvd8Mg5STVh7DKIGBcF+LjbuZdyINKixY6orUDH3z/fhfwpE3g4DQIyM77hKdaFWpcFZdAr5t/sGhhFNl9jX1KTbFC2di8DiZATQ37OpLTwzjojqjxHCHZLrZ6RQLBNTWHbPFvUxuFWH4h4BvEX4JnAIlzCQzzZPIjH+Fr0oECUa/WISnVYUT0A1q5MNCPWRJTTHSUhvGBWVtjXPegnmN4WQbyLog3m8Ee6Jf/BjKbMjTNSZnPEvKWRyM8balGTP9aqqTquDjHG15C8RpiVU4uGQ7QhCf0NJn47r9/0cDn36VZtOUpOop/EMGEyMyoZnI/FEQ== Logan on Arch
EOF

When you are done, exit back to root.

exit

Secure SSH

Now, we’re going to disable root login via SSH, change the SSH port, and turn off password authentication by editing the /etc/ssh/sshd_config file. Substitute 1234 with your desired port.

sed -i "s/PermitRootLogin yes/PermitRootLogin no/g" /etc/ssh/sshd_config
sed -i "s/Port 22/Port 1234/g" /etc/ssh/sshd_config
sed -i "s/#PasswordAuthentication yes/PasswordAuthentication no/g" /etc/ssh/sshd_config
service ssh restart
exit

Note – Don’t perform this step until you have all your SSH keys setup for your user account. You could lock yourself out of your droplet and need to use the console to get back in.

Now, login as the new user on the new port. You should not be using your password here, since you have SSH setup for the user named testuser.

ssh testuser@xx.xx.xx.xx -p 1234

If you need root access, you can su – to root, or use sudo.

Install and setup LEMP

Install updates

For security purposes, you should install any updates right away.

sudo apt-get update && sudo apt-get upgrade

To update the kernel, use the command below. You’ll need to then follow the instructions here to select a new kernel in the DigitalOcean Control Panel.

sudo apt-get update && sudo apt-get dist-upgrade

Install LEMP (and extras)

First, select the correct MariaDB repository from here, then install the LEMP stack, substituting your repository as necessary. I’m using the stable branch of Nginx, as the version that Ubuntu uses by default is outdated (from March 2014).

sudo apt-get update && sudo apt-get install software-properties-common
sudo apt-key adv --recv-keys --keyserver hkp://keyserver.ubuntu.com:80 0xcbcb082a1bb943db
sudo add-apt-repository 'deb http://ams2.mirrors.digitalocean.com/mariadb/repo/10.0/ubuntu trusty main'
sudo add-apt-repository ppa:nginx/stable
echo deb http://ppa.launchpad.net/nginx/stable/ubuntu $(lsb_release -cs) main | sudo tee --append /etc/apt/sources.list.d/nginx-stable-$(lsb_release -cs).list
echo deb-src http://ppa.launchpad.net/nginx/stable/ubuntu $(lsb_release -cs) main | sudo tee --append /etc/apt/sources.list.d/nginx-stable-$(lsb_release -cs).list
sudo apt-get update && sudo apt-get install htop nginx-extras mariadb-server ntp php5 php5-fpm php5-mysql php5-gd php5-cli php5-curl libssh2-php rsync ufw unzip

Then, start the necessary services.

sudo service nginx start
sudo service mysql start
sudo service php5-fpm start

Note – I used the nginx-extras package, instead of nginx. This gives me the HttpHeadersMoreModule, which allows me to add/remove HTTP headers. You could also get this by compiling Nginx from source and including that option.

Tweak PHP

We need to change a few options to secure/tune PHP by editing the /etc/php5/fpm/php.ini file.

sudo sed -i "s/^expose_php = On/expose_php = Off/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^;cgi.fix_pathinfo=1/cgi.fix_pathinfo=0/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^session.gc_probability = 0/session.gc_probability = 1/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^;display_errors = Off/display_errors = Off/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^max_input_time = -1/max_input_time = 60/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^disable_functions = /disable_functions = phpinfo,system,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,show_source,/g" /etc/php5/fpm/php.ini
sudo sed -i "s/^;listen.mode = 0660/listen.mode = 0660/g" /etc/php5/fpm/pool.d/www.conf
sudo service php5-fpm restart

Setup MariaDB

First, run the setup script.

sudo killall mysqld
sudo rm /var/lib/mysql/aria_log_control
sudo mysql_install_db
sudo service mysql start

Then, run the secure setup script to remove temporary users and files.

sudo mysql_secure_installation
  • Change root password = N (assuming you set it during the install)
  • Remove anonymous user = Y
  • Disallow root login remotely = Y
  • Remove test database and access to it = Y
  • Reload privilege tables = Y

Next, make sure there aren’t any users without a host or password associated.

sudo mysql -u root -p
SELECT User,Host,Password FROM mysql.user;

As shown below, the user demo-user doesn’t have a password and is valid on any host. If you see something like that, remove the user.

+------------------+-----------+-------------------------------------------+
| user             | host      | password                                  |
+------------------+-----------+-------------------------------------------+
| root             | localhost | *DE06E242B88EFB1FE4B5083587C260BACB2A6158 |
| demo-user        | %         |                                           |
| root             | 127.0.0.1 | *DE06E242B88EFB1FE4B5083587C260BACB2A6158 |
| root             | ::1       | *DE06E242B88EFB1FE4B5083587C260BACB2A6158 |
| debian-sys-maint | localhost | *ECE81E38F064E50419F3074004A8352B6A683390 |
+------------------+-----------+-------------------------------------------+

If you’d like, you can change the database user root to something else. This is security through obsecurity and will help deter brute-force attacks.

RENAME USER 'root'@'localhost' TO 'dbroot'@'localhost';
RENAME USER 'root'@'127.0.0.1' TO 'dbroot'@'127.0.0.1';
RENAME USER 'root'@'::1' TO 'dbroot'@'::1';
FLUSH PRIVILEGES;
exit

Setup NTP

First, select the correct timezone.

sudo dpkg-reconfigure tzdata

Then, manually sync the time (only once) and start the NTP service.

sudo ntpdate pool.ntp.org
sudo service ntp start

Setup firewall

Check the status of UFW.

sudo ufw status verbose

Set the default state of UFW to deny incoming and allow outgoing.

sudo ufw default deny incoming
sudo ufw default allow outgoing

Edit the /etc/ufw/applications.d/openssh-server file to change the SSH port from 22 to 1234 (or whatever port you used). Otherwise, you’ll lock yourself out of SSH.

sudo sed -i "s/ports=22\/tcp/ports=1234\/tcp/g" /etc/ufw/applications.d/openssh-server

List all of the current apps that have UFW rules.

sudo ufw app list

Enable OpenSSH, Nginx, and NTP access.

sudo ufw allow OpenSSH
sudo ufw allow "Nginx HTTP"
sudo ufw allow ntp

Finally, turn on UFW.

sudo ufw enable

Then, check the status again.

sudo ufw status verbose

Setup SWAP

Create a swapfile to help RAM usage. The swapfile should be equal to, or the double the size of, your RAM. Here, I’m setting the swappiness to 15.

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo chown root:root /swapfile 
sudo mkswap /swapfile
sudo swapon /swapfile
sudo sh -c 'echo "/swapfile none swap sw 0 0" >> /etc/fstab'
sudo sysctl -w vm.swappiness=15
sudo sh -c 'echo "vm.swappiness = 15" >> /etc/sysctl.conf'
sudo reboot

 

Backup old website

Backup WordPress files

SSH into your old shared host. Then, create a temporary directory to copy your /wp-content directory to. I’m also copying my favicon.ico file.

mkdir ~/tempfiles
cd ~/public_html
zip -vr ~/tempfiles/wp_content_new.zip wp-content/
cp -p ~/www/favicon.ico ~/tempfiles

Backup WordPress/Piwik databases

In my case, I’m going to backup my Piwik database, in addition to my WordPress database. SSH into your old shared host and move to the temporary directory.

cd ~/tempfiles

Login to MySQL, and list your databases.

mysql -u username -p
show databases;

From here, list all the tables in each database to determine the table prefix. Make note of this prefix for later.

show tables in wordpressdb;
show tables in piwikdb;

Find the engine type for each of your databases and exit back to the prompt.

SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'wordpressdb';
SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'piwikdb';
exit

WordPress probably defaulted to MyISAM and Piwik to InnoDB. Assuming that is the case, issue the commands below to backup your databases.

mysqldump --opt -u username -p wordpressdb > wordpressdb.sql;
mysqldump --opt --single-transaction -u username -p piwikdb > piwikdb.sql;

Setup WordPress/Piwik

The rest of this setup takes place on your new DigitalOcean droplet.

Create new databases and users

We need to create two blank databases: one for WordPress and one for Piwik. Change the database names and usernames/passwords as necessary. Since we’re not importing the databases themselves, just the tables we exported earlier, we can make these database names/usernames/passwords whatever we’d like (i.e., we don’t have to match the previous database names/usernames/passwords).

sudo mysql -u username -p
CREATE DATABASE wordpressdb;
CREATE USER wordpressuser@localhost IDENTIFIED BY 'wordpressuserpassword';
GRANT ALL PRIVILEGES ON wordpress.* TO wordpressuser@localhost;
FLUSH PRIVILEGES;
CREATE DATABASE piwikdb;
CREATE USER piwikuser@localhost IDENTIFIED BY 'piwikuserpassword';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES ON piwik.* TO piwikuser@localhost;
FLUSH PRIVILEGES;
exit

Transfer old website files

From your droplet, use SCP to transfer your files. Here, we’ll copy the temporary directory on our old shared host to our droplet.

sudo scp -vr username@xx.xx.xx.xx:~/tempfiles/ ~

Download WordPress/Piwik

Download WordPress and Piwk to your droplet. Note, the versions of WordPress and Piwik on your old server have to match the versions on your new droplet (e.g., you can’t transfer your site from 4.0–>4.1, they must both be on 4.1). It’s easiest to upgrade both WordPress and Piwik to the latest versions before proceeding.

cd ~/tempfiles
sudo wget http://wordpress.org/latest.tar.gz
sudo wget http://builds.piwik.org/piwik.tar.gz
sudo tar xzvf latest.tar.gz
sudo tar xzvf piwik.tar.gz
sudo rm latest.tar.gz
sudo rm piwik.tar.gz
sudo rm How\ to\ install\ Piwik.html

Update wp-config.php file

We need to update the DB_NAME, DB_USER, and DB_PASSWORD parameters in the wp-config.php file with the information about the WordPress database we created. Obviously, substitute your information below.

sudo cp -p ~/tempfiles/wordpress/wp-config-sample.php ~/tempfiles/wordpress/wp-config.php
sudo sed -i "s/database_name_here/wordpressdb/g" ~/tempfiles/wordpress/wp-config.php
sudo sed -i "s/username_here/wordpressuser/g" ~/tempfiles/wordpress/wp-config.php
sudo sed -i "s/password_here/wordpressuserpassword/g" ~/tempfiles/wordpress/wp-config.php

Also in the wp-config.php file, change the WordPress table prefix (if necessary) to match what is was earlier.

sudo sed -i "s/wp_/oldprefix_/g" ~/tempfiles/wordpress/wp-config.php

Copy files

Now, we are going to copy our WordPress/Piwik installation into the new document root, as well as restore the backed up files from our home directory.

sudo mkdir -p /var/www/loganmarchione
sudo mkdir -p /var/www/piwik1
sudo rsync -avP ~/tempfiles/wordpress/ /var/www/loganmarchione/
sudo rsync -avP ~/tempfiles/piwik1/ /var/www/piwik1/
sudo rm -rf /var/www/loganmarchione/wp-content/
sudo unzip ~/tempfiles/wp_content_new.zip -d /var/www/loganmarchione/
sudo mysql -h localhost -u username -p wordpressdb < ~/tempfiles/wordpressdb.sql
sudo mysql -h localhost -u username -p piwikdb < ~/tempfiles/piwikdb.sql
sudo cp -p ~/tempfiles/favicon.ico /var/www/loganmarchione
sudo chown -R www-data:www-data /var/www/*
sudo rm -rf ~/tempfiles

Setup HTTP Authentication

First, use OpenSSL to create an encrypted version of a password.

openssl passwd

Then, create a file with a username and the encrypted password, separated by a colon. We will use this file for HTTP authentication to protect login pages.

sudo sh -c 'echo "username:NVrQpg0wIcPRw" >> /etc/nginx/pass'

Setup Adminer

This step is optional. If you don’t want to manage your databases through the command line, you can choose to use phpMyAdmin or Adminer. I used to use phpMyAdmin, but it seemed big, bloated, had more features than I would ever need. Adminer is database management in a single PHP file. A comparison between phpMyAdmin and Adminer is here.

Start by creating the necessary files and directories. I’m downloading a different theme as well.

sudo mkdir /var/www/adminer
sudo mkdir /var/www/adminer/plugins
cd /var/www/adminer
sudo wget http://downloads.sourceforge.net/adminer/adminer-4.1.0-mysql-en.php
sudo mv adminer-4.1.0-mysql-en.php ad.php
sudo wget https://raw.github.com/vrana/adminer/master/designs/price/adminer.css
sudo touch /var/www/adminer/index.php

Download any plugins you’ll need.

cd /var/www/adminer/plugins
sudo wget https://raw.github.com/vrana/adminer/master/plugins/plugin.php
sudo wget https://raw.github.com/vrana/adminer/master/plugins/dump-bz2.php
sudo wget https://raw.github.com/vrana/adminer/master/plugins/dump-zip.php
sudo wget https://raw.github.com/vrana/adminer/master/plugins/dump-date.php

Then, paste the code below into /var/www/adminer/index.php. You’ll need to include the plugins in this file as well.

<?php
function adminer_object() {
    // required to run any plugin
    include_once "./plugins/plugin.php";

    // autoloader
    foreach (glob("plugins/*.php") as $filename) {
        include_once "./$filename";
    }

    $plugins = array(
        // specify enabled plugins here
        new AdminerDumpDate,
        new AdminerDumpZip,
        new AdminerDumpBz2,
    );

    /* It is possible to combine customization and plugins:
    class AdminerCustomization extends AdminerPlugin {
    }
    return new AdminerCustomization($plugins);
    */

    return new AdminerPlugin($plugins);
}

// include original Adminer or Adminer Editor
include "./ad.php";
?>

Finally, change the permissions again.

sudo chown -R www-data:www-data /var/www/*
sudo service php5-fpm restart
sudo service nginx reload

Populate config files

First, we need to overwrite the default Nginx config file with ours. This config file specifies settings on a global level, and includes our gzip configuration. The file is located at /etc/nginx/nginx.conf.

user www-data;
worker_processes 1; #this should be equal to "grep processor /proc/cpuinfo | wc -l"
pid /run/nginx.pid;

events {
        worker_connections 1024; #this should be equal to "ulimit -n"
        multi_accept on;
}

http {
        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 30;
        types_hash_max_size 2048;
        server_names_hash_bucket_size 64;
        include /etc/nginx/mime.types;
        default_type application/octet-stream;

#Added for security
        server_tokens off;
        server_name_in_redirect off;
        add_header X-Frame-Options SAMEORIGIN;

#Compression settings
        gzip on;
        gzip_disable "msie6";
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 9;
        gzip_buffers 16 8k;
        gzip_http_version 1.1;
        gzip_types text/plain text/css text/xml application/json application/javascript application/x-javascript application/xml application/xml+rss text/javascript;

#Other files to include
        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

Next, create the necessary config files for your website. In our main website config file, we’ll reference two other config files. You could, however, include the contents in a single file.

sudo touch /etc/nginx/sites-available/conf.security
sudo touch /etc/nginx/sites-available/conf.headers
sudo touch /etc/nginx/sites-available/com.loganmarchione

Next, populate the security file. The file blocks access to certain WordPress files/features and is further explained in the comments below.

#Deny access to key WordPress files
        location ~ /(wp-config.php|wp-config-sample.php|readme.html|readme.txt|install.php|license.txt) {
        return 404;
        }

#Deny access to XML-RPC to prevent pingback DoS attacks
        location = /xmlrpc.php {
        deny all;
        }

#Deny access to hidden dotfiles
        location ~ /\. {
        deny all;
        }

#Deny files ending in ~
        location ~ ~$ {
        deny all;
        }

#Deny any potentially-executable files in the uploads directory from being executed by forcing their MIME type to text/plain
        location ~* ^/wp-content/uploads/.*.(html|htm|shtml|php|js|swf)$ {
        types { }
        default_type text/plain;
        }

#Deny access to common locations
        location ~* wp-admin/includes {
        deny all;
        }

        location ~* wp-includes/theme-compat/ {
        deny all;
        }

        location ~* wp-includes/js/tinymce/langs/.*\.php {
        deny all;
        }

#Still testing thse two rules
#        location /wp-content/ {
#        internal;
#        }

#        location /wp-includes/ {
#        internal;
#        }

#Only allow these request methods
        if ($request_method !~ ^(GET|HEAD|POST)$ ) {
        return 444;
        }

#Rewrite /wp-admin to /wp-admin/
        rewrite /wp-admin$ $scheme://$host$uri/ permanent;

#Redirect 403 errors to 404 error to fool attackers
        error_page 403 = 404;

#Allow access to favicon.ico and robots.txt and don't log that access
        location = /favicon.ico {
        log_not_found off;
        access_log off;
        }

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

Next, populate the headers file. This is the reason we installed HttpHeadersMoreModule, to spoof the HTTP headers.

#Stripped headers
more_clear_headers 'Server*';
more_clear_headers 'X-Powered*';
more_clear_headers 'X-Page*';
more_clear_headers 'X-Pingback*';

#Added headers
more_set_headers 'Server: ';
more_set_headers 'X-Backend-Server: ';
more_set_headers 'X-Powered-By: ';
more_set_headers 'X-Contact: loganmarchione.com/connect';
more_set_headers 'X-Hiring: loganmarchione.com/connect';
more_set_headers 'X-Firewall: ';

Finally, populate your website file.

server {
        listen 80 default_server;                       #Listen on IPv4
        listen [::]:80;                                 #Listen on IPv6
        server_name loganmarchione.com loganmarchione.com;
        root /var/www/loganmarchione;                   #Set document root
        autoindex off;                                  #Turn off index browsing everywhere
        index index.php index.html;                     #Set indexes to include .php before .html

        #Rewrites for Yoast SEO plugin
        rewrite ^/sitemap_index\.xml$ /index.php?sitemap=1 last;
        rewrite ^/([^/]+?)-sitemap([0-9]+)?\.xml$ /index.php?sitemap=$1&sitemap_n=$2 last;

        location / {
        try_files $uri $uri/ /index.php?$args;
                #Cache these filetypes in the user's browser for a set number of days
                location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
                expires 10d;
                add_header Cache-Control "private, must-revalidate";
                }
        }

        error_page 404 /var/www/loganmarchione/wp-content/themes/decode/404.php;
        error_page 500 502 503 504 /50x.html;

        location = /50x.html {
        root /usr/share/nginx/html;
        }

        #Include configuration files
        include /etc/nginx/sites-available/conf.security;
        include /etc/nginx/sites-available/conf.headers;

        #Protect /wp-login.php and /wp-admin/ with basic HTTP authentication
        location ~* /wp-login.php {
        auth_basic "Restricted";                        #Enable HTTP authentication
        auth_basic_user_file /etc/nginx/pass;           #Set authentication file location
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        include fastcgi_params;
        }

        #Location for Piwik
        location /analytics {
        alias /var/www/piwik1/;
        auth_basic "Restricted";                        #Enable HTTP authentication
        auth_basic_user_file /etc/nginx/pass;           #Set authentication file location
        try_files $uri $uri/ /index.php;
                location = /analytics/piwik.js{         #Disable HTTP authentication for the piwik.js file
                auth_basic off;
                }
                location ~* ^/analytics(.+\.php)$ {
                auth_basic off;                         #Disable HTTP authentication for *.php files
                fastcgi_split_path_info ^(.+\.php)(/.+)$;
                fastcgi_pass unix:/var/run/php5-fpm.sock;
                fastcgi_param SCRIPT_FILENAME $request_filename;
                include fastcgi_params;
                }
        }

#       Location for Adminer
#       location /db {
#       alias /var/www/adminer/;
#       auth_basic "Restricted";                        #Enable HTTP authentication
#       auth_basic_user_file /etc/nginx/pass;           #Set authentication file location
#       try_files $uri $uri/ /index.php;
#               location ~* ^/db(.+\.php)$ {
#               fastcgi_split_path_info ^(.+\.php)(/.+)$;
#               fastcgi_pass unix:/var/run/php5-fpm.sock;
#               fastcgi_param SCRIPT_FILENAME $request_filename;
#               include fastcgi_params;
#               }
#       }

        location ~* \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        fastcgi_index index.php;
        include fastcgi_params;
        }
}

A few things to note:

  • I’m listening on IPv4 and IPv6.
  • I’m caching a few filetypes (mostly images) in the user’s browser.
  • I’m protecting /wp-admin, Piwik, and Adminer with HTTP authentication.
  • I turned off HTTP authentication for piwik.js and .php pages in my /piwik1 directory, since users were getting prompted to login at each page. The problem is described here under Access to piwik.php fails, and my resolution is here.
  • I have access to Adminer disabled until I need it.

Enable website(s)

Create a symlink for your website and remove the default site.

sudo ln -s /etc/nginx/sites-available/com.loganmarchione /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo service nginx reload
sudo service php5-fpm restart

Enable email

WordPress will occassionally email you with things like available updates, new comments, etc… In order to send email from your server, you’ll need to install a message transfer agent. Full disclosure, I’m not an email expert. I know that Exim4 is a MTA, but it can’t receive email, only send. I’m using it because it’s lightweight, and only takes 30 seconds to setup.

First, we’ll need to install Exim4 to send messages from our server.

sudo apt-get update && sudo apt-get install exim4

Then, run the setup script for Exim4. Subsititute your information where needed.

sudo dpkg-reconfigure exim4-config
  • Mail server configuration type = internet site; mail is sent and received directly using SMTP
  • FQDN = server1.loganmarchione.com
  • SMTP listenter = 127.0.0.1 ; ::1 (since I’m using both IPv4 and IPv6)
  • Mail destinations = server1.loganmarchione.com; server1; localhost.localdomain; localhost
  • Domains to relay mail for = (blank)
  • Machines to relay mail for = (blank)
  • Keep DNS queries to a minimum = No
  • Delivery method for local mail = Maildir (this is your personal preference)
  • Split configuration file = No

To test Exim4, run the following command. If this email goes to your spam folder, you may need to setup a SPF record.

echo "This is a test." | mail -s Testing youremail@something.com

Finally, enable the wp_mail function by enabling the PHP mail() function.

sudo sed -i "s/^;sendmail_path =/sendmail_path = /usr/sbin/sendmail -t -i/g" /etc/php5/fpm/php.ini
sudo service php5-fpm restart
sudo service exim4 restart

Setup DNS

I’m assuming that you’ve already setup DNS to point your domain name to your droplet’s IP. You can do this in a couple ways:

  1. On your domain registrar’s site, change your DNS servers to DigitalOcean’s nameservers, then setup DNS in DigitalOcean’s Control Panel (this is the “wrong” way and breaks email on your domain, as described perfectly, here).
  2. On your domain registrar’s site, setup an A and AAAA record to point to your droplet’s IP (this is the “right” way).
  3. On a third party site (e.g., Dyn), setup your DNS records. This may slightly decrease the time it takes to load your site (which is good!) because your DNS provider may use a CDN to cache DNS entries.

Test your website(s)

Test main site

Visit http://your_server_IP_or_name/ in your browser. With any luck, the page will load.

You can test your website speed with Pingdom’s Full Page Test, or with Google’s PageSpeed Insights. Both offer a lot of good information about what you can do to improve your load times.

Test IPv6

There’s a good chance your personal machine is not on an IPv6 connection. Use this site to check your server for IPv6 connectivity.

Test /wp-admin

Visit http://your_server_IP_or_name/wp-admin in your browser. You should be asked to login with the username and password we setup in /etc/nginx/pass before you’ll see the WordPress login page.

Test email

Leave a test comment on your blog and verify you recieve an email from Exim4. If not, check the log located at /var/log/exim4/mainlog.

Test Adminer

Visit http://your_server_IP_or_name/db in your browser. You should be asked to login with the username and password we setup in /etc/nginx/pass before you’ll see the Adminer login page, at which point you’ll login with the MariaDB root username/password.

Test Piwik

Visit http://your_server_IP_or_name/analytics in your browser. You should be asked to login with the username and password we setup in /etc/nginx/pass before you’ll see the Piwik login page. However, since this the first time visiting this address, you’ll need to install Piwik. When prompted, choose localhost as the server, and enter the Piwik database username, password, and instance name to restore your data. Some of that process can be seen when I installed Piwik, here.

Test gzip compression

See this page to test if gzip compression is working. I was able to get a 77% savings on my homepage by enabling compression.

Test browser caching

Use curl to get the HTTP headers for a file on your site. Note, this needs to be a filetype that is cached, as specified in our Nginx cache settings.

curl -I http://your_server_IP_or_name/wp-content/path/to/your/file.jpg

You will see output similar to that below. Note the expiration date is 10 days out, which is what we set in our cache setting.

HTTP/1.1 200 OK
Date: Wed, 07 Jan 2015 16:46:23 GMT
Content-Type: image/jpeg
Content-Length: 815503
Last-Modified: Sun, 05 Oct 2014 15:14:48 GMT
Connection: keep-alive
Expires: Sat, 17 Jan 2015 16:46:23 GMT
Cache-Control: max-age=864000
X-Frame-Options: SAMEORIGIN
Accept-Ranges: bytes

 

This has been a fairly long post and I tried to cover a lot of topics in a short amount of time. In the future, I plan on adding:

  • SSL support
  • Log rotation
  • Multiple WordPress installations
  • Additional caching
  • Fail2ban

Until then, if you notice anything wrong or can help me improve my setup, let me know!

-Logan

19 thoughts on “DigitalOcean+LEMP+WordPress”

      • Cliff,

        I’m not an expert, but according to Google PageSpeed, it looks like you’re not compressing .js files. Maybe WhatIsMyIP was only checking other filetypes?
        What do your compression settings look like? Mine are in /etc/nginx/nginx.conf, so they apply globally.
        #Compression settings
        gzip on;
        gzip_disable "msie6";
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 9;
        gzip_buffers 16 8k;
        gzip_http_version 1.1;
        gzip_types text/plain text/css text/html text/xml application/json application/javascript application/x-javascript application/xml application/xml+rss text/javascript;

        In the last line, you can see I’m compressing Javascript (.js) files. You can see documentation on those settings here.

        • Hi Logan,

          I used your nginx.conf settings as mentioned in the original tutorial. I also added text/html to the gzip_types just in case (but I don’t think it is necessary because nginx gzip always compresses text/html from what I’ve read)

          #Compression settings
          gzip on;
          gzip_disable “msie6”;
          gzip_vary on;
          gzip_proxied any;
          gzip_comp_level 9;
          gzip_buffers 16 8k;
          gzip_http_version 1.1;
          gzip_types text/html text/plain text/css application/json applicat$

        • Ooops. Copied my nginx.conf directives from nano and it chopped off part of it. Here is the correct paste:

          #Compression settings
          gzip on;
          gzip_disable “msie6”;
          gzip_vary on;
          gzip_proxied any;
          gzip_comp_level 9;
          gzip_buffers 16 8k;
          gzip_http_version 1.1;
          gzip_types text/html text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

          • Don’t know if it matters, but check your quote marks in gzip_disable “msie6″;. They appear to be formatted quotes instead of plain-text quotes.
            Also, try adding application/javascript in addition to application/x-javascript.

            • Thanks Logan. I think it was the malformed quotemark. Now Google PageSpeed reports that my site is using compression. Now onto the other issues to try and get my pagespeed ranking higher.

              Again. Thanks for the help (and sorry for slow response on my part, I was away from the project).

  1. hey Logan, thanks for your tutorial. I like your priorities which seem to be speed, KISS (KeepItSimpleStupid) and security. Very efficient system you got. ATM i don’t have those additional layers of security on my system (http auth, disabling adminer,…), but i might consider implementing them in the future.

    • You’re welcome, glad to help! I was inspired a lot by Daniel Miessler, who runs his personal blog the same way. WordPress gets a bad reputation for security and speed, but when configured properly (and without 20 plugins), it can be pretty fast and flexible.

  2. What a detailed tutorial… You just saved my server because I was going to re-install the operating system and start afresh. But you resolved all of my issues. My main issue was that PHP code was being downloaded instead of executing.

  3. I believe with nginx 1.10.1 you can no longer pass the validation using #Stripped headers and #Added headers

    I get this error:
    [emerg] unknown directive “more_clear_headers” in /etc/nginx/sites-available/conf.headers and thus can’t start the server unless I comment the conf.headers file

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.