Self hosting WordPress securely in 2018 on FreeBSD with nginx, PHP 7.2, ModSecurity, brotli, Let’s Encrypt SSL

Self hosting WordPress securely in 2018 on FreeBSD with nginx, PHP 7.2, ModSecurity, brotli, Let’s Encrypt SSL

 

← Page 1

 

Build nginx with ModSecurity and brotli compression

With all the required software downloaded, we can compile mod_security:

cd ModSecurity
sh autogen.sh
./configure --enable-standalone-module
make

And we are now ready to compile nginx.

cd ../nginx-*/
./configure --add-module=../ngx_brotli/ --add-module=../ModSecurity/nginx/modsecurity/ --prefix=/usr/local/etc/nginx --with-cc-opt='-I /usr/local/include' --with-ld-opt='-L /usr/local/lib' --conf-path=/usr/local/etc/nginx/nginx.conf --sbin-path=/usr/local/sbin/nginx --pid-path=/var/run/nginx.pid --error-log-path=/var/log/nginx/error.log --user=www --group=www --modules-path=/usr/local/libexec/nginx --with-file-aio --http-client-body-temp-path=/var/tmp/nginx/client_body_temp --http-fastcgi-temp-path=/var/tmp/nginx/fastcgi_temp --http-proxy-temp-path=/var/tmp/nginx/proxy_temp --http-scgi-temp-path=/var/tmp/nginx/scgi_temp --http-uwsgi-temp-path=/var/tmp/nginx/uwsgi_temp --http-log-path=/var/log/nginx/access.log --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gzip_static_module --with-http_gunzip_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_stub_status_module --with-http_sub_module --with-pcre --with-http_v2_module --with-stream=dynamic --with-stream_ssl_module --with-stream_ssl_preread_module --with-threads --with-mail=dynamic --without-mail_imap_module --without-mail_pop3_module --without-mail_smtp_module --with-mail_ssl_module --with-http_ssl_module --with-http_geoip_module=dynamic
make && make install
cd ..

Plase note that most of the configuration flags are FreeBSD’s defaults. nginx will be installed just like if you’d add it from packages. Would you need to remove it – simply install and delete its package.

As we haven’t installed nginx from ports nor packages, system will lack it’s start script. You can obtain it by installing nginx from packages or ports first, copying the file, uninstalling it, and installing the above source-compiled nginx. Or you can just download the file from here – this is the default nginx rc.d script from FreeBSD 11’s ports:

mkdir -p /usr/local/etc/rc.d
fetch -o /usr/local/etc/rc.d/nginx https://malcont.net/wp-content/uploads/2018/01/nginx_freebsd_rcd_script
chmod 555 /usr/local/etc/rc.d
chmod 555 /usr/local/etc/rc.d/nginx
sysrc nginx_enable="YES"

 

Basic mod_security configuration

Before we start copying mod_security rules and configs, you should make sure that your nginx was succesfully built with mod_security support. Issue the following command:

nginx -V
nginx version: nginx/1.14.0
built by clang 4.0.0 (tags/RELEASE_400/final 297347) (based on LLVM 4.0.0)
built with OpenSSL 1.0.2k-freebsd  26 Jan 2017
TLS SNI support enabled
configure arguments: --add-module=../ngx_brotli/ --add-module=../ModSecurity/nginx/modsecurity/ --prefix=/usr/local/etc/nginx --with-cc-opt='-I /usr/local/include' --with-ld-opt='-L /usr/local/lib' --conf-path=/usr/local/etc/nginx/nginx.conf --sbin-path=/usr/local/sbin/nginx --pid-path=/var/run/nginx.pid --error-log-path=/var/log/nginx/error.log --user=www --group=www --modules-path=/usr/local/libexec/nginx --with-file-aio --http-client-body-temp-path=/var/tmp/nginx/client_body_temp --http-fastcgi-temp-path=/var/tmp/nginx/fastcgi_temp --http-proxy-temp-path=/var/tmp/nginx/proxy_temp --http-scgi-temp-path=/var/tmp/nginx/scgi_temp --http-uwsgi-temp-path=/var/tmp/nginx/uwsgi_temp --http-log-path=/var/log/nginx/access.log --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gzip_static_module --with-http_gunzip_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_stub_status_module --with-http_sub_module --with-pcre --with-http_v2_module --with-stream=dynamic --with-stream_ssl_module --with-stream_ssl_preread_module --with-threads --with-mail=dynamic --without-mail_imap_module --without-mail_pop3_module --without-mail_smtp_module --with-mail_ssl_module --with-http_ssl_module

If there are ngx_brotli and ModSecurity modules included in the “configure arguments” line, you’re good.
Now we can copy the default mod_security configuration to nginx config directory. We will configure nginx itself later.

cp owasp-modsecurity-crs/rules/*data /usr/local/etc/nginx/
cp ModSecurity/unicode.mapping /usr/local/etc/nginx/
cat owasp-modsecurity-crs/crs-setup.conf.example owasp-modsecurity-crs/rules/*conf > /usr/local/etc/nginx/modsecurity.conf
Note that we are copying all ModSec’s rules and configs. You may want to shorten the list to REQUEST-901-INITIALIZATION.conf REQUEST-903.9002-WORDPRESS-EXCLUSION-RULES.conf REQUEST-911-METHOD-ENFORCEMENT.conf REQUEST-912-DOS-PROTECTION.conf REQUEST-913-SCANNER-DETECTION.conf REQUEST-920-PROTOCOL-ENFORCEMENT.conf REQUEST-921-PROTOCOL-ATTACK.conf REQUEST-933-APPLICATION-ATTACK-PHP.conf REQUEST-941-APPLICATION-ATTACK-XSS.conf REQUEST-942-APPLICATION-ATTACK-SQLI.conf RESPONSE-953-DATA-LEAKAGES-PHP.conf RESPONSE-959-BLOCKING-EVALUATION.conf and crawlers-user-agents.data php-config-directives.data php-errors.data php-function-names* php-variables.data restricted-files.data scanners*data sql*data unix*data scripting-user-agents.data scanners-*

 

Install and configure MariaDB database

MariaDB is a MySQL fork created when Oracle bought Sun. Many of the old MySQL developers work on MariaDB now. I’m chosing the fork over original MySQL Community Edition, but you can install mysql56-server or mysql57-server packages to use MySQL instead of MariaDB.

pkg install mariadb102-server mariadb102-client
sysrc mysql_enable="YES"
service mysql-server start

 

Random password for MariaDB’s root user

We will generate 16 characters long, random password for MariaDB’s root user. Please create a copy of the printed password in a secure place. If you encounter any problem here, or you will be asked for a password – make sure that you are using csh or tcsh shell. It’s FreeBSD default, but some VPS providers might install FreeBSD with sh or other shell for root user.
If you feel paranoid, you can use 32 characters long passwords – just change the `fold` part.

setenv LC_CTYPE en_US.UTF=8
set mysql_root_password=`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1`
/usr/local/bin/mysqladmin -u root password $mysql_root_password
printf "\n\n\nThis is your MariaDB root password. Store it safely: %s\n\n\n" $mysql_root_password
unset mysql_root_password

WordPress database and user

We will now create the database for WordPress and also generate another random password – for its user. You will be asked for previously generated root password twice, and once this operation is done, user’s password will be printed out. You should also store it securely. It will be needed further, in WordPress’ database configuration.

mysql -u root -p -e 'CREATE DATABASE wordpressdb'
Enter password:
set mysql_user_password=`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1`
set QUERY="GRANT INSERT,UPDATE,SELECT,ALTER,DELETE,CREATE ON wordpressdb.* TO 'wordpressuser'@'localhost' IDENTIFIED BY '$mysql_user_password';"
mysql -u root -p -e "${QUERY}"
Enter password:
printf "\n\n\nThis is your MariaDB WordPress user password. Store it safely: %s\n\n\n" $mysql_user_password
unset mysql_user_password QUERY

 

Download WordPress

We will be storing WordPress in FreeBSD’s default web directory, /usr/local/www/. The below link is always the most recent version of WordPress. Remember to change “mysite” to the name of your user.

fetch https://wordpress.org/latest.zip
unzip -d /usr/local/www/ latest.zip
chown -R mysite:mysite /usr/local/www/wordpress
chmod 701 /usr/local/www/wordpress/
chmod -R 700 /usr/local/www/wordpress/*
find /usr/local/www/wordpress -name \*txt |xargs rm -f 

 

SSL/TLS and Let’s Encrypt certificate

It’s strongly advised to create Diffie-Hellman parameters file for nginx. This process can take quite some time (think 15-120 minutes), so you might want to open a new ssh session to this system and let OpenSSL generate the dhparam.pem file in another window, while configuring Certbot – Let’s Encrypt Client, and nginx.

mkdir -p /etc/ssl/nginx && chmod 600 /etc/ssl/nginx
openssl dhparam -out /etc/ssl/nginx/dhparam.pem 4096 && chmod 600 /etc/ssl/nginx/dhparam.pem

 

Certbot

Let’s Encrypt’s client – Certbot is written in Python and there are packages available for different versions of Python. WARNING! This command assumes that you either have python 2.7 or 3.6. If you do have other version of Python – use proper package. This will be for example py27-certbot for Python 2.7.x. To figure out if and which version of Python you have installed, issue the following command: pkg info|grep ^python. If you don’t have any version of Python installed, you are free to choose either.
These commands will install Certbot for Python 3.6 or 2.7. You have to choose one of the below commands:

pkg install py27-certbot

OR

pkg install py36-certbot

And now a lot of python packages will be installed. I considered using some more lightweight ACME client for this howto, but decided to relay on the official client to keep things simple. If you prefer something with less dependencies, you might want to consider other ACME client, like dehydrated.

Having Certbot installed, we will start its stand-alone temporary webserver to obtain Let’s Encrypt certificate. Later on we will configure nginx to handle periodic certificate updates, but stand-alone server is enough for now.

certbot certonly --standalone -d yourdomain.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator standalone, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): [email protected]

-------------------------------------------------------------------------------
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-v01.api.letsencrypt.org/directory
-------------------------------------------------------------------------------
(A)gree/(C)ancel: A

-------------------------------------------------------------------------------
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about EFF and
our work to encrypt the web, protect its users and defend digital rights.
-------------------------------------------------------------------------------
(Y)es/(N)o: N (or Y if you wish)
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for yourdomain.com
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /usr/local/etc/letsencrypt/live/yourdomain.com/fullchain.pem
   Your key file has been saved at:
   /usr/local/etc/letsencrypt/live/yourdomain.com/privkey.pem
   Your cert will expire on XXXX-XX-XX. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

Link the certificate and key to /etc/ssl/nginx and ensure that the proper file permissions are set

ln -s /usr/local/etc/letsencrypt/live/*/fullchain.pem /etc/ssl/nginx/fullchain.pem
ln -s /usr/local/etc/letsencrypt/live/*/privkey.pem /etc/ssl/nginx/privkey.pem; chmod -h 400  /usr/local/etc/letsencrypt/live/*/privkey.pem /etc/ssl/nginx/privkey.pem

Add auto renewals to crontab

We will be checking if the renewal is needed every 6 hours to minimize the downtime in case the certificate would get revoked.

mkdir -p /usr/local/www/well-known && chmod 600 /usr/local/www/well-known
echo '15 */6 * * * root certbot certonly --webroot -w /usr/local/www/well-known -d yourdomain.com -n' >> /etc/crontab

 

Configure nginx

First, let’s create required directories

mkdir -p /etc/ssl/nginx /usr/local/etc/nginx/conf.d /var/run/modsecurity
chmod 700 /etc/ssl/nginx 
chmod 600 /usr/local/etc/nginx/conf.d /var/run/modsecurity

Next, edit nginx.conf and add required entries:

vi /usr/local/etc/nginx/nginx.conf

Somewhere near the top of the file, there are two lines:

#user  nobody;
worker_processes  1;

Change them to:

user  mysite;
worker_processes  2;

Find the last line that simply says

}

and add three lines above, making it:

  client_max_body_size  32m;
  server_tokens         off;
  include               conf.d/*;
}

First line sets the max body size to the size of max POST in PHP, second one tells nginx to not include it’s own version in HTTP headers – we are doing the same with PHP that is by default adding a HTTP header. In this case, server will send “Server: nginx” header instead of “Server: nginx/1.14.0”.
Now let’s create separate files for SSL/TLS, compression configuration, and our site’s config with server{} directives. First the ssl.conf:

vi /usr/local/etc/nginx/ssl.conf
# SSL configuration
# SSL certificate file should include website's certificate along with
# intermediate certificates (full chain). fullchain.pem file obtained
# from Let's Encrypt, includes chain certificate(s).
# If you add another site to your nginx, you will have to move
# ssl_certificate and ssl_certificate_key to server{} block of your site
# and create another certificate+key pair for your new site.
ssl_certificate            /etc/ssl/nginx/fullchain.pem;
ssl_certificate_key        /etc/ssl/nginx/privkey.pem;
ssl_dhparam                /etc/ssl/nginx/dhparam.pem;
ssl_session_timeout        10m;
ssl_session_cache          shared:SSL:10m;
ssl_session_tickets        off;

# Configuration for modern and a little dated browsers
# Will not work with very old browsers
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-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';

# OCSP stapling - https://en.wikipedia.org/wiki/OCSP_stapling
ssl_stapling               on;
ssl_stapling_verify        on;

# HSTS (6 months); beware - if there will be any problem with your
# SSL certificate, the site will probably stop working in all browsers
# that visited it in the last 6 months. With HTTP Strict Transport Security, 
# you _MUST_ keep your certificate valid!
# But as we are going full TLS, it's safe to leave HSTS header on.
# It is also required for A+ score on Qualys SSL Labs test
add_header                 Strict-Transport-Security max-age=15552000;

# config from https://malcont.net/2018/02/self-hosting-wordpress-securely-freebsd-nginx-php72-modsecurity-brotli-lets-encrypt-ssl/
# version 2018-02-14
#

File including gzip and brotli compression algorithms configuration:

vi /usr/local/etc/nginx/compression.conf
# brotli compression configuration
brotli			on;
brotli_static		on;
brotli_types		text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

# gzip compression configuration - browsers that do not support brotli
# will fall back to gzip
gzip			on;
gzip_types		text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

# config from https://malcont.net/2018/02/self-hosting-wordpress-securely-freebsd-nginx-php72-modsecurity-brotli-lets-encrypt-ssl/
# version 2018-02-14
#

Edit modsecurity.conf and enable the engine and logging. Open the file and put the following on top of it:

vi /usr/local/etc/nginx/modsecurity.conf
SecRuleEngine On
SecAuditLog /var/log/nginx/modsecurity.log
SecDataDir /var/run/modsecurity
SecDefaultAction "phase:1,deny,log"

And finally, our Secure WordPress site’s configuration file. Remember to change “yourdomain.com” to your valid host/domain name that is pointing to public IP of your secure WordPress server.
Also please be advised that this configuration is intended for WordPress site with no user accounts. You will have to remove wp-login.php from block if you want to enable user access.

vi /usr/local/etc/nginx/conf.d/wordpress.conf
# UNIX socket connection to PHP upstream
upstream php {
    server  unix:/var/run/php-wordpress.sock;
}

# HTTP (port 80) default server used only to redirect all requests to HTTPS version
server {
    # listening socket that will bind to port 80 on all available IPv4 addresses
    listen                     80 default_server;

    # listening socket that will bind to port 80 on all available IPv6 addresses
    listen                     [::]:80 default_server;

    # HTTP redirection to HTTPS
    return                     301 https://$host$request_uri;
}


# HTTPS (port 443) server - our website
server {
    # listening socket that will bind to port 443 on all available IPv4 addresses
    listen                     443 ssl;

    # listening socket that will bind to port 443 on all available IPv6 addresses
    listen                     [::]:443 ssl;

    # our SSL/TLS settings
    include                    ssl.conf;

    # brotli and gzip compression configuration
    include                    compression.conf;

    root                       /usr/local/www/wordpress;
    index                      index.php;

    # change this to your domain name (domain.com) or host name (blog.domain.com)
    server_name                yourdomain.com;   # CHANGE THIS!

    # handle weird requests in a rude manner
    if ($host != $server_name) {
         return                418 "I'm a teapot";
    }

    # DNS resolver - you may want to change it to some other provider,
    # e.g. OpenDNS: 208.67.222.222
    # or Google: 8.8.8.8
    # (9.9.9.9 is https://quad9.net )
    resolver                   9.9.9.9;

    # allow POSTs to static pages
    error_page                 405    =200 $uri;

    access_log                 /var/log/nginx/yourdomain.com-access.log;
    error_log                  /var/log/nginx/yourdomain.com-error.log;

    location / {
        ModSecurityEnabled     on;
        ModSecurityConfig      modsecurity.conf;
        # permalinks
        try_files              $uri $uri/ /index.php?$args;
        location /wp-admin {
            ModSecurityEnabled          off;
            # admin-ajax.php and load-styles.php under wp-admin are allowed
            location ~ /wp-admin/(admin-ajax|load-styles)\.php {
                fastcgi_pass            php;
                include                 fastcgi.conf;
            }
            # scripts in wp-admin secured with additional HTTP-level password
            location ~* /wp-admin/.*\.php$ {
                auth_basic              "Restricted";
                auth_basic_user_file    /usr/local/etc/nginx/htpasswd;
                fastcgi_pass            php;
                include                 fastcgi.conf;
            }
        }
        # wp-login.php access restricted with HTTP password
        location ~* /wp-login\.php {
            auth_basic                  "Restricted";
            auth_basic_user_file        /usr/local/etc/nginx/htpasswd;
            fastcgi_pass                php;
            include                     fastcgi.conf;
            ModSecurityEnabled          off;
        }
        # deny access to xmlrpc.php which allows brute-forcing at a higher rates
        # than wp-login.php; this may break some functionality, like WordPress
        # iOS/Android app posting 
        location ~* /xmlrpc\.php {
            deny                        all;
        }
        # cache binary and SVG files for one month, don't log these requests
        location ~* \.(eot|gif|ico|jpg|png|jpeg|otf|pdf|swf|ttf|woff|woff2|mp4|svg)$ {
            expires                     1M;
            add_header                  Cache-Control public;
            access_log                  off;
        }
        # Let's Encrypt certificate updates
        location ~ /\.well-known/acme-challenge/ {
            allow                       all;
            root                        /usr/local/www/well-known;
            try_files                   $uri =404;
            break;
        }
        # don't log requests to robots.txt and ads.txt
        location ~ /(robots|ads)\.txt {
            allow                       all;
            log_not_found               off;
            access_log                  off;
        }
        # handle PHP scripts
        location ~ .php$ {
	    fastcgi_pass                php;
            fastcgi_index               index.php;
            include                     fastcgi.conf;
        }
    }
}
# config from https://malcont.net/2018/02/self-hosting-wordpress-securely-freebsd-nginx-php72-modsecurity-brotli-lets-encrypt-ssl/
# version 2018-02-14
#

Note that mod_security is turned off on wp-login.php and wp-admin, as we are restricting these scripts with additional password. But the truth is: these OWASP rules aren’t perfectly fit for current versions of WordPress and all plugins. Sometimes even a base WordPress script like wp-login.php can break without actually triggering any rules – it just timeouts with 408, or 5xx HTTP code. Other forms (posting, commenting) does not seem to be affected.
Just remember: if you happen to hit an unusual wall, some weird timeout appears, something is not working as supposed – after checking your nginx’ access and error logs – try to disable mod_security for the script in question… Again: there are some paid rules for WordPress sites.

Create an additional username and password to further secure WordPress by protecting the dashboard and login page. This command creates a user called “access” and will ask you for the password twice:

sh -c 'printf "access:`openssl passwd -apr1`\n"' >> /usr/local/etc/nginx/htpasswd
Password:
Verifying - Password:
chmod 600 /usr/local/etc/nginx/htpasswd
chown mysite:mysite /usr/local/etc/nginx/htpasswd

You will be asked for these credentials when accessing /wp-admin directory of your site. This will also stop brute-forcing bots from accessing login page and burdening PHP engine unnecessarily.
Additional password (htpasswd) protecting wp-login.php and wp-admin pages

Start your nginx

If Diffie-Hellman parameters file (dhparam) has already been generated in your other ssh window, you should finally be able to start your nginx. For the time of installation and configuration, you might want to restrict access to ports 80 and 443 of your server to only allow connections from your IP address.

service nginx start
Performing sanity check on nginx configuration:
nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok
nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful
Starting nginx.

In case of any error arising here, nginx should point you to where the problem is.

WordPress installation and configuration

With PHP and nginx working, you should now be able to visit your site via the browser and finish WordPress installation. Head on to https://yourdomain.com/ and you will be redirected to the installation directory.
When you are asked to provide database information, type in the following:

  • Database name: wordpressdb
  • Username: wordpressuser
  • Password: [user password generated during MariaDB configuration]
  • Database Host: localhost:/tmp/mysql.sock
  • Table Prefix: blah_

Change table prefix from “wp_” to something else – like “blah_” – as an additional protection against SQL injection attacks.

WordPress database configuration with UNIX socket
Note that as the MySQL hostname, we are not only pointing to localhost, but also to a UNIX socket. Connecting this way instead of simply using “localhost” will discard the TCP overhead and slightly increase performance.

You will also be asked to create WordPress admin user. This is a in-CMS username that is stored in the database. It is imperative that you do not use some obvious username like “admin”. I suggest using alphanumeric username that pretty much looks like a password:

WordPress installation - use some weird string as the admin name
Use the admin account to manage plugins, themes and do administrative things, but never post from the admin account. Create additional account with “Editor” or “Author” role and write all your posts as this user.
If your WordPress site hangs during installation or login process – you probably do not have enough RAM. Check the top -b | grep ^Mem\: command and try to determine if there is enough “Wired”, “Inact” or “Free” memory. You can try to disable ModSecurity in /usr/local/etc/nginx/conf.d/wordpress.conf or disable OPcache in /usr/local/etc/php.ini. This shouldn’t be a case on systems with 2+ GB RAM.

Congratulations, your Secure WordPress site should now be up and you should have access to the dashboard.
WordPress dashboard

Clean up

We have installed some things that won’t be needed for your WordPress site to work, and we can now delete them. Beware however that these packages will be needed for nginx rebuilds:

pkg delete -f autoconf\* automake\* apache24\*

 

Test your site

Qualys SSL Labs test is the best and most comprehensive website SSL test available online. At the moment of finishing this write-up, the above configuration results in a beautiful A+ score:
Qualys SSL Labs test - our configuration scores A+

Use your browser Inspector to determine the compression algorithm the server used to send files to your browser. You will have to enable “Content-Encoding” column under “Network” tab and after refreshing the site, you should see “br” encoding next to text files. “br” indicates brotli compression.
Firefox Quantum Inspector shows all text files transferred with brotli compression

HTTP version of your site should be redirecting all requests to HTTPS version:

curl -I --silent http://yourdomain.com/test.txt | grep -i ^location
Location: https://yourdomain.com/test.txt

You should get HTTP code 200 (OK) on HTTPS version of your site:

curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/
200

You should get code 401 (Unauthorized) on wp-admin/, wp-admin/install.php and wp-login.php:

curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/wp-admin/
401
curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/wp-admin/install.php
401
curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/wp-login.php
401

wp-admin/admin-ajax.php should return HTTP code 400 (Bad Request):

curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/wp-admin/admin-ajax.php
400

xmlrpc.php request should result in code 403 (Forbidden):

curl -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/xmlrpc.php
403

Also check if your openssl-generated apr1 htpassword is working:

curl -u access -ksw '%{http_code}\n' -o /dev/null https://yourdomain.com/wp-login.php
Enter host password for user 'access':
200

And finally test mod_security. You should get 403 HTTP code with this request:

curl -ks -o /dev/null -w '%{http_code}\n' 'https://yourdomain.com/?whatever=../../etc/passwd'
403

 

Additional WordPress security tips

  • Try to use as few plugins as possible. Every addition to your WordPress may result in loss of performance and higher loading times
  • Carefully choose theme and plugins for your site. If you intend to handle private data (WooCommerce shop customer’s information, etc), it is imperative that you choose secure plugins and, if possible, hire a knowledgeable PHP coder who can audit the code. I will be blunt: huge amount of plugins available on WordPress.com are just bad code. Really bad code.
  • Remove unused plugins. Even if you disable a plugin in the dashboard, it’s files may still be accessible
  • Keep WordPress updated. WordPress is not free of bugs but they are being fixed quite often. You should always promptly update to new versions
  • Regularly backup your WordPress files and database. You probably can exclude wp-content/uploads directory from daily backups (but backup it from time to time!), but backup everything else often. Either rsync WordPress files and database dumps to another machine, or use service like Tarsnap.
  • Use strong passwords. Even with additional HTTP password – protected login page and dashboard, weird-up the shit out of your passwords. 13 characters minimum, the more random the better
  • Track changes to your files. You should monitor changes to your files, most importantly, all PHP scripts
  • Manually check your SSL certificate. Let’s Encrypt’s certificates are only issued for 90 days and require renewal in the last month. Create an entry in your calendar or reminders to check the certificate in the next 60+ days.
  • Disable file editing. This will prevent an attacker with dashboard access from editing files. Edit your wp-config.php file and the following at the end:
    define( 'DISALLOW_FILE_EDIT', true );
  • Consider using some captcha plugin to secure your forms from bots
  • In case of having user accounts and not securing wp-login.php script, consider using a plugin that will limit user login attemtps
  • Rembeber to cut access to your server to a minimum. Restrict ssh on the firewall to only allow connections from your IP address. If you have dynamic IP, consider setting openssh to listen on some random high port, like 45678. Disable all other ports, but 80 (http) and 443 (https)
  • Consider using remote SMTP server for sending e-mails from your WordPress. VPS IP address spaces often have bad e-mail reputation and it is not advised to send e-mails directly from WordPress, using mail() function. Create a SMTP account for your WordPress and send authorized e-mails through it
  • Once again: Only use admin account for administrative operations. Create additional account with “Editor” or “Author” role and write all your posts as this user
  • Remove *txt files (but robots.txt and other wanted ones) and *version* files from WordPress directory to prevent WordPress scanning tools from version sniffing

 

7 thoughts on “Self hosting WordPress securely in 2018 on FreeBSD with nginx, PHP 7.2, ModSecurity, brotli, Let’s Encrypt SSL

  1. Hey,

    Great post ! I’m trying to install nginx + mod_security v3 from ports. Everything “works” but logs are always empty and im wondering if u would like to try install nginx+mod_security v3 from FreeBSD ports.
    Thank you,

      1. Hey,
        I resolved. Logs were empty because v3 works a little bit different then v2 and some changes like log level are needed.
        I have installed nginx+modsec v3 from ports. It works great. I think it’s much better solution then manually compilation. The advantage is also an update with ports. I really recommend using v3. There are many rules fixed and false positive.
        Bye,

  2. Thank you for typing this up. I got all the way to the Certbot install, then I started having issues.

    Like you, I started with a fresh deploy of FreeBSD on a t2.small Amazon Web Services EC2 instance. It did not have any version of Python installed and I saw your warning about the different versions. I first tried running “pkg install python36”, which downloaded python3 and all dependencies, but when i ran “py36-certbot” afterwards, it bombed and complained about python36 not being my default python version. so I uninstalled python36 and installed python27 but the py27-certbot did the same thing. I spent about an hour trying to figure out how to set the default python version then gave up. Read a few forum posts that mentioned /etc/make.conf, but that file didn’t exist.

    Anyway, any ideas let me know. I would like to take another crack at this soon.

    1. I’ve just spinned up a t2.small with Colin Percival’s FreeBSD 11 from Marketplace and “pkg info|grep ^py” shows that there is Python 2.7 installed:
      pkg info|grep ^python
      python27-2.7.14_1 Interpreted object-oriented programming language

      “pkg install py27-certbot” works.

  3. Thank you very much for this awesome tutorial! I did this on a Raspberry Pi B+ successfully (skipped certbot part, just using a self signed certificate), the only problem is the bad performance on FreeBSD armv6.😂
    This is really a best practise for setting a WordPress server. I will try to do it on Raspbian later.

  4. Hey,

    How have you done with wordpress and php7.2
    After installing everything i go to site and getting that error:

    Warning: Use of undefined constant DB_USER – assumed ‘DB_USER’ (this will throw an Error in a future version of PHP) in /home/proton/domains/proton.edu.pl/public_html/wordpress/wp-includes/load.php on line 404

    Warning: Use of undefined constant DB_PASSWORD – assumed ‘DB_PASSWORD’ (this will throw an Error in a future version of PHP) in /home/proton/domains/proton.edu.pl/public_html/wordpress/wp-includes/load.php on line 404

    Warning: Use of undefined constant DB_NAME – assumed ‘DB_NAME’ (this will throw an Error in a future version of PHP) in /home/proton/domains/proton.edu.pl/public_html/wordpress/wp-includes/load.php on line 404

    Warning: Use of undefined constant DB_HOST – assumed ‘DB_HOST’ (this will throw an Error in a future version of PHP) in /home/proton/domains/proton.edu.pl/public_html/wordpress/wp-includes/load.php on line 404

Leave a Reply

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

Time limit is exhausted. Please reload the CAPTCHA.