TL;DR

Assuming you’ve already got your reverse proxy running, in wp-config.php add the following:

1
2
3
4
5
6
7
8
9
<?php
/** TLS/HTTPS fixes **/
// in some setups HTTP_X_FORWARDED_PROTO might contain a comma-separated list
// e.g. http,https so check for https existence.
if (strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
    // update HTTPS server variable to always 'pretend' incoming requests were 
    // performed via the HTTPS protocol.
    $_SERVER['HTTPS']='on';
}

If you’re getting desperate:

1
2
3
4
5
6
7
// If you ever get stuck, you can override the database set site URIs as well.
define( 'WP_HOME',    'https://example.com' );
define( 'WP_SITEURL', 'https://example.com' );

// FORCE_SSL_ADMIN is for when you want to secure logins and the admin area so
// that both passwords and cookies are never sent in the clear.
define( 'FORCE_SSL_ADMIN', true );

Introduction

If you are using a reverse proxy that performs SSL termination for you, you may find yourself in a redirect loop or getting a lot of mixed content as Wordpress, quite rightly, acts as if it’s a normal HTTP request.

PHP uses server variables[1] that scripts can hook into to learn about their world. One of these magic variables, $_SERVER['HTTPS'], is set to a non-empty value if the script was queried through the HTTPS protocol.

Wordpress uses this variable ($_SERVER['HTTPS']) to detect if an incoming request should mutate links, hence leading to a nasty redirect loop when behind a reverse proxy with SSL termination.

⚠️ Warning! There are a lot of example configuration files ahead!

Architecture Overview

In order to fix it, we can programmatically make use of that standardised X-Forwarded-Proto header that can be forwarded via a proxy pass configuration in nginx, so we need to configure a few things. In this configuration we have the following architecture:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
            
                                +---------------------------+
                                |                           |
                                |     +-----------+         |
                                |     |  Varnish  |         |
                                |     +--^----+---+         |
                                |        |    |             |
            +---------------+   |        |    |             |
            |    Client     |   |     +--+----v-+           |
            |               +-------->|  Nginx  |           |
            |  (ノಠ ∩ಠ)ノ彡 |   |     +-------+-+           |
            +---------------+   |             |             |
                                |             |             |
                                |    +--------v----+        |
                                |    |  Wordpress  |        |
                                |    +-------------+        |
                                |                           |
                                |   (づ。◕‿‿◕。)づ server   |
                                |                           |
                                +---------------------------+

     client -> Nginx (https) -> Varnish --(on cache miss)--> Nginx (http) -> wordpress
                    client -> Nginx (https) -> Varnish (on cache hit)

nginx (https) -> varnish configuration

This configures the client->varnish flow using SSL termination, but setting a number of X-Forwarded-* headers that we can use to pick up on.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// /etc/nginx/conf.d/example.com.https.conf
# nginx (TLS termination) -> varnish cache
server {
    listen 443 ssl;
    listen [::]:443 ssl;

    ## Your website name goes here e.g. example.com *.example.com
    server_name example.com;
    server_name_in_redirect off;
    port_in_redirect        off;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://varnish;

        proxy_set_header Host $http_host;
        proxy_set_header HTTPS "on";
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Port 443;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

upstream varnish {
    server varnish:80;
}

varnish -> nginx (http) configuration

Thanks to Linode[2] for the reference implementation (which has been extended to add a X-Cache header for tracking cache hits).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// default.vcl
vcl 4.0;

sub vcl_hit {
    set req.http.X-Cache = "hit";
}

sub vcl_miss {
    set req.http.X-Cache = "miss";
}

sub vcl_pass {
    set req.http.X-Cache = "pass";
}

sub vcl_pipe {
    set req.http.X-Cache = "pipe uncacheable";
}

// Specify that the backend (nginx) is listening on port 8080.
// This is our internal backend route to wordpress.
backend default {
  .host = "nginx";
  .port = "8080";
}

// Allow cache-purging requests only from the following hosts.
acl purger {
  "localhost";
  "127.0.0.1";
}

sub vcl_recv {
    unset req.http.X-Cache;

    // Allow cache-purging requests only from the IP addresses in the above acl purger.
    // If a purge request comes from a different IP address, an error message will be produced.
    if (req.method == "PURGE") {
        if (!client.ip ~ purger) {
            return(synth(405, "This IP is not allowed to send PURGE requests."));
        }

        return (purge);
    }

    // Change the X-Forwarded-For header
    if (req.restarts == 0) {
        if (req.http.X-Forwarded-For) {
            set req.http.X-Forwarded-For = client.ip;
        }
    }

    // Exclude POST requests or those with basic authentication from caching
    if (req.http.Authorization || req.method == "POST") {
        return (pass);
    }

    // Exclude RSS feeds from caching
    if (req.url ~ "/feed") {
        return (pass);
    }

    // Don't cache the WordPress admin and login pages:
    if (req.url ~ "wp-admin|wp-login") {
        return (pass);
    }

    // Remove has_js and CloudFlare/Google Analytics __* cookies and statcounter is_unique
    set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(_[_a-z]+|has_js|is_unique)=[^;]*", "");

    // Remove a ";" prefix, if present.
    set req.http.Cookie = regsub(req.http.Cookie, "^;\s*", "");

    // WordPress sets many cookies that are safe to ignore.
    set req.http.cookie = regsuball(req.http.cookie, "wp-settings-\d+=[^;]+(; )?", "");
    set req.http.cookie = regsuball(req.http.cookie, "wp-settings-time-\d+=[^;]+(; )?", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "wordpress_test_cookie=[^;]+(; )?", "");
    if (req.http.cookie == "") {
        unset req.http.cookie;
    }
}

// Cache-purging for a particular page must occur each time we make edits
// to that page.
sub vcl_purge {
    set req.method = "GET";
    set req.http.X-Purger = "Purged";
    return (restart);
}

// The sub vcl_backend_response directive is used to handle communication
// with the backend server, nginx. We use it to set the amount of time
// the content remains in the cache.
// We can also set a grace period, which determines how Varnish will serve
// content from the cache even if the backend server is down.
// - Time can be set in seconds (s), minutes (m), hours (h) or days (d).
// Here, we’ve set the caching time to 24 hours, and the grace period to
// 1 hour, but you can adjust these settings based on your needs.
sub vcl_backend_response {
    set beresp.ttl = 24h;
    set beresp.grace = 1h;

    if (bereq.url !~ "wp-admin|wp-login|product|cart|checkout|my-account|/?remove_item=") {
        unset beresp.http.set-cookie;
    }
}

// Change the headers for purge requests.
sub vcl_deliver {
    if (req.http.X-Purger) {
        set resp.http.X-Purger = req.http.X-Purger;
    }

    if (obj.uncacheable) {
        set req.http.X-Cache = req.http.X-Cache + " uncacheable" ;
    } else {
        set req.http.X-Cache = req.http.X-Cache + " cached" ;
    }

    // set X-Cache header in response
    set resp.http.X-Cache = req.http.X-Cache;
}

nginx (http) -> wordpress configuration

Here we use a generic nginx+php-fpm fastcgi_pass configuration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// /etc/nginx/conf.d/example.com.http.conf
# nginx (TLS termination) -> varnish cache -> nginx http -> php-fpm
server {
    listen 8080;
    listen [::]:8080;

    server_name example.com;
    server_name_in_redirect off;
    port_in_redirect        off;

    access_log  /var/log/nginx/example.com/access.log;
    error_log   /var/log/nginx/example.com/error.log info;

    ## Your only path reference.
    root        /var/www/example.com;

    location = /favicon.ico {
        log_not_found off;
        access_log off;
    }

    location = /robots.txt {
        allow all;
        log_not_found off;
        access_log off;
    }

    # deny running scripts inside writable directories
    location ~* /(images|cache|media|logs|tmp)/.*\.(php|pl|py|jsp|asp|sh|cgi)$ {
        return          403;
        error_page      403 /403_error.html;
    }

    location / {
        # This is cool because no php is touched for static content.
        # include the "?$args" part so non-default permalinks doesn't break when using query string
        try_files $uri $uri/ /index.php?$is_args$query_string;
    }

    location ~ \.php$ {
        try_files                   $uri =404;
        fastcgi_split_path_info     ^(.+\.php)(/.+)$;
        fastcgi_intercept_errors    on;

        #NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini
        include         /etc/nginx/fastcgi.conf;
        fastcgi_index   index.php;
        fastcgi_param   SCRIPT_FILENAME     $document_root$fastcgi_script_name;
        fastcgi_pass    php-fpm;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
        expires max;
        log_not_found off;
    }
}

upstream php-fpm {
    server php-fpm:9000;
}

Forcing ‘Faux’TTPS for PHP (wp-config.php configuration)

Woo! Now we have all the above bootstrapped, we can finally grab the X-Forwarded-Proto header that’s been passed along, and force PHP to think that $_SERVER['HTTPS'] = 'on'; by creating an override in wp-config.php[3]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// wp-config.php
<?php
/** TLS/HTTPS fixes **/
// in some setups HTTP_X_FORWARDED_PROTO might contain a comma-separated list
// e.g. http,https so check for https existence.
if (strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
    // update HTTPS server variable to always 'pretend' incoming requests were 
    // performed via the HTTPS protocol.
    $_SERVER['HTTPS']='on';
}

If you get stuck, or redirects are still happening, you can use the site variables to revert to HTTP, or enforce an HTTPS root uri, which will override the database configuration:

1
2
3
4
5
// wp-config.php

// If you ever get stuck, you can override the database set site URIs as well.
define( 'WP_HOME',    'https://example.com' );
define( 'WP_SITEURL', 'https://example.com' );

Lastly, you can enforce that admin must be visited over HTTPS (FauxTTPS?) by setting FORCE_SSL_ADMIN[4] to stop bad actors trying to jump in over HTTP… ¯\_(ツ)_/¯

1
2
3
4
5
// wp-config.php

// FORCE_SSL_ADMIN is for when you want to secure logins and the admin area so
// that both passwords and cookies are never sent in the clear.
define( 'FORCE_SSL_ADMIN', true );

Addendum

  • ASCII chart created by: asciiflow
  • Time to SSL Termination frustration: 7+ hours
  • Time to Blog: 1h:39m