Harden Nginx for WordPress websites

Nginx is the most popular web server alongside of Apache. Personally, I am a huge fan of Nginx. One important reason for this is that Nginx can easily be setup to become a live-stream server. Also, it makes a great reverse proxy that can greatly improve the performance of any website when properly set up. But it can, of course, also host websites. One of the most popular engines to build a website is WordPress. Generally speaking, a website will become slightly faster when it’s hosted on Nginx, but this really depends on how you set it up. A web server that is not properly setup may result in terrible performance. Naturally other factors play a role when it comes to the overall performance of a website, like for example the hardware that is used, and many articles have already been written on the subject. So I won’t go into that any further. In this post, I simply want to share some information that can help you to harden your Nginx when you are hosting WordPress sites. It’s really easy and simple to set it up.

Start by create a new configuration file, call whatever you want, and include the file in your already existing configuration for Nginx. What I did was create a new folder like: /etc/nginx/conf.wp, and in this folder I created a new file like: /etc/nginx/conf.wp/harden-wordpress.conf. In this file, I placed the lines that are shown below. I proceeded to add a single line to all the vhost configuration files in /etc/nginx/sites-available/* that are used for a WordPress site like so:

include /etc/nginx/conf.wp/harden-wordpress.conf;

Save it and restart Nginx. Don’t forget to test it by using the nginx -t command first. And if all is good, you can safely restart Nginx. That’s all basically! See how easy it is? I do want to mention that it would be wise to go through the file first yourself, so you will be aware of the changes that are being made. In case some issue may appear on your website, make sure to check if the cause may be found in the new config file. I haven’t run into any issues using this config, on a handful of WordPress sites, and even WordPress Multi-sites work fine with this config.

The content for the config file is shown below, and it is also available at a github gist I made that you can find here: https://gist.github.com/ustoopia/2a54d06ab990597d139d6eb8a327e470

Please let me know if you believe it needs certain changes or improvements in the comments.

############ WordPress ####################

# Disable logging for favicon and robots.txt
location = /favicon.ico {
  try_files /favicon.ico @empty;
  access_log off;
  log_not_found off;
  expires max;
}
location = /robots.txt {
    allow all;
    log_not_found off;
    access_log off;
    try_files $uri /index.php?$args;
}

#Deny access to wp-content folders for suspicious files
location ~* ^/(wp-content)/(.*?)\.(zip|gz|tar|bzip2|7z)$ {
  deny all;
}

location ~ ^/wp-content/uploads/sucuri {
  deny all;
}

location ~ ^/wp-content/updraft {
  deny all;
}

#Disable execution of scripts other than PHP from your document root
location ~* .(pl|cgi|py|sh|lua|asp)$ {
   return 444;
}

#Disable access to your configuration files and other files that you don't want to users are able to see
location ~* /(wp-config.php|readme.html|license.txt|nginx.conf) {
   deny all;
}

# Disable wp-config.txt
location = /wp-config.txt {
    deny all;
    access_log off;
    log_not_found off;
}

# Disallow php in upload folder and add webp rewrite
location /wp-content/uploads/ {
    location ~ \.php$ {
    #Prevent Direct Access Of PHP Files From Web Browsers
        deny all;
    }
    # webp rewrite rules
    location ~ \.(png|jpe?g)$ {
        add_header Vary "Accept-Encoding";
        add_header "Access-Control-Allow-Origin" "*";
        add_header Cache-Control "public, no-transform";
        access_log off;
        log_not_found off;
        expires max;
        try_files $uri  $uri =404;
    }
}

# nginx block xmlrpc.php requests
location /xmlrpc.php {
  deny all;
  access_log off;
  log_not_found off;
  return 444;
}

# nginx block wpscann on plugins folder
location ~* ^/wp-content/plugins/.+\.(txt|log|md)$ {
  deny all;
  error_page 403 =404 / ;
}

# block access to install.php and upgrade.php
location ^~ /wp-admin/install.php {
  deny all;
  error_page 403 =404 / ;
}

location ^~ /wp-admin/upgrade.php {
  deny all;
  error_page 403 =404 / ;
}

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

# Stop scann for the follow files on plugins folder
location ~* ^/wp-content/plugins/.+\.(txt|log|md)$ {
      deny all;
      error_page 403 =404 / ;
}

# Stop scann for the follow files on themes folder
location ~* ^/wp-content/themes/.+\.(txt|log|md)$ {
      deny all;
      error_page 403 =404 / ;
}

#Direct PHP File Access
#If somehow, a hacker successfully sneaks in a PHP file onto your site,
#they'll be able to run this file by loading file which effectively becomes a backdoor to infiltrate your site.
#We should disable direct access to any PHP files by adding the following rules:
location ~* /(?:uploads|files|wp-content|wp-includes|akismet)/.*.php$ {
    deny all;
    access_log off;
    log_not_found off;
}

#Dotfiles
#Similar to PHP file, a dotfile like .htaccess, .user.ini, and .git may contain sensitive information.
#To be on the safer side, it's better to disable direct access to these files.
location ~ /\.(svn|git)/* {
    deny all;
    access_log off;
    log_not_found off;
}
location ~ /\.ht {
    deny all;
    access_log off;
    log_not_found off;
}
location ~ /\.user.ini {
    deny all;
    access_log off;
    log_not_found off;
}


# Deny access to uploads that aren't images, videos, music, etc.
location ~* ^/wp-content/uploads/.*.(html|htm|shtml|php|js|swf)$ {
    deny all;
}


# Deny backup extensions & log files
location ~* ^.+\.(bak|log|old|orig|original|php#|php~|php_bak|save|swo|swp|sql)$ {
  deny all;
  access_log off;
  log_not_found off;
}


#WordFence
location ~ \.user\.ini$ {
 deny all;
}


# WordPress: deny wp-content, wp-includes php files
location ~* ^/(?:wp-content|wp-includes)/.*\.php$ {
        deny all;
}

# WordPress: deny wp-content/uploads nasty stuff
location ~* ^/wp-content/uploads/.*\.(?:s?html?|php|js|swf)$ {
        deny all;
}

# WordPress: deny general stuff
location ~* ^/(?:xmlrpc\.php|wp-links-opml\.php|wp-config\.php|wp-config-sample\.php|wp-comments-post\.php|readme\.html|license\.txt)$ {
        deny all;
}


# NGINX RESTRICTIONS

# Directives to send expires headers and turn off 404 error logging.
location ~* ^.+\.(curl|heic|swf|tiff|rss|atom|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
    access_log off;
    log_not_found off;
    expires max;
}

# Web fonts send expires headers
location ~* \.(?:eot|otf|ttf|woff|woff2)$ {
  expires max;
  access_log off;
  add_header Cache-Control "public";
}

# SVGs & MP4 WEBM send expires headers - this rule is set specific to ns site
location ~* \.(?:svg|svgz|mp4|webm)$ {
  expires max;
  access_log off;
  add_header Cache-Control "public";
}

# Media: images, icons, video, audio send expires headers.
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|aac|m4a|mp3|ogg|ogv|webp)$ {
  expires 1M;
  access_log off;
  add_header Cache-Control "public";
}

# Cache css & js files
location ~* \.(?:css(\.map)?|js(\.map)?)$ {
    add_header "Access-Control-Allow-Origin" "*";
    access_log off;
    log_not_found off;
    expires 30d;
}

# CSS and Javascript send expires headers.
location ~* \.(?:css|js)$ {
  expires 1y;
  access_log off;
  add_header Cache-Control "public";
}

# HTML send expires headers.
location ~* \.(html)$ {
  expires 7d;
  access_log off;
  add_header Cache-Control "public";
}

# Security settings for better privacy
# Deny hidden files
# Deny all attempts to access hidden files such as .htaccess, .htpasswd, .DS_Store (Mac).
location ~ /\. {
    deny all;
}

# Return 403 forbidden for readme.(txt|html) or license.(txt|html) or example.(txt|html) or other common git repository files
location ~*  "/(^$|readme|license|example|README|LEGALNOTICE|INSTALLATION|CHANGELOG)\.(txt|html|md)" {
    deny all;
}

# Deny backup extensions & log files and return 403 forbidden
location ~* "\.(old|orig|original|php#|php~|php_bak|save|swo|aspx?|tpl|sh|bash|bak?|cfg|cgi|dll|exe|git|hg|ini|jsp|log|mdb|out|sql|svn|swp|tar|rdf)$" {
    deny all;
}

# common nginx configuration to block sql injection and other attacks
location ~* "(eval\()" {
    deny all;
}
location ~* "(127\.0\.0\.1)" {
    deny all;
}
location ~* "([a-z0-9]{2000})" {
    deny all;
}
location ~* "(javascript\:)(.*)(\;)" {
    deny all;
}

location ~* "(base64_encode)(.*)(\()" {
    deny all;
}
location ~* "(GLOBALS|REQUEST)(=|\[|%)" {
    deny all;
}
location ~* "(<|%3C).*script.*(>|%3)" {
    deny all;
}
location ~ "(\\|\.\.\.|\.\./|~|`|<|>|\|)" {
    deny all;
}
location ~* "(boot\.ini|etc/passwd|self/environ)" {
    deny all;
}
location ~* "(thumbs?(_editor|open)?|tim(thumb)?)\.php" {
    deny all;
}
location ~* "(\'|\")(.*)(drop|insert|md5|select|union)" {
    deny all;
}
location ~* "(https?|ftp|php):/" {
    deny all;
}
location ~* "(=\\\'|=\\%27|/\\\'/?)\." {
    deny all;
}
location ~ "(\{0\}|\(/\(|\.\.\.|\+\+\+|\\\"\\\")" {
    deny all;
}
location ~ "(~|`|<|>|:|;|%|\\|\s|\{|\}|\[|\]|\|)" {
    deny all;
}
location ~* "/(=|$&|_mm|(wp-)?config\.|cgi-|etc/passwd|muieblack)" {
    deny all;
}

location ~* "(&pws=0|_vti_|\(null\)|\{$itemURL\}|echo(.*)kae|etc/passwd|eval\(|self/environ)" {
    deny all;
}
location ~* "/(^$|mobiquo|phpinfo|shell|sqlpatch|thumb|thumb_editor|thumbopen|timthumb|webshell|config|settings|configuration)\.php" {
    deny all;
}