How to make a superfast and lightweight WordPress server hosting multiple sites

So after writing the long tutorial about how to make a superfast and lightweight stack on Ubuntu 18.04 before (Read here if you haven’t already) I am going to show you how to modify this stack a bit to host multiple websites, and it’s redoing some of the work we have done prior. I used some of these same steps, with tweaks for my own security setup, when consolidating beinglibertarian.com, rationalstandard.com, and think-liberty.com, but of course when I moved these sites they were already established with content. So before doing anything, I first made a backup using Updraft Plus, and I have mine set to backup to Digital Ocean S3 buckets so I waited for them to upload. If you don’t have them backing up remotely, I highly recommend that you do, but if you don’t you can download the archives through Updraft Plus. Once you have a backup, let’s proceed.

Thoughts and Considerations:

This server will be hosting more than one webserver, you will most likely be needing more resources than the base Digital Ocean VPS, so plan accordingly.

Make the Databases:

So for the sake of this article I will refer to three separate domains. example1.com, example2.com, and example3.com. You’ll first want to make sure on this server you have at least done all of the following in the prior article I linked above. If you’ve already made a database for one of the sites on this server, skip making the DB and such for that one, and wait until we get to modifying Nginx. But for this article we will assume you have a server with resources for three sites, and it’s a blank server ready for databases.

 sudo mysql -u root -p

Type in your password when prompted. This will open up a MariaDB shell session. Once into the MariaDB console it’s time to make three databases, add more or less depending on your requirements. Everything you type here is treated as a SQL query, so make sure you end every line with a semicolon! This is very easy to forget. Here are the commands you need to type in to create a new database, user, and assign privileges to that user:

MariaDB [(none)]> CREATE DATABASE ex1wpdb DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci; 
MariaDB [(none)]> GRANT ALL ON ex1wpdb.* TO 'ex1wpdbuser'@'localhost' IDENTIFIED BY 'securepassword1';
MariaDB [(none)]> CREATE DATABASE ex2wpdb DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci; 
MariaDB [(none)]> GRANT ALL ON ex2wpdb.* TO 'ex2wpdbuser'@'localhost' IDENTIFIED BY 'securepassword2';
MariaDB [(none)]> CREATE DATABASE ex3wpdb DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
MariaDB [(none)]> GRANT ALL ON ex3wpdb.* TO 'ex3wpdbuser'@'localhost' IDENTIFIED BY 'securepassword3';
MariaDB [(none)]> FLUSH PRIVILEGES;
MariaDB [(none)]> quit

Note that although it’s customary to use ALL CAPS to write SQL statements like this, it is not strictly necessary. Also, where I’ve used exwpdb, exwpdbuser, and securepassword make sure to put your own choices and make each username, password, and database different for each site. The last thing you want is someone knowing you had an easy to guess database name and password.

Create the web folders:

So each site will need its own folder for its own files and such to be hosted. So if you followed my prior article and placed the files in /var/www/html you’ll want to create a subfolder and move the files there. So if you have done that do the following with your own domain name of course.

sudo mkdir /var/www/html/example1.com
sudo mv !(/var/www/html) /var/www/html/example1.com

This will have moved all the files into the subfolder. But if you are starting fresh and this is not currently hosting any files, just creating the sub directories yourself.

 sudo mkdir /var/www/html/example1.com
sudo mkdir /var/www/html/example2.com
sudo mkdir /var/www/html/example3.com

Modify Nginx:

First we are going to have multiple sites lets remove the “default” site config and symlinks

sudo mv /etc/nginx/sites-available/default /etc/nginx/sites-available/example1.com
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/example1.com /etc/nginx/sites-enabled/example1.com

In the last article we had you edit /etc/nginx/sites-available/default which you can see on the original post.Well we are going to remove some of the top lines and modify some other settings. I will put the changes in Bold and comment information to know if relevant.

#modify the /var/run/nginx-cache to be slightly different e.g. make a /var/run/nginx-cache, /var/run/nginx-cache2, and etc. 
#The keys_zone variable must be different for each site as well and mach up the PHP variables set further down.
fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=EXAMPLECOM1:100m inactive=60m;
#Three lines were removed, we are placing these somewhere else. So copy the commented out lines for later
#fastcgi_cache_key "$scheme$request_method$host$request_uri";
#fastcgi_cache_use_stale error timeout invalid_header http_500;
#fastcgi_ignore_headers Cache-Control Expires Set-Cookie;

server {
#remove any mention of default_server
  listen 80;
  listen [::]:80;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  #Comment out the lines about an ssl_certificate unless you've already provisioned it.
  #ssl_certificate /etc/letsencrypt/live/example1.com/fullchain.pem; # managed by Certbot
  #ssl_certificate_key /etc/letsencrypt/live/example1.com/privkey.pem; # managed by Certbot
  include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
 
  # force redirect to HTTPS from HTTP. COMMENT OUT IF YOU HAVEN'T DONE LETSENCRYPT
  #if ($scheme != "https") {
  #  return 301 https://$host$request_uri;
  #}
 
  client_max_body_size 256M;
  #Must make a different folder for each site. I like to make them each their own folder in /var/www/html but do whatever feelts most comfortable to you
  root /var/www/html/example1.com;
  index index.php index.html;
 
  server_name example.com www.example.com;
 
  set $skip_cache 0;
 
  if ($request_method = POST) {
    set $skip_cache 1;
  }
 
  if ($query_string != "") {
    set $skip_cache 1;
  }
 
  if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") {
    set $skip_cache 1;
  }
 
  if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
    set $skip_cache 1;
  }
 
  location ~ /purge(/.*) {
    fastcgi_cache_purge EXAMPLECOM1 "$scheme$request_method$host$1";
  }
 
  location / {
    try_files $uri $uri/ /index.php?$args;
    #The zone name must match what will be set in the next step. Set a different zone name for each and remember what you set
    limit_req zone=ex1 burst=50;
  }
 
  # Turn off directory indexing
  autoindex off;
 
  # Deny access to htaccess and other hidden files
  location ~ /\. {
    deny  all;
  }
 
  # Deny access to wp-config.php file
  location = /wp-config.php {
    deny all;
  }
 
  # Deny access to revealing or potentially dangerous files in the /wp-content/ directory (including sub-folders)
  location ~* ^/wp-content/.*\.(txt|md|exe|sh|bak|inc|pot|po|mo|log|sql)$ {
    deny all;
  }
 
  # Stop php access except to needed files in wp-includes
  location ~* ^/wp-includes/.*(?<!(js/tinymce/wp-tinymce))\.php$ {
    internal; #internal allows ms-files.php rewrite in multisite to work
  }
 
  # Specifically locks down upload directories in case full wp-content rule below is skipped
  location ~* /(?:uploads|files)/.*\.php$ {
    deny all;
  }
 
  # Deny direct access to .php files in the /wp-content/ directory (including sub-folders).
  # Note this can break some poorly coded plugins/themes, replace the plugin or remove this block if it causes trouble
  location ~* ^/wp-content/.*\.php$ {
    deny all;
  }
 
  location = /favicon.ico {
    log_not_found off;
    access_log off;
  }
 
  location = /robots.txt {
    access_log off;
    log_not_found off;
  }
 
  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache $skip_cache;
    fastcgi_cache EXAMPLECOM1;
    fastcgi_cache_valid 60m;
    include fastcgi_params;
  }
  ## Block file injections
    set $block_file_injections 0;
    if ($query_string ~ "[a-zA-Z0-9_]=http://") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=(\.\.//?)+") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=/([a-z0-9_.]//?)+") {
        set $block_file_injections 1;
    }
    if ($block_file_injections = 1) {
        return 403;
  }
  ## Block SQL injections
    set $block_sql_injections 0;
    if ($query_string ~ "union.*select.*\(") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "union.*all.*select.*") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "concat.*\(") {
        set $block_sql_injections 1;
    }
    if ($block_sql_injections = 1) {
        return 403;
  }
  ## Block common exploits
    set $block_common_exploits 0;
    if ($query_string ~ "(<|%3C).*script.*(>|%3E)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "GLOBALS(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "_REQUEST(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "proc/self/environ") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "mosConfig_[a-zA-Z_]{1,21}(=|\%3D)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "base64_(en|de)code\(.*\)") {
        set $block_common_exploits 1;
    }
    if ($block_common_exploits = 1) {
        return 403;
  }
}

You will need to make a file in /etc/nginx/sites-available for each site you plan to host on this server wit the appropriate modifications above. Make sure all relevant letsencrypt parts are commented out for now and we will uncomment those later.

Now remember those three lines at the top I said to copy? Open up /etc/nginx/nginx.conf we’re pasting that there along with a couple extra settings. In the http block of the config file, you will paste them in. This is how the beginning of the file looks for me as we also added rate limiting to the zone.

 http {
        ##
        # EasyEngine Settings
        ##

        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 30;
        types_hash_max_size 2048;

        server_tokens off;
        reset_timedout_connection on;
        # add_header X-Powered-By "EasyEngine";
        add_header rt-Fastcgi-Cache $upstream_cache_status;

        # Limit Request
        limit_req_status 403;
        limit_req_zone $binary_remote_addr zone=ex1:10m rate=2r/s;
        limit_req_zone $binary_remote_addr zone=ex2:10m rate=2r/s;
        limit_req_zone $binary_remote_addr zone=ex3:10m rate=2r/s;

        # Proxy Settings
        # set_real_ip_from      proxy-server-ip;
        # real_ip_header        X-Forwarded-For;

        fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
        fastcgi_cache_use_stale error timeout invalid_header http_500;
        fastcgi_cache_key "$scheme$request_method$host$request_uri";
        fastcgi_read_timeout 300;
        client_max_body_size 100m;

Now we need to make symlinks from the sites-available to the sites-enabled. This lets Nginx know these are going to be used

sudo ln -s /etc/nginx/sites-available/example1.com /etc/nginx/sites-enabled/example1.com
sudo ln -s /etc/nginx/sites-available/example2.com /etc/nginx/sites-enabled/example2.com
sudo ln -s /etc/nginx/sites-available/example3.com /etc/nginx/sites-enabled/example3.com

Now test your configs and check for errors

sudo nginx -t

Once that is all done, for the site and nginx config files. Point your domains at the server IP address via your registrar, Cloudflare, or etc. Let’s place a test file in each of the web folders, and restart nginx, then get on to provisioning let’s encrypt for each site.

echo "<?php phpinfo();" | sudo tee /var/www/html/example1.com/index.php > /dev/null
echo "<?php phpinfo();" | sudo tee /var/www/html/example2.com/index.php > /dev/null
echo "<?php phpinfo();" | sudo tee /var/www/html/example3.com/index.php > /dev/null
sudo service nginx restart

If everything went well all domains should pull up PHP info.

Let’s Encrypt your transport:

Now just follow the instructions in the prior article to do the initial steps, and provided you entered your domains correctly into the Nginx config file, certbot should find and install certificates for all of the domains. Make sure to pick a reliable email for alerts from letsencrypt.

If for some reason certbot can’t find them or you want an SSL for another domain that is pointed at your server you can generate a certificate by using “-d domain.tld” for all the domains you want like so, and bare in mind www.example.tld and example.tld are considered two different domains, so you need to include both in the certificate you generate along with any other subdomains.

 sudo certbot -d example.tld -d www.example.tld

Now all of the other Let’s Encrypt settings should be fine provided you followed the original guide. Also remember all those lines we commented out that were related to https, let’s encrypt, and TLS? Go back to your site configs and uncomment those and then restart the nginx server.

sudo service nginx restart

Now provided everything went well with Nginx and Let’s Encrypt all of your sites should be showing as encrypted. Now it’s time to setup WordPress. Simply go back to the original guide and just cd into each directory, using the wp-cli commands and such for each site and you should be golden!

 

Make a super fast and lightweight WordPress on Ubuntu 18.04 with PHP 7.2, Nginx, and MariaDB

I’ve been building servers for a long while based on the ideas I learned a few years ago from morphatic.com, however I wanted to move on PHP 7.2 and I also wanted to begin a server migration project to have Beinglibertarian.com, of which I am the CTO, also host our newest members think-liberty.com and rationalstandard.com since they really liked the speed of our WordPress stack. This is a wordpress stack we will build based on Ubuntu 18.04, Nginx, MariaDB, and PHP 7.2. We will even cover setting up lets encrypt. Just a note that I use Mailgun to deliver the emails, it’s free for up to 10,000 emails per month, and they have an easy to use WordPress plugin that makes it super easy to configure. There is of course far more that you can do to secure your server, and while we aren’t going to cover hosting multiple sites in this tutorial, you can understand how I made such a robust server stack on a Digital Ocean Virtual Private Server. You can use this referral code (https://m.do.co/c/0c6bfeff20b7)to get you a few dollars free with Digital Ocean when you sign up, and it also helps support the costs of my own hosting.

So first things first go to Digital Ocean, which we use, or any other VPS or dedicated server provider you use and get the OS setup and do the basics so we can even login to the box, and point your domain at your server. Once that is done I like to start setting up security, disabling root, and allowing a username to have sudo rights.

For those new to Linux administration you can use these tutorials as to how to add new sudo users and setup ssh keys for for even more security but once that is done let’s move on to the basic security I use.

We definitely want the firewall on our box, but IPtables can be a pain to manage. So let’s begin installing things on the box for security. Including Uncomplicated Firewall which can easily manage firewall rules for us.

Install UFW, Fail2Ban, Nginx, and MariaDB:

In order to use a WordPress plugin for purging the NGINX cache that I talk about below, you have to install a custom version of NGINX. MariaDB is a drop-in replacement for MySQL. You can read about why people think it’s better, but from what I have mostly noticed is that it is incredibly fast compared to MySQL. The MariaDB website has a convenient tool for configuring the correct repositories in your Ubuntu distro. From the command line:

sudo apt update 
sudo apt dist-upgrade -y 
sudo apt install ufw fail2ban
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3050AC3CD2AE6F03
sudo sh -c "echo 'deb http://download.opensuse.org/repositories/home:/rtCamp:/EasyEngine/xUbuntu_18.04/ /' >> /etc/apt/sources.list.d/nginx.list"
sudo apt update
sudo apt install nginx-custom
sudo ufw limit ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo apt-key adv --recv-keys --keyserver hkp://keyserver.ubuntu.com:80 0xF1656F24C74CD1D8 sudo add-apt-repository 'deb [arch=amd64,arm64,ppc64el] https://mirrors.evowise.com/mariadb/repo/10.3/ubuntu bionic main' 
sudo apt install mariadb-server

When the following screen comes up, make sure you provide a good secure password that is different from the password you used for your user account.

Next, lock down your MariaDB instance by running:

sudo mysql_secure_installation

Since you’ve already set up a secure password for your root user, you can safely answer “no” to the question asking you to create a new root password. Answer “Yes” to all of the other questions. Now we can set up a separate MariaDB account and database for our WordPress instance. At the command prompt type the following:

sudo mysql -u root -p

Type in your password when prompted. This will open up a MariaDB shell session. Everything you type here is treated as a SQL query, so make sure you end every line with a semicolon! This is very easy to forget. Here are the commands you need to type in to create a new database, user, and assign privileges to that user:

MariaDB [(none)]> CREATE DATABASE mywpdb DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
MariaDB [(none)]> GRANT ALL ON mywpdb.* TO 'mywpdbuser'@'localhost' IDENTIFIED BY 'securepassword';
MariaDB [(none)]> FLUSH PRIVILEGES;
MariaDB [(none)]> quit

Note that although it’s customary to use ALL CAPS to write SQL statements like this, it is not strictly necessary. Also, where I’ve used mywpdb, mywpdbuser, and securepassword make sure to put your own choices. The last thing you want is someone knowing you had an easy to guess database name and password.

Fail2Ban Installation and Setup:

Fail2Ban is an intrusion prevention software framework that protects servers from brute-force attacks. It’s probably one of my all time favorite security tools as it’s very robust and flexible. In order to make modifications to Fail2Ban we need to make a local copy that we can modify so we can preserve changes.

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Now open the newly made file so we can edit it

sudo nano /etc/fail2ban/jail.local

I recommend reading this guide from Digital Ocean on Fail2Ban with Nginx and follow the tutorial to setup and activate the following jails

  1. Change Defaults per tutorial
  2. nginx-http-auth
  3. nginx-badbots
  4. nginx-nohome
  5. nginx-noproxy

Also make sure the SSH and SSH-DDoS jails are enabled, and consider enabling the recidive filter. I also recommend adding a jail for WordPress via the WP Fail2ban plugin for wordpress, which can be easily installed and activated by following their instructions.

Installing and Configuring PHP 7.2:
Since we are using Ubuntu 18.04, PHP 7.2 is the default for PHP so simply run in terminal

sudo apt install -y zip unzip php-fpm php-mysql php-xml php-gd php-mbstring php-zip php-curl 

Just an FYI that this also installs the MySQL, XML, Curl and GD packages so that WordPress can interact with the database, support for XMLRPC (required for Jetpack), and also automatically cropping and resize images. It also installs zip/unzip because I use zip and unzip in some of my own backup plugins and tools.

I also like to tweak the php.ini settins to allow for more memory and larger file sizes. So let’s open /etc/php/7.2/fpm/php.ini.

sudo nano /etc/php/7.2/fpm/php.ini

You can make this faster by using the search function with CTRL + W and then typing what you’re looking for. I usually recommend increasing the post_max_size from the default 8MB, upload_max_filesize from the default 2MB, and memory_limit from it’s default. I generally set all of mine to 128MB and 256MB respectively

Now let’s restart PHP

sudo service php7.2-fpm restart

Now we need to tell Nginx to use PHP7.2-fpm, so let’s open up our configuration file for our default site.

sudo nano /etc/nginx/sites-available/default

We need to edit the file so that it looks like below, but change example.com and www.example.com to your TLD that you are using with your server.

server {
  listen 80 default_server;
  listen [::]:80 default_server;
 
  root /var/www/html;
  index index.php index.html;
 
  server_name example.com www.example.com;
 
  location / {
    try_files $uri $uri/ =404;
  }
 
  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
  }
 
  location ~ /\.ht {
    deny all;
  }
}

Save and exit, then restart Nginx to apply changes

 sudo service nginx restart

Now it’s time to test it all out and make sure this is all working properly. So let’s make a sample PHP file in your /var/www/html folder called index.php

echo "<?php phpinfo();" | sudo tee /var/www/html/index.php > /dev/null

Now open up your web browser and go to http://SERVER.IP.ADDRESS.HERE (e.g. http://192.168.1.1), and you should see something like this.

Awesome sauce, we’re starting to see it finally coming together! You officially have made a Linux, Nginx, MariaDB, and PHP stack aka a LEMP stack. Honestly at this point you can serve up just about any LEMP needs you have for any software such as NextCloud or more. Let’s move on, the goal line is within sight!

Encrypt! Encrypt! Encrypt! Let’s Encrypt, with TLS/SSL Certificates from letsencrypt.org

This is pretty straight forward but I recommend reading Digital Ocean’s tutorial on setting up and securing nginx, to fully grasp what we are doing here. So let’s install letsencrypt. Before you used to have to add a PPA, update, and install certbot, but it’s in the main Ubuntu repo these days so one command to install letsencrypt, and another to install the certificates to the domains defined in the /etc/nginx/sites-available config file as we have done earlier.

 sudo apt install -y letsencrypt
sudo certbot --nginx

Now just follow the instructions, and provided you entered your domains correctly into the Nginx config file, certbot should find and install certificates for all of the domains. Make sure to pick a reliable email for alerts from letsencrypt.

If for some reason certbot can’t find them or you want an SSL for another domain that is pointed at your server you can generate a certificate by using “-d domain.tld” for all the domains you want like so, and bare in mind www.example.tld and example.tld are considered two different domains, so you need to include both in the certificate you generate along with any other subdomains.

 sudo certbot -d example.tld -d www.example.tld

Now we need to edit the Nginx snippet created by certbot.

 sudo nano /etc/letsencrypt/options-ssl-nginx.conf

Edit it so it looks like below, althought the top few lines are created by certbot, so add the ones below to enhance our security profile.

 
# automatically added by Certbot
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA38$
 
# MANUALLY ADD THESE
ssl_ecdh_curve secp384r1;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;

Now save and exit.

It’s extremely important to renew your Letsencrypt certificates every couple months at least as they expire every 90 days. So we need to setup a cron job to check for renewals often, and renew the certificates automatically. So lets edit the crontab as root

sudo crontab -e

Add the following lines so we can have it check and autorenew certificates every Monday.

30 2 * * 1 /usr/bin/certbot renew >> /var/log/le-renew.log
35 2 * * 1 /bin/systemctl reload nginx

Now lets save that and run certbot in a dry run to see if renewals will work.

sudo certbot renew --dry-run

Now it’s time to install WordPress
Personally I like to install wp-cli and then finish it up in the WebUI. I love WP CLI as it is a command line interface to administrate wordpress. So if a worst case happens and you say lock yourself out and can’t reset the password, want to install or deactivate a plugin that isn’t allowing wordpress to work, or more it can do it. It’s extremely powerful and handy to have on a system regardless. So let’s install that, then have it download the WordPress files.

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
cd /var/www/html
sudo wp core download

Now go to your domain, and you can run the WordPress quick install, it’s straightforward just enter all the information that it asks for, once that is done hop back into terminal and let’s use WP CLI to install some plugins easily that I recommend with this setup to integrate with the caching on the OS, Fail2Ban, and more. If not planning to use mailgun I recommend gmail-smtp if you use gmail. But do not install both Mailgun and Gmail-Smtp, pick one. I also added Cloudflare because I use that, and it’s a free CDN as well as proxy to help avoid DDoS attacks. They have a free plan that is great.I also added WP-Sweep a great database cleaner tool, and Updraft plus, one of the best WordPress backup software. Plus iThemes Security which I really like for it’s many free security features.

sudo wp plugin delete hello --allow-root
sudo wp plugin install nginx-helper --allow-root
sudo wp plugin activate nginx-helper --allow-root
sudo wp plugin install mailgun --allow-root
sudo wp plugin activate mailgun --allow-root
sudo wp plugin install jetpack --allow-root
sudo wp plugin activate jetpack --allow-root
sudo wp plugin install gmail-smtp --allow-root
sudo wp plugin activate gmail-smtp --allow-root
sudo wp plugin install cloudflare --allow-root
sudo wp plugin activate cloudflare --allow-root
sudo wp plugin install wp-sweep --allow-root
sudo wp plugin activate wp-sweep --allow-root
sudo wp plugin install updraftplus --allow-root
sudo wp plugin activate updraftplus --allow-root
sudo wp plugin install wp-better-security --allow-root
sudo wp plugin activate wp-better-security --allow-root

Mailgun Setup
You’ll need to setup an account. I recommend despite their recommendation, making your domain the same as your regular domain, do not subdomain it. The reason why is you can up a forwarding rule so if say someone emails you at [email protected] it could look professional and forward to a gmail account per se. After you’ve set up your domain at Mailgun, go to Settings > Mailgun from the WP dashboard, copy and paste in your Mailgun domain name and API key, and then click “Save Changes” to get it set up. Click “Test Configuration” to make sure it is working. You may also want to use the Check Email plugin just to make sure that emails are being sent correctly.

GMail SMTP Setup
If you setup the GMail SMTP servers in your DNS according to this guide, you’ll want to have installed the GMail SMTP plugin for WP. The setup for this plugin is somewhat involved. I strongly urge you to follow the instructions on their documentation site.

Time to Optimize and Secure the WordPress

Here are some tips for securing and optimizing your wordpress. Simply replace the content of /etc/nginx/sites-available/default with the following and make sure any reference of “example.com” reflects your actual domain and tld.

fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:100m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header http_500;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
 
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  listen 443 ssl http2 default_server;
  listen [::]:443 ssl http2 default_server;
  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
  include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
 
  # force redirect to HTTPS from HTTP
  if ($scheme != "https") {
    return 301 https://$host$request_uri;
  }
 
  client_max_body_size 256M;
  root /var/www/html;
  index index.php index.html;
 
  server_name example.com www.example.com;
 
  set $skip_cache 0;
 
  if ($request_method = POST) {
    set $skip_cache 1;
  }
 
  if ($query_string != "") {
    set $skip_cache 1;
  }
 
  if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") {
    set $skip_cache 1;
  }
 
  if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
    set $skip_cache 1;
  }
 
  location ~ /purge(/.*) {
    fastcgi_cache_purge WORDPRESS "$scheme$request_method$host$1";
  }
 
  location / {
    try_files $uri $uri/ /index.php?$args;
    limit_req zone=one burst=50;
  }
 
  # Turn off directory indexing
  autoindex off;
 
  # Deny access to htaccess and other hidden files
  location ~ /\. {
    deny  all;
  }
 
  # Deny access to wp-config.php file
  location = /wp-config.php {
    deny all;
  }
 
  # Deny access to revealing or potentially dangerous files in the /wp-content/ directory (including sub-folders)
  location ~* ^/wp-content/.*\.(txt|md|exe|sh|bak|inc|pot|po|mo|log|sql)$ {
    deny all;
  }
 
  # Stop php access except to needed files in wp-includes
  location ~* ^/wp-includes/.*(?<!(js/tinymce/wp-tinymce))\.php$ {
    internal; #internal allows ms-files.php rewrite in multisite to work
  }
 
  # Specifically locks down upload directories in case full wp-content rule below is skipped
  location ~* /(?:uploads|files)/.*\.php$ {
    deny all;
  }
 
  # Deny direct access to .php files in the /wp-content/ directory (including sub-folders).
  # Note this can break some poorly coded plugins/themes, replace the plugin or remove this block if it causes trouble
  location ~* ^/wp-content/.*\.php$ {
    deny all;
  }
 
  location = /favicon.ico {
    log_not_found off;
    access_log off;
  }
 
  location = /robots.txt {
    access_log off;
    log_not_found off;
  }
 
  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache $skip_cache;
    fastcgi_cache WORDPRESS;
    fastcgi_cache_valid 60m;
    include fastcgi_params;
  }
  ## Block file injections
    set $block_file_injections 0;
    if ($query_string ~ "[a-zA-Z0-9_]=http://") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=(\.\.//?)+") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=/([a-z0-9_.]//?)+") {
        set $block_file_injections 1;
    }
    if ($block_file_injections = 1) {
        return 403;
  }
  ## Block SQL injections
    set $block_sql_injections 0;
    if ($query_string ~ "union.*select.*\(") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "union.*all.*select.*") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "concat.*\(") {
        set $block_sql_injections 1;
    }
    if ($block_sql_injections = 1) {
        return 403;
  }
  ## Block common exploits
    set $block_common_exploits 0;
    if ($query_string ~ "(<|%3C).*script.*(>|%3E)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "GLOBALS(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "_REQUEST(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "proc/self/environ") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "mosConfig_[a-zA-Z_]{1,21}(=|\%3D)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "base64_(en|de)code\(.*\)") {
        set $block_common_exploits 1;
    }
    if ($block_common_exploits = 1) {
        return 403;
  }
}

Then while we are at it let’s make sure /etc/nginx/nginx.conf has an additional parameter we set. So open up it up with nano

sudo nano /etc/nginx/nginx.conf

Then look to see if the following block is there under the http section, and make sure it refers to zone one.

http {
    limit_req_zone  $binary_remote_addr  zone=one:10m   rate=2r/s; 

This config file will take advantage of the advanced caching capabilities of our custom version of NGINX. It will also prevent visitors from accessing files that they shouldn’t be. This also adds some configurations to block SQL and file injection attacks, as well as blocking common exploits. Plus we also added some rate limiting so it can help prevent a Denial of Service attack. The combined effect will be to make your site faster and more secure.

Admin’s Have to get Alerts. Set those Admin Emails Up!

Sometimes things happen and you need to know when they happen. So we need to setup email alerts, and while there are a number of ways to do this, this is the best way I recommend to less advanced Linux users. The two ways here will either route through Mailgun, or Gmail depending on what you did earlier will determine what you will do right now. It is based on this tutorial from the EasyEngine folks. First, install the necessary packages. When prompted about your server type, select “Internet Site”, and for your FQDN, the default should be acceptable. Then open the config file for editing:

sudo apt install -y postfix mailutils libsasl2-2 ca-certificates libsasl2-modules
sudo nano /etc/postfix/main.cf

We’ll need to edit the “mydestination” property and add a few more, but we can leave the rest as their defaults.

mydestination = localhost.$myhostname, localhost
relayhost = [smtp.mailgun.org]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_CAfile = /etc/postfix/cacert.pem
smtp_use_tls = yes

If you’re using Gmail as your SMTP server, edit it slightly to look like the following

mydestination = localhost.$myhostname, localhost
relayhost = [smtp.gmail.com]:465
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_CAfile = /etc/postfix/cacert.pem
smtp_use_tls = yes
smtp_tls_wrappermode = yes
smtp_tls_security_level = encrypt

Now save that file and it’s time to make a file to store our SMTP credentials

 sudo nano /etc/postfix/sasl_passwd

Now add one of the following single lines, only use one of them, and only pick the one you need for Mailgun or Gmail. Where “PASSWORD” is, of course put your actual password”

[smtp.mailgun.org]:587 [email protected]:PASSWORD
OR
[smtp.gmail.com]:465 [email protected]:PASSWORD

You’ll have to get the password for the postmaster account from your Mailgun dashboard. The password for the GMail example should be the password for the email address used. Next we need to lock down this file and tell postfix to use it by running the following:

$ sudo chmod 400 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
cat /etc/ssl/certs/thawte_Primary_Root_CA.pem | sudo tee -a /etc/postfix/cacert.pem

Now it’s testing time

  sudo /etc/init.d/postfix reload
echo "Test mail from postfix" | mail -s "Test Postfix" [email protected]

If everything went perfect, you’ll receive an email from the server at the address in the last line. Also you can check the mailgun logs to see if it routed through their servers.

FINISH HIM! Auto Updating the server
So we need to make sure our server it automatically applying security updates for obvious reasons. So now we need to enable auto updates for apt.

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

After editing the file it should look like this

Unattended-Upgrade::Allowed-Origins {
        "${distro_id}:${distro_codename}-security";
        "${distro_id}:${distro_codename}-updates";
        "${distro_id}ESM:${distro_codename}";
};
Unattended-Upgrade::Mail "[email protected]";
//Unattended-Upgrade::MailOnlyOnError "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "02:00";

This will tell the server to automatically apply security and regular updates, email the admin when updates are done, automatically remove unused dependencies, automatically reboot if necessary, and reboot at 2AM if necessary. But now we need to edit the 10periodic to enable some options.

sudo nano /etc/apt/apt.conf.d/10periodic

Once done it should look like this

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";

What that tells it to do is run “apt update” to pull new packages, download packages that are available for update, automatically clean package installers weekly, and enable the unattended upgrade we configured prior.

Finally lets do one last update and clean the server up for it’s maiden voyage.

sudo apt-get update
sudo apt-get upgrade
sudo apt-get autoclean
sudo reboot

Another thing that maybe useful is adding a swapfile, I prefer to instead go to a larger server with more RAM as a Swap file isn’t as ideal as more RAM, but better than nothing if you absolutely need it. Digital Ocean has a great tutorial here.

Conclusion:
This was a bit of a longer tutorial, and there is a whole lot more you can do from additional wordpress plugins, to a CDN like Cloudflare which can really speed up the site, additional security from Port Scan Attack Detector (PSAD), additional blocklists, and more. I hope to cover an addition to this tutorial in the future to detail how I got multiple website on the same box using a slightly modified version of this stack