My Beautifully Fast WordPress Webserver

A step-by-step guide explaining how to install, securely configure and performance-tune WordPress on Apache on Linux. Please also read this follow-up article explaining how to upgrade to Ubuntu 16.04 and PHP 7.

The web is all about speed. If your site is slow, you lose visitors. And administering it is not much fun. In order to get to a fast server, you need to control the hardware. If you currently are on any kind of virtual server or even on shared hosting, in other words on anything where someone other than you is controlling resource allocation, your chances at a fast server are based solely on luck. For that reason I decided to get my own Hyper-V server in the cloud.

To Virtualize or Not

There is, of course, nothing wrong with (web server) virtualization – as long as you do not overcommit resources. Hosting providers, however, tend to do that, which explains why performance is often less than optimal. To illustrate that point compare the system I am describing here with my previous setup, a managed virtual server at a good (and not inexpensive) hosting provider:

Time spent downloading a page - new and old server - Google Webmasters Tools

The graphic shows the time spent downloading a page and is taken from an authoritative source: Google Webmaster Tools.

Windows – or Linux?

Being at home more in the Windows than in the Linux world my initial plan was to deploy Server 2012 R2 in the webserver VM. That was until I learned that my personal killer application mod_pagespeed was not available for Windows.

Another thing that weighed strongly in favor of Windows: if you want a software to just work, use it the way its developers intended it to, in its natural habitat. Do not try to be clever. Be pragmatic. Exotic configurations are tested less thoroughly – if at all – and finding help on the internet is a lot easier if there is more than a single person on this planet with your exact configuration.

So Linux it was. As for the distribution, I decided on Ubuntu 14.04 Server LTS. It comes with long-term support (hence the acronym LTS) and is fully supported even on generation 2 Hyper-V VMs without the need to install integration components. Just make sure to disable secure boot (see below).

Installing Ubuntu in a Generation 2 Hyper-V Virtual Machine

Downloading Without a Browser

Download the ISO through PowerShell (IE is not available in minimal interface mode):

$client = new-object system.net.webclient
$client.DownloadFile("http://releases.ubuntu.com/14.04/ubuntu-14.04.1-server-amd64.iso", "ubuntu-14.04.1-server-amd64.iso")

Creating the VM

  • Create a new generation 2 VM
  • Enable Dynamic Memory
  • Disable Secure Boot
  • Assign the MAC-Address obtained from Hetzner to the VM’s NIC

I have assigned 6 vCPUs and 8 GB RAM to the VM. That seems to be more than enough for the time being: CPU usage is well below 5% and RAM usage around 1.5 GB.

Installing Ubuntu

  • Run the installer
  • Select the partitioning scheme Guided – use entire disk
  • Reboot after the installer finishes and log in with the account you specified during installation

To simplify management install Midnight Commander, a Norton Commander clone:

sudo apt-get install mc

Set up time synchronization:

sudo apt-get install ntp

Install OpenSSH for remote management and SFTP:

sudo apt-get install openssh-server

Securing Ubuntu

Change the SSH port by editing /etc/ssh/sshd_config:

Port YOUR-SSH-PORT
sudo service ssh reload

Create an SSH keypair on your PC. On Windows that can easily be done with Puttygen. Secure the private key with a passphrase and store it locally. The public key goes to the file ~/.ssh/authorized_keys which needs to be created:

cd ~
mkdir .ssh
chmod 700 .ssh
nano .ssh/authorized_keys
# Paste the public key into the file "authorized_keys". OpenSSH expects the entire key to be on one line

After testing public key authentication disable password authentication by editing /etc/ssh/sshd_config:

# Replace the default "yes" with "no"
PasswordAuthentication no
sudo service ssh reload

Enable the firewall, allowing only SSH traffic:

sudo ufw allow YOUR-SSH-PORT/tcp
sudo ufw logging on
sudo ufw enable
sudo ufw status

Rate-limit SSHd, allowing only 5 connections per IP address in any 30 second interval:

sudo ufw limit YOUR-SSH-PORT/tcp

IP hardening, uncomment the following lines in /etc/sysctl.conf:

net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

Reload:

sudo sysctl -p

Install logwatch to get regular email reports distilled from the server’s log files. Change the report frequency from daily to weekly:

sudo apt-get install logwatch
mv /etc/cron.daily/00logwatch /etc/cron.weekly/

Edit /etc/cron.weekly/00logwatch, changing the logwatch call so that you are emailed instead of root, HTML is used instead of text and the date range processed is one week instead of a day:

/usr/sbin/logwatch --mailto ADDRESS@DOMAIN.com --format html --range 'between -7 days and -1 days'

E-Mail

Install Sendmail to enable your server applications to send e-mail:

sudo apt-get install sendmail

Configure a reverse DNS entry in Hetzner’s robot so that it points to something meaningful like www.yourserver.com.

Installing and Hardening Apache

Installing LAMP

Install LAMP (Apache, MySQL, PHP):

sudo tasksel install lamp-server

Install additional Apache and PHP modules:

sudo a2enmod rewrite
sudo apt-get install php5-gd
sudo apt-get install php5-curl
sudo service apache2 restart

Hardening PHP

Add the following to disable_functions in etc/php5/apache2/php.ini: exec, system, shell_exec, passthrough

Tuning PHP

PHP’s OPCache is enabled by default, but we can assign more RAM than it does by default. Modify the following lines in the section [opcache] in etc/php5/apache2/php.ini:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=10
opcache.max_accelerated_files=10000

Then reload Apache’s configuration:

sudo service apache2 reload

Hardening Apache

Enable HTTP and HTTPS in the firewall:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Edit /etc/apache2/conf-enabled/security.conf to send only minimal information about the server:

ServerTokens Prod
ServerSignature Off
TraceEnable Off

Restart Apache:

sudo service apache2 restart
Mod_security?

Mod_security is a powerful application firewall for Apache. As with all firewalls its usefulness depends entirely on the quality of the rulesets available. The one free quality ruleset, OWASP ModSecurity Core, does not work correctly with WordPress. Two companies I am aware of sell rulesets and claim full WordPress compatibility but those are expensive. So no mod_security for me.

Mod_evasive

Mod_evasive is a kind of rate limiter helping against DDoS attacks.

apt-get install libapache2-mod-evasive
sudo mkdir /var/log/apache2/mod_evasive
sudo chown www-data:www-data /var/log/apache2/mod_evasive/

Edit the configuration file /etc/apache2/mods-enabled/evasive.conf so that it looks like this:

<IfModule mod_evasive20.c>
   DOSHashTableSize    3097
   DOSPageCount        2
   DOSSiteCount        50
   DOSPageInterval     1
   DOSSiteInterval     1
   DOSBlockingPeriod   10
 
   DOSEmailNotify      YOUR-E-MAIL-ADDRESS
   #DOSSystemCommand    "su - someuser -c '/sbin/... %s ...'"
   DOSLogDir           "/var/log/apache2/mod_evasive"
</IfModule>

Restart Apache:

sudo service apache2 restart

Configuring Apache

Apache Virtual Host Configuration

Delete the default site:

sudo rm /etc/apache2/sites-enabled/000-default.conf

Create a new site configuration (replace helgeklein.com with your domain name):

cd /etc/apache2/sites-available
sudo nano helgeklein.com.conf

Paste the following into the file helgeklein.com.conf:

<VirtualHost *:80>
   ServerName helgeklein.com
   ServerAlias www.helgeklein.com
   DocumentRoot /var/www/helgeklein.com/public_html
   DirectoryIndex index.php index.html
   <Directory /var/www/helgeklein.com/>
      AllowOverride All
      Require all granted
      Options -Indexes
   </Directory>
</VirtualHost>

Enable the new Apache site helgeklein.com.conf:

sudo a2ensite helgeklein.com.conf
sudo service apache2 restart

Website Directory Structure and Filesystem Permissions

Create the directory structure in the file system:

sudo mkdir -p /var/www/helgeklein.com/public_html

Add your account to the webserver’s group (replace helge with your user name):

sudo usermod -a -G www-data helge

Set the directory’s ownership:

sudo chown -R helge:www-data /var/www

Set permissions on files and directories:

find /var/www -type d -print0 | sudo xargs -0 chmod 775
find /var/www -type f -print0 | sudo xargs -0 chmod 664
find /var/www -type f -name 'wp-config.php' -print0 | sudo xargs -0 chmod 640

Enable SSL

Add the following to the virtual host configuration file helgeklein.com.conf:

<IfModule mod_ssl.c>
   <VirtualHost *:443>
   ServerName helgeklein.com
   ServerAlias www.helgeklein.com
   DocumentRoot /var/www/helgeklein.com/public_html
   DirectoryIndex index.php index.html
   <Directory /var/www/helgeklein.com/>
      AllowOverride All
      Require all granted
      Options -Indexes
   </Directory>
 
   SSLEngine on
 
   SSLCertificateFile   /etc/ssl/certs/www_helgeklein_com.crt
   SSLCertificateKeyFile /etc/ssl/private/www_helgeklein_com.key
   SSLCertificateChainFile /etc/apache2/ssl.crt/DigiCertCA.crt
 
   SSLProtocol             all -SSLv2 -SSLv3
   SSLCipherSuite          ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-G$
   SSLHonorCipherOrder     on
   SSLCompression          off
 
   <FilesMatch "\.(cgi|shtml|phtml|php)$">
      SSLOptions +StdEnvVars
   </FilesMatch>
      BrowserMatch "MSIE [2-6]" nokeepalive ssl-unclean-shutdown downgrade-1.0 force-response-1.0
      # MSIE 7 and newer should be able to use keepalive
      BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown
   </VirtualHost>
</IfModule>

Add the following to your main Apache configuration file /etc/apache2/apache2.conf:

<IfModule mod_ssl.c>
   # OCSP Stapling, only in httpd 2.3.3 and later
   SSLUseStapling                   on
   SSLStaplingResponderTimeout      5
   SSLStaplingReturnResponderErrors off
   SSLStaplingCache                 shmcb:/var/run/ocsp(128000)
</IfModule>

Enable the SSL module:

sudo a2enmod ssl
sudo service apache2 restart

Cache-Control Header

Set a cache-control header for static resources. Add the following to /etc/apache2/apache2.conf:

<FilesMatch "\.(ico|jpg|jpeg|gif|png|js|css)$">
   Header set Cache-control "public, max-age=2678400"
</FilesMatch>

Security Headers

Add the following to your Apache configuration file /etc/apache2/conf-enabled/security.conf:

# Prevent MSIE from interpreting files as something else than declared by the content type in the HTTP headers.
# Requires mod_headers to be enabled.
Header set X-Content-Type-Options: "nosniff"
 
# Prevent other sites from embedding pages from this site as frames. This defends against clickjacking attacks.
# Requires mod_headers to be enabled.
Header set X-Frame-Options: "sameorigin"
 
# Block pages from loading when they detect reflected XSS attacks
# Requires mod_headers to be enabled.
Header set X-XSS-Protection: "1; mode=block"
 
# Pre-existing site uses too much inline code to fix, but wants to ensure resources are loaded only over https
# Requires mod_headers to be enabled.
Header set Content-Security-Policy: "default-src https:; font-src https: data:; img-src https: data: 'self' about:; script-src 'unsafe-inline' 'unsafe-eval' https: data:; style-src 'unsafe-inline' https:;"
 
# Only connect to this site and subdomains via HTTPS for the next year and also include in the preload list
# Requires mod_headers to be enabled.
Header set Strict-Transport-Security: "max-age=31536000; includeSubDomains; preload"

Log Rotation

Change the default log rotation so that it keeps 30 daily logs instead of 52 weekly ones. Edit /etc/logrotate.d/apache2 so that it looks like this:

/var/log/apache2/*.log {
   daily
   missingok
   rotate 30
   compress
   delaycompress
   dateext
   notifempty
   create 640 root adm
   sharedscripts
   postrotate
   if /etc/init.d/apache2 status > /dev/null ; then \
      /etc/init.d/apache2 reload > /dev/null; \
   fi;
   endscript
   prerotate
      if [ -d /etc/logrotate.d/httpd-prerotate ]; then \
         run-parts /etc/logrotate.d/httpd-prerotate; \
      fi; \
   endscript
}

Configuring MySQL

Securely initialize MySQL:

sudo mysql_install_db
sudo mysql_secure_installation

Set the collation to UTF-8 by adding the following to /etc/mysql/my.cnf:

[mysqld]
collation-server = utf8_general_ci
init-connect='SET NAMES utf8'
character-set-server = utf8

Set the minimum length of words to be indexed to three (default: four) to enable searches for terms like “PDF” in UseResponse.

[mysqld]
ft_min_word_len = 3

Restart MySQL:

sudo /etc/init.d/mysql stop
sudo /etc/init.d/mysql start

Migrating WordPress

This guide assumes that you already have WordPress running on another server and want to transfer it without modification, keeping the domain name.

Create the database for WordPress:

mysql -u root -p
mysql> create database wordpress;
mysql> grant all privileges on wordpress.* to "wordpress"@"localhost" identified by "PASSWORD";
mysql> flush privileges;
mysql> exit

Import into the WordPress database from an SQL dump file dump.sql (created on your old site):

mysql -u root -p
mysql> use wordpress
mysql> source dump.sql
mysql> exit

Copy all the files in the public_html folder (or similar) from the old server to the new server. I did that by creating a backup on the old server and transferring that to the new server.

Edit wp-config.php and update database name, user and password.

If you get this error when WordPress needs to write to the file system: “To perform the requested action, WordPress needs to access to your web server. Please enter your FTP credentials to proceed. If you do not remember your credentials, you should contact your web host” add the following to wp-config.php:

define('FS_METHOD','direct');

Hardening WordPress

Another layer of security for WordPress’ admin area. Additional basic authentication makes it harder to exhaust the server’s resources through many logon attempts in quick succession.

sudo apt-get install apache2-utils

Create a .htpasswd file for Apache authentication with a single user helge:

sudo htpasswd -c /etc/apache2/.htpasswd helge

Note: to add additional users, simply repeat above command without the -c parameter.

Create a .htaccess file in wp-admin with the following content:

AuthType Basic
AuthName "Please log on"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
 
<Files admin-ajax.php>
   Require all granted
   Satisfy any 
</Files>

Mod_pagespeed: Apache Performance Tuning

Tuning a website for speed can be done at different levels. Optimizing at the (web) server level has the benefit of affecting all sites and applications. Mod_pagespeed runs as an Apache module. HTML/JavaScript minification and image recompression are just some of its tricks, and it gets better with every release.

Installing Mod_pagespeed

sudo wget https://dl-ssl.google.com/dl/linux/direct/mod-pagespeed-stable_current_amd64.deb
sudo dpkg -i mod-pagespeed-*.deb
sudo apt-get -f install
rm mod-pagespeed-*.deb
sudo chown www-data:root /var/cache/mod_pagespeed/
sudo service apache2 restart

Monitoring Mod_pagespeed

Mod_pagespeed makes detailed statistics like the following available through the URL /pagespeed_admin:

Mod_pagespeed admin console

In order to be able to access the admin pages you need to configure /etc/apache2/mods-enabled/pagespeed.conf to allow access from your IP address. You also need to turn off the rewrite engine if you are using this in conjunction with WordPress:

<Location /pagespeed_admin>
   <IfModule mod_rewrite.c>
      RewriteEngine Off
   </IfModule>
   Require local
   Require ip YOUR-IP
   SetHandler pagespeed_admin
</Location>
<Location /pagespeed_global_admin>
   <IfModule mod_rewrite.c>
      RewriteEngine Off
   </IfModule>
   Require local
   Require ip YOUR-IP
   SetHandler pagespeed_global_admin
</Location>

Configuring Mod_pagespeed

The default rules (called filters) are just fine and they are updated whenever mod_pagespeed is updated, so in theory your site should get faster over time. The following configuration changes need to be made by editing the file /etc/apache2/mods-enabled/pagespeed.conf.

Enable rewriting of resources that have Cache-Control: no-transform set:

ModPagespeedDisableRewriteOnNoTransform off

Increase the size of the disk cache from 100 MB (default) to 10 GB:

ModPagespeedFileCacheSizeKb          10240000

Enable fetching via HTTPS:

ModPagespeedFetchHttps enable

Enable additional filters:

ModPagespeedEnableFilters collapse_whitespace,lazyload_images

Exclude Akismet directories or we get Apache error log entries because of .htaccess and mod_pagespeed:

ModPagespeedDisallow "*/wp-content/plugins/akismet/*"

Access all static files directly instead via http(s). The further down a rule the higher its precedence. One entry per Apache virtual server maps that server’s base URL into the corresponding file system path. Notice how different paths can be specified for specific subdirectories: in older WordPress multisite installations a site’s files virtual directory may map to a subdirectory of blogs.dir.

    ModPagespeedLoadFromFile https://uberagent.com/ /var/www/uberagent.com/public_html/
    ModPagespeedLoadFromFile https://helgeklein.com/ /var/www/helgeklein.com/public_html/
    ModPagespeedLoadFromFile https://vastlimits.com/ /var/www/vastlimits.com/public_html/
    ModPagespeedLoadFromFile https://vcnrw.de/ /var/www/vcnrw.de/public_html/
    ModPagespeedLoadFromFileRuleMatch disallow .*$
    ModPagespeedLoadFromFileRuleMatch allow \.css$
    ModPagespeedLoadFromFileRuleMatch allow \.js$
    ModPagespeedLoadFromFileRuleMatch allow \.gif$
    ModPagespeedLoadFromFileRuleMatch allow \.png$
    ModPagespeedLoadFromFileRuleMatch allow \.jpg$

Turning Dynamic Into Static Pages

Even with mod_pagespeed’s optimizations our webpages are still built dynamically for every single visitor every single time. That is far from being efficient and significantly reduces the response time. To further enhance page load performance we need to create static pages that can be delivered without executing PHP code. The difficulty lies in the integration with WordPress: whenever you change a page, post or some backend setting the cache needs to be (partly) invalidated. Also the caching method must play nicely with mod_pagespeed.

One product that looks very promising is Varnish, a reverse caching proxy. Unfortunately it does not support SSL, and mod_pagespeed support for Varnish is still experimental. An easier and very well-tested solution is the WordPress plugin W3 Total Cache. W3TC can do many things that are already covered by mod_pagespeed – we just need its ability to create static HTML. On W3TC’s admin page general settings make sure to enable the page cache and disable all other optimizations, notably minify, database cache, object cache, browser cache, CDN and reverse proxy.

Support & Operations

Patching Ubuntu

sudo apt-get update
sudo apt-get dist-upgrade
sudo apt-get autoremove
sudo shutdown -r now

Restricted SFTP Access for Support Users

When you need support for your WordPress theme or a similar web application the vendor may request access to your installation. In such a case you may want to create an account with limited access. The following instructions show how to create a user support-account with access to only what is explicitly mounted in that user’s directory.

Add the following to /etc/ssh/sshd_config:

Match User support-account
   ChrootDirectory /var/sftp/support-account
   AllowTCPForwarding no
   X11Forwarding no
   ForceCommand internal-sftp
   PasswordAuthentication yes

Please note that all directories in the path /var/sftp/support-account need to be user/group owned by root.

# Create a new user without home directory
sudo adduser --no-create-home support-account
 
# Deny interactive login
sudo usermod -s /bin/false
 
# Create the directory we mount to below
sudo mkdir -p /var/sftp/support-account/public_html
 
# Add mount information to /etc/fstab (use your favorite editor)
/var/www/helgeklein.com/public_html/  /var/sftp/support-account/public_html  none  bind  0 0
 
# Mount everything in fstab:
sudo mount -a
 
# Reload the SSH service
sudo service ssh reload

, , , , ,

8 Responses to My Beautifully Fast WordPress Webserver

  1. andre November 17, 2014 at 09:59 #

    hi,
    can you explain why need to disable database cache, object cache, and cdn?

  2. Dave Hilditch December 12, 2014 at 16:35 #

    Hi – wondering why you went with Apache rather than Nginx on a brand new install? Have you managed to figure out a way to make Apache scale nicely and quickly?

    • Helge Klein December 12, 2014 at 17:15 #

      While putting together this configuration I researched many products and plugins. Not all of them were compatible with Nginx, so I went for Apache instead. Also I already had some Apache configuration knowledge.

  3. Roberto December 18, 2014 at 21:16 #

    Thanks! Very helpful. I am running Apache on my dedicated server with CentOS and would like some help. I would like to avoid using W3TC and I need to cache dynamic pages for logged in users because they are the ones that will be using the site most of the time.

  4. James March 10, 2015 at 07:46 #

    Helge, you legend :)
    Thanks a million for this, you have helped me so so much in setting up a few Google Cloud Compute instances, especially in the security aspects. Still trying to get my .htaccess file to work though, as soon as I edit the thing my site goes into a 500 error.
    For us noobs out there in SSH and server setups, this page is what we need.
    Cheers
    James

  5. Saddam Hossain May 27, 2015 at 16:16 #

    Thanks for the nice and useful article. Just finished installing and running my sites with mod_pagespeed. However, still need to Eliminate render-blocking JavaScript and CSS in above-the-fold content. PageSpeed score for my site still hasn’t improved yet. Hopefully, will resolve other issues soon.

  6. Kai Arzheimer October 9, 2015 at 16:02 #

    Helge,
    you are a star. I have not messed with an Apache configuration for over a decade, but with your tutorial, I have just moved my personal website from shared hosting to a cheap vserver without any glitches. Thanks!

  7. Hayden February 1, 2016 at 13:20 #

    Really cool explanation.

    I thought I was good until I implemented SSL with mod_pagespeed. What a nightmare. It was not re-writing my images. I believe ModPagespeedDisableRewriteOnNoTransform off fixed this, but I also added the load from file.

    Cheers.

Leave a Reply