CDN, TLS, and WordPress – Oh My!

This is a post that details how, after a great deal of research and frustration, I figured out how to perform whole-site WordPress acceleration and resiliency with a content delivery network (CDN), using SSL/TLS via Let’s Encrypt!.

I run a fair number of WordPress sites, some of which have more need for resiliency to brief outages or periods of unavailability, as well as the need to sustain increased demand at times.  There are a number of ways to do this, many of which have nuances and technical exclusions.  I experimented with a number of different options, each of which was unsuitable in their own unique ways.  Don’t @ me. 🙂

After more than a few rounds of trial and error, and much research, I selected KeyCDN.  Again, there are lots of other options, but KeyCDN hit all the various requirements for the project.  I’m not being compensated at all for mentioning them here.

  • Whole-site acceleration, not just static resources like images, stylesheets, javascript, etc.
  • TLS encryption using Let’s Encrypt! or something similarly automated
  • Content availability during network connectivity drops or brief origin server outages
  • WordPress needs to not puke through all of this

To be honest, the configuration was a lot more of a challenge than I’d expected.  I’ll detail what I did below, so if you’re in the same position, it’ll hopefully serve as a reasonable guide and “lessons learned” from my own research and testing.

KeyCDN has a nice guide that almost hit the mark, save for a few things:

I use Apache rather than Nginx, so I translated the supplied Nginx configuration to the following (not showing ServerName and related configuration directives):

SetEnvIf Request_Method "POST" NOCACHE=1
SetEnvIf Request_URI "^/(wp-login.php|wp-admin|login.php|backend|admin)" NOCACHE=1
SetEnvIfNoCase Cookie "PHPSESSID" NOCACHE=1
SetEnvIfNoCase Cookie "(wp-postpass|wordpress|comment_author)_" NOCACHE=1
RewriteCond %{QUERY_STRING} !^$
RewriteRule ^ - [E=NOCACHE:1]
Header set Cache-Control "no-cache" env=NOCACHE
Header set Cache-Control "max-age=2592000, stale-while-revalidate=86400, stale-if-error=604800" env=!NOCACHE

These determine what is and is not going to be cached by the CDN, specifically excluding HTTP POST method responses, logged in users, and anything with a query string.  The defaults will allow a maximum cache time of 30 days, allow one day of serving stale content while trying to revalidate, and seven days of serving stale content if there are server-side errors.  The latter two values allow for KeyCDN’s cached and accelerated content to be considered “good” during any outages at the origin server.

The KeyCDN Zone and ZoneAlias setup was identical to the documentation linked above.  For the purposes of this post, our origin server is origin.example.com and the KeyCDN ZoneAlias is cdntest.example.com.  As you can read in the KeyCDN document, whole-site acceleration requires configuring WordPress so both the “WordPress Address” and “Site Address” are set to the ZoneAlias.  The only step I took that was not explicitly in the KeyCDN document was the letsencrypt setting:

 

WordPress was working work fine for all pages and resources – except for the home page, which would enter an endless 301 redirect loop.  Clearly this is a problem.  It took quite a bit of trial and error for me to learn that this was due to WordPress’s permalink setting.  Since the site, as most WordPress sites do, was using some form of “pretty” permalinks rather than the “plain” ?p=1234 style, the homepage was some kind of odd edge case that wouldn’t work.  Glorious.

After some more trial and error, I found this was because the HTTP Host: header does not match the “WordPress Address” and “Site Address” values.  (Yes, I verified with all plugins disabled.) . KeyCDN acts as an intermediary, and their requests to the origin server are for origin.example.com, while WordPress is expecting requests to cdntest.example.com.  An attractive – but ultimately failing – option is KeyCDN’s “Forward Host Header” option, which replaces the HTTP Host: header value with that of the ZoneAlias – which matches what WordPress expects.

Immediately upon enabling this, all requests to the ZoneAlias failed with a 400 response code.  Whomp whomp.

I ramped up Apache’s debug logging to the max and found that it was now because the TLS negotiation was failing.  The newly-faked HTTP Host: header value didn’t match the TLS negotiation’s Server Name Indication (SNI) value.  (The SNI field also contains the hostname being requested.)  The following log message was being dropped with each 400-generating request:

[Wed Aug 15 20:56:34.364285 2018] [ssl:error] [pid 3867] AH02032: Hostname origin.example.com provided via SNI and hostname cdntest.example.com provided via HTTP are different

After a great deal of research, I found that a bit of Apache configuration tweaking would allow rewriting the HTTP Request headers after all of the TLS negotiation was complete but before it was handed off to WordPress.  KeyCDN also forwards a header named X-Forwarded-Host which contains the originally-requested hostname – aka the ZoneAlias.  I disabled the “Forward Host Header” option in the KeyCDN zone and added the following configuration while the Zone was re-provisioning:

RewriteCond %{HTTP:X-Forwarded-Host} !^$
RewriteRule ^ - [E=XFH:%{HTTP:X-Forwarded-Host}]
RequestHeader set "Host" %{XFH}e env=XFH

Translated: If the X-Forwarded-Host header is not empty, set an environment variable named XFH populated with that value.  Then if the XFH environment value is present, set the Host: header with its contents.

As soon as this was in place, all pages, all redirects, and all content worked perfectly.  I grabbed a beer.

There were a number of other steps that are more generically related to changing the hostname of your WordPress site (e.g. from origin.example.com to cdntest.example,com) nor the various nuances of most efficiently and effectively using a CDN to accelerate your site – but I’m not going to go into all those details here.  I’ll only say that the Search Replace DB project is a lifesaver, since it handles all the unserialize/search-and-replace/re-serialize steps more smoothly than any other project I’ve found.

In the end, we have a fully-accelerated, resilient site via KeyCDN, with automatically-renewing TLS certificates, and WordPress fully working.  That last part was the tough one.


The entirety of configuration options and directives are below.  Not all of these are required for the functionality described above – they’re just what I’m using at this time.

KeyCDN:

  • Zone name: testzone
  • Zone status: active
  • Zone type: Pull
  • Force download: disabled
  • CORS: enabled
  • GZip: enabled
  • Expire (in minutes): 0
  • Block Bad Bots: enabled
  • Allow Empty Referer: enabled
  • Secure Token: disabled
  • HTTP/2: enabled
  • SSL: letsencrypt
  • Force SSL: enabled
  • Origin URL: https://origin.example.com
  • Origin Shield: disabled
  • Max Expire (in Minutes): 1440
  • Ignore Cache Control: disabled
  • Ignore Query String: disabled
  • Forward Host Header: disabled
  • Cache Key Scheme: disabled
  • Cache Key Host: disabled
  • Cache Key Cookie: disabled
  • Cache Key Device: disabled
  • Cache Brotli: disabled
  • Cache Cookies: disabled
  • Strip Cookies: disabled
  • X-Pull CDN: KeyCDN
  • Canonical Header: disabled
  • Robots.txt: disabled
  • Custom Robots.txt: none
  • Optimize for HLS: disabled
  • Generic Error Pages: disabled

Apache:

<VirtualHost 1.2.3.4:443>
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/example.com/cert.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
    SSLCertificateChainFile /etc/letsencrypt/live/example.com/chain.pem
    SSLCACertificateFile /etc/pki/tls/certs/ca-bundle.crt

    DocumentRoot /path/to/docroots/example.com
    ServerName example.com
    ServerAlias origin.example.com
    ServerAlias www.example.com
    ServerAdmin webmaster@example.com

    <Directory "/path/to/docroots/example.com">
        AllowOverride Options AuthConfig Indexes Limit FileInfo
        Options +SymLinksIfOwnerMatch
        Require all granted
    </Directory>
    <Directory "/path/to/docroots/example.com/wp-content/themes/">
        Options FollowSymLinks
        AllowOverride Options Indexes AuthConfig Limit FileInfo
        Require all granted
    </Directory>

    RewriteCond %{HTTP:X-Forwarded-Host} !^$
    RewriteRule ^ - [E=XFH:%{HTTP:X-Forwarded-Host}]
    RequestHeader set "Host" %{XFH}e env=XFH

    SetEnvIf Request_Method "POST" NOCACHE=1
    SetEnvIf Request_URI "^/(wp-login.php|wp-admin|login.php|backend|admin)" NOCACHE=1
    SetEnvIfNoCase Cookie "PHPSESSID" NOCACHE=1
    SetEnvIfNoCase Cookie "(wp-postpass|wordpress|comment_author)_" NOCACHE=1
    RewriteCond %{QUERY_STRING} !^$
    RewriteRule ^ - [E=NOCACHE:1]
    Header set Cache-Control "no-cache" env=NOCACHE
    Header set Cache-Control "max-age=2592000, stale-while-revalidate=86400, stale-if-error=604800" env=!NOCACHE
</VirtualHost>

Leave a comment

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