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>