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!

 

Author: Alon Ganon

CTO of Being Libertarian LLC. IT Consultant at AccuNet. Dental IT and Linux Specialist. Free, Libre, and Open Source Software advocate. Crypto-Anarchist. My philosophy is, "You are not dead, until you stop learning."

Leave a Reply

Your email address will not be published. Required fields are marked *