Hosting WordPress over HTTPS with Docker
Tags: wordpress, docker, nginx, https
Published: 2017-09-29 15:51:11

---
title: Hosting WordPress over HTTPS with Docker
published: true
description: Sharing my experience migrating my WordPress install to HTTPS.
tags: wordpress, docker, nginx, https
---

> UPDATE 10/13/2020: Hello! Forest from the future here! I no longer use this setup. And, I do not intend to keep it up to date. Since I no longer maintain this for myself, and seeing as it is a very custom solution, I do not have the time to create a repository for it.
>
>This solution is at least 3 years old. If you are doomed to use a legacy version of WordPress and this solution works for you, I'm happy to have helped. On the other hand, if you are using a newer version of WordPress, Nginx, Docker, or Certbot, there may be some nuggets of useful information in here, but beyond that I wish you the best of luck!
>
>Cheers! :smile:

[Just the other day](https://twitter.com/ForestJHoffman/status/912031600405123073), I moved my portfolio to a separate server and started serving it over HTTPS. I was super stoked when it was all done! I wanted to talk a bit about what steps I took, since I found some annoying gotchas along the way. This isn't a step-by-step tutorial, rather I'm sharing the configurations that finally got it working for me.

I use `example.com` as a placeholder here. If you're reading this and trying to get your own setup working, you'd replace `example.com` with your own domain. Also, though I doubt it needs to be said, don't use `root` as your MySQL password. <a name="toc"></a>

## Table of Contents

- [The Server Stack](#server-stack)
- [Docker Compose Configuration Overview](#config-overview)
- [Nginx Proxy Container](#nginx-proxy): [docker-compose.yml](#nginx-proxy-compose), [Dockerfile](#nginx-proxy-dockerfile), [crontab](#nginx-proxy-crontab), [startup.sh](#nginx-proxy-startup), [nginx.conf](#nginx-proxy-config)
- [The WordPress Containers](#wordpress): [docker-compose.yml](#wordpress-compose)
- [Nginx Container](#wordpress-nginx): [docker-compose.yml](#wordpress-nginx-compose), [nginx.conf](#nginx-config), [nginx/wordpress.conf](#wordpress-nginx-config)
- [WordPress Container](#wordpress-wordpress): [docker-compose.yml](#wordpress-wordpress-compose), [wordpress/wp-config.php](#wordpress-wordpress-wp-config)
- [Mariadb Container](#wordpress-mariadb): [docker-compose.yml](#wordpress-mariadb-compose)
- [Installing SSL Certificates with Certbot](#certbot)
- [Further Reading](#further-reading)

<a name="server-stack"></a>
## The Server Stack 
^ [ToC](#toc)

I use [Docker](https://www.docker.com/) to isolate my servers, so that they can all have their own dedicated environments. _That, and if I mess something up on a Docker container, I can just rebuild it._ Also, I use [Docker Compose](https://docs.docker.com/compose/) to handle grouping together my containers.

I chose [Nginx](https://nginx.org/en/) as my proxy and my WordPress web server. Note that the proxy isn't necessary here, but I needed it for a few other things. I wanted to keep things consistent, so that's why I opted to use Nginx across the board.

If you're trying to achieve a similar setup, but sans-proxy, you can just as well provide your WordPress container's Nginx server with the SSL certificates and add in the proper server blocks for SSL-support. I mention those below in the [Nginx Proxy Container](#nginx-proxy-config) section.

For my SSL certificates, I'm using [Let's Encrypt](https://letsencrypt.org/) via [Certbot](https://github.com/certbot/certbot). It's a lovely little tool that automates the whole certificate registration process.
<a name="config-overview"></a>
## Docker Compose Configuration Overview
^ [ToC](#toc)

The following sections list out all the necessary configurations for the following containers:
```
- proxy:
  - nginx
- wordpress:
  - nginx
  - wordpress
  - mariadb
```
<a name="nginx-proxy"></a>
## Nginx Proxy Container
^ [ToC](#toc)

<span id="nginx-proxy-compose">**docker-compose.yml**</span>

```bash
version: '3.3'

services:
  nginx-proxy:
    container_name: nginx-proxy
    build: .
    image: nginx-proxy:0.0.2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "./etc/letsencrypt/:/etc/letsencrypt/"
      - "./etc/ssl/:/etc/ssl/"
    command: bash -c "startup.sh"
    restart: always

```

The container uses an adjacent Dockerfile for building the custom nginx-proxy image. The container exposes port `80` and port `443` to the host machine. When the container is brought up, it mounts the two local directories to the `/etc/letsencrypt/` and `/etc/ssl/` directories on the container, respectively. Then, the default startup command (`nginx -g 'daemon off;'`) is overridden to run the `startup.sh` file. Finally, the container is configured to restart whenever it goes now, via the `restart: always` line.

<span id="nginx-proxy-dockerfile">**Dockerfile**</span>

```bash
FROM nginx:1.13.5-alpine

LABEL maintainer="Forest Hoffman<forestjhoffman@gmail.com>"

##
# setting necessary server configurations
##

# add curl and other necessary packages for openssl and certbot
RUN apk add --update \
                curl \
                bash \
                openssl \
                certbot \
                python \
                py-pip \
        && pip install --upgrade pip \
        && pip install 'certbot-nginx' \
        && pip install 'pyopenssl'

RUN addgroup staff
RUN addgroup www-data
RUN adduser -h /home/dev/ -D -g "" -G staff dev
RUN adduser -S -H -g "" -G www-data www-data
RUN echo dev:trolls | chpasswd

##
# copying nginx configuration file
##

COPY nginx.conf /etc/nginx/nginx.conf
RUN chown root:staff /etc/nginx/nginx.conf

# adds certbot cert renewal job to cron
COPY crontab /tmp/crontab-certbot
RUN (crontab -l; cat /tmp/crontab-certbot) | crontab -

# copies over the startup file which handles initializing the nginx
# server and the SSL certification process (if necessary)
COPY startup.sh /bin/startup.sh
RUN chown root:root /bin/startup.sh
RUN chmod ug+rx /bin/startup.sh
RUN chmod go-w /bin/startup.sh
```

The Dockerfile will include all the necessary requirements for running the Certbot within the container. It will also add a line to the container's [cron](http://www.unixgeeks.org/security/newbie/unix/cron-1.html) which will run the Certbot in renewal mode, early in the morning (according to the Docker container's system time).

<span id="nginx-proxy-crontab">**crontab**</span>

```bash
# Renew Let's Encrypt SSL Certificates that have < 30 days to go,
# in the morning (UTC time).
0 11 * * * /usr/bin/certbot renew --quiet
```

<span id="nginx-proxy-startup">**startup.sh**</span>

```bash
#!/bin/bash
#
# startup.sh
#

# Startup the nginx server. The server has to be active for the Let's Encrypt Certbot to
# register and install the certificates.
nginx -g "daemon on;"

# Checks that the SSL certificates are installed. If they are, renews any that are old, and
# installs them if not.
if [[ -d "/etc/letsencrypt/live/example.com" ]]; then
        certbot renew --quiet
else
        if ! [[ -d "/etc/letsencrypt/live/example.com" ]]; then
                certbot --nginx -m your-email@ex.com --agree-tos --no-eff-email --redirect --expand -d example.com,www.example.com
        fi
        if ! [[ -f "/etc/ssl/certs/dhparam.pem" ]]; then
                openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048
        fi
fi

# Shuts down the daemonized nginx server and fires up one in the foreground.
nginx -s stop && nginx -g 'daemon off;'
```

The `startup.sh` script is intended to be run whenever the container is brought up (i.e. with `docker-compose up`). It will only attempt to register certifications if the directory for the certifications are not already in the `/etc/letsencrypt/live` directory on the container. The paths in `startup.sh` must coincide with those in the Nginx configuration file. Speaking of which...

<span id="nginx-proxy-config">**nginx.conf**</span>

```bash
user www-data;
worker_processes 1;
pid /run/nginx.pid;

events {
    worker_connections 1024;
    # multi_accept on;
}

http {
    #sendfile on;

    upstream docker-wordpress {
            server nginx-wordpress:80;
    }

    # listen for HTTPS requests to example domain
    server {
            ssl_dhparam /etc/ssl/certs/dhparam.pem;
            server_name example.com www.example.com;

            listen 443 ssl; # managed by Certbot
            ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
            ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
            include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot

            location / {
                    proxy_pass       http://docker-wordpress;
                    proxy_redirect   off;
                    proxy_set_header Host $host;
                    proxy_set_header X-Real-IP $remote_addr;
                    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                    proxy_set_header X-Forwarded-Host $server_name;
                    proxy_set_header X-Forwarded-Proto https;
            }
    }

    # handle ACME challenge from Certbot, and send HTTP requests to HTTPS
    server {
            listen 80;
            server_name example.com www.example.com;

            # listen for ACME challenge from Certbot
            location ^~ /.well-known/acme-challenge/ {
                    # No HTTP authentication
                    allow all;

                    default_type "text/plain";
            }

            location = /.well-known/acme-challenge/ {
                    return 404;
            }

            # Redirect other HTTP traffic to HTTPS
            location / {
                    access_log off;
                    return 301 https://$host$request_uri;
            }
    }
}
```

This configures the proxy to listen for requests hitting port `443` (e.g. requests using `https://*`). If the requests are for the `example.com` or `www.example.com` domain, then the request is passed on to the `http://docker-wordpress` server. The `http://docker-wordpress` "upstream" server is defined as port `80` of the connected `nginx-wordpress` container. Connecting the containers together is accomplished by using the `networks` key in the `docker-compose.yml` files.

[Edited 12/05/17]: I updated the Nginx configuration file above to include a server block for allowing the proxy to pass Certbot's ACME challenge. This is required for certificate renewal.

Any requests to port `80` are not using SSL (just normal `http://*`), so those requests are redirected to port `443` by forcing the use of HTTPS in the url.

This block...
```bash
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
```

Indicates where the SSL certificates for the desired domains will reside on the proxy container.
<a name="wordpress"></a>
## The WordPress Containers
^ [ToC](#toc)

Note that I've broken up the container configurations in the sub-sections below, but they are actually all from the same `docker-compose.yml` file.

Here's what it looks like all together.

<span id="wordpress-compose">**docker-compose.yml**</span>

```bash
version: '3.3'

services:
  nginx:
    container_name: nginx-wordpress
    image: nginx:latest
    ports:
      - '80'
    volumes:
      - ./nginx:/etc/nginx/conf.d
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./logs/nginx:/var/log/nginx
      - ./wordpress:/var/www/html
    depends_on:
      - wordpress
    restart: always
    networks:
      - nginxproxy_default
  mysql:
    container_name: mysql
    image: mariadb
    ports:
      - '3306'
    volumes:
      - ./mysql:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=root
    restart: always
    networks:
      - nginxproxy_default
  wordpress:
    container_name: wp
    image: wordpress-prod:4.8.1-php5.6-fpm
    ports:
      - '9000'
    volumes:
      - ./wordpress:/var/www/html
    environment:
      - WORDPRESS_DB_NAME=wordpress
      - WORDPRESS_TABLE_PREFIX=wpprefix_
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_PASSWORD=root
    depends_on:
      - mysql
    restart: always
    networks:
      - nginxproxy_default
networks:
  nginxproxy_default:
    external: true
```

The key thing to see here is the top-level `networks` key, which allows each of the containers to connect to the [proxy](#nginx-proxy)'s default network. The network is not generated by any of the containers in this compose file, so the `nginxproxy_default` network is defined as an external network via the `external: true` line.
<a name="wordpress-nginx"></a>
## Nginx for WordPress Container
^ [ToC](#toc)

<span id="wordpress-nginx-compose">**docker-compose.yml**</span>

```bash
###
  nginx:
    container_name: nginx-wordpress
    image: nginx:latest
    ports:
      - '80'
    volumes:
      - ./nginx:/etc/nginx/conf.d
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./logs/nginx:/var/log/nginx
      - ./wordpress:/var/www/html
    depends_on:
      - wordpress
    restart: always
    networks:
      - nginxproxy_default
###
```

The nginx server in front of the WordPress container exposes port `80`, and mounts four directories. The first two are for nginx configuration, the next is for preserving logs, and the last one is for preserving the WordPress core file structure.

<span id="nginx-config">**nginx.conf**</span>

```bash
user www-data;
worker_processes auto;

events {
    worker_connections 768;
    # multi_accept on;
}

http {

    ##
    # Basic Settings
    ##

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    # server_tokens off;

    server_names_hash_bucket_size 64;
    # server_name_in_redirect off;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ##
    # SSL Settings
    ##

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
    ssl_prefer_server_ciphers on;

    ##
    # Logging Settings
    ##

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    ##
    # Gzip Settings
    ##

    gzip on;
    gzip_disable "msie6";

    # gzip_vary on;
    # gzip_proxied any;
    # gzip_comp_level 6;
    # gzip_buffers 16 8k;
    # gzip_http_version 1.1;
    # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    ##
    # Virtual Host Configs
    ##

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}
```

This is just a basic configuration file that includes any additional configuration files mounted to the `/etc/nginx/conf.d` and `/etc/nginx/sites-enabled/` directories. This file also specifies the `www-data` user as the user that Nginx should use when attempting to serve files. This means that the server's file structure (mounted to `/var/www/html`) should be accessible by the `www-data` user/group.

<span id="wordpress-nginx-config">**nginx/wordpress.conf**</span>

```bash
server {
    listen 80;

    root /var/www/html;
    index index.php;

    access_log /var/log/nginx/wp-access.log;
    error_log /var/log/nginx/wp-error.log;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.(png|jpg|jpeg|gif|svg)$ {
        try_files $uri $uri/;
    }

    # Deny access to config.
    location = /wp-config.php {
        deny all;
    }

    # Deny access to htaccess.
    location ~ /\. { 
        deny all;
    }

    # Directly allow access to /wp-admin/admin-ajax.php. This is necessary for
    # WordPress to function on the admin side.
    location ~* ^/wp-admin/admin-ajax.php$ {
        try_files $uri =404;
        fastcgi_intercept_errors on;
        fastcgi_pass wordpress:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Allow access to all other PHP files.
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass wordpress:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}
```

This Nginx server specifically caters to the WordPress container. It listens for requests to port `80` and sends any requests for PHP files (include admin pages) to the WordPress container on port `9000`. This nginx server doesn't need to listen on port `443` because the proxy is handling the SSL authentication. Any incoming requests for files on the WordPress server will be routed through the proxy.
<a name="wordpress-wordpress"></a>
## WordPress Container
^ [ToC](#toc)

<span id="wordpress-wordpress-compose">**docker-compose.yml**</span>

```bash
###
  wordpress:
    container_name: wp
    image: wordpress-prod:4.8.1-php5.6-fpm
    ports:
      - '9000'
    volumes:
      - ./wordpress:/var/www/html
    environment:
      - WORDPRESS_DB_NAME=wordpress
      - WORDPRESS_TABLE_PREFIX=wpprefix_
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_PASSWORD=root
    depends_on:
      - mysql
    restart: always
    networks:
      - nginxproxy_default
###
```

The WordPress container exposes port `9000`. It has one volume mounted for preserving the WordPress file structure. It also has four environment variables that correspond to the connected database. Note that the `WORDPRESS_DB_HOST` name is the same as the `container_name` of the connected [mysql](#wordpress-mariadb) container.

<span id="wordpress-wordpress-wp-config">**wordpress/wp-config.php**</span>

```php
<?php
###
// If we're behind a proxy server and using HTTPS, we need to alert Wordpress of that fact
// see also http://codex.wordpress.org/Administration_Over_SSL#Using_a_Reverse_Proxy
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';

    // Force SSL in admin.
    define('FORCE_SSL_ADMIN', true);
}

/* That's all, stop editing! Happy blogging. */
###
```

Ignoring the default configurations, these few lines can be placed before the "stop editing!" comment. These lines force WordPress to make internal requests using HTTPS, including on the admin side.
<a name="wordpress-mariadb"></a>
## Mariadb (MySQL) for WordPress Container
^ [ToC](#toc)

<span id="wordpress-mariadb-compose">**docker-compose.yml**</span>

```bash
###
  mysql:
    container_name: mysql
    image: mariadb
    ports:
      - '3306'
    volumes:
      - ./mysql:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=root
    restart: always
    networks:
      - nginxproxy_default
###
```

The difference between the Mariadb/MySQL container and the others is the `mysql` volume being mounted, and the root-password environment variable. All the other configurations in this block are very similar to the previous containers.
<a name="certbot"></a>
## Installing SSL Certificates with Certbot
^ [ToC](#toc)

In order to run the Certbot on the Nginx Proxy, the server has to be up and running first. Assuming that the certifications are not already in their local `./etc/letsencrypt` directory, attempting to run the proxy now would fail. I discovered that in order for the proxy to run, several things need to happen.

1) The `nginxproxy_default` network needs to be created. If the proxy has never been run before, then the network can't exist. Create the network by running the following in a terminal:

```bash
$ docker network create nginxproxy_default
```

2) The WordPress containers need to be running before the proxy can start. First navigate to wherever the `docker-compose.yml` file is for the WordPress containers. Then run the following in a terminal:

```bash
$ docker-compose up -d
```

Note: The `-d` flag starts the containers in headless mode, meaning that you won't see any errors unless you use the command `docker-compose logs` or check the status of the containers via `docker ps -a`.

3) The paths to the certificates need to be commented out before the proxy container can run the Certbot. In the proxy's `nginx.conf` I had to comment out the server block containing the `listen 443 ssl;` line and the server block handling redirection from HTTP to HTTPS. In their place, I temporarily entered this server block:

```bash
server {
    listen 80;
    server_name example.com www.example.com;
    location / {
         proxy_pass       http://docker-wordpress;
         proxy_redirect   off;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header X-Forwarded-Host $server_name;
    }
}
```

Then, the Nginx Proxy can be built using the `docker-compose up --build` command. Docker-compose will then user the non-default startup command, which will run the `startup.sh` script in the container. I would recommend not using headless mode when building the proxy, that way you can be sure that the Certbot was able to register the certificates.

Once the Certbot has successfully created the certificates, the temporary sever block added in step 3 above can be removed and the two other server blocks uncommented. After changing the configuration, the proxy will need to be reloaded with an `docker-compose down && docker-compose up -d`.

Doing a quick check with `docker ps -a` should display something along these lines:

```bash
CONTAINER ID  IMAGE                            COMMAND                 CREATED     STATUS        PORTS                                     NAMES
62b3a673f1cb  nginx-proxy:0.0.2                "nginx -g 'daemon ..."  3 days ago  Up 2 minutes  0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp  nginx-proxy
ea4acq9cc868  nginx:latest                     "nginx -g 'daemon ..."  4 days ago  Up 3 minutes  0.0.0.0:8081->80/tcp                      nginx-wordpress
c7badc76c834  wordpress-prod:4.8.1-php5.6-fpm  "docker-entrypoint..."  4 days ago  Up 3 minutes  0.0.0.0:8082->9000/tcp                    wp
2ba321f4afdd  mariadb                          "docker-entrypoint..."  4 days ago  Up 3 minutes  0.0.0.0:8083->3306/tcp                    mysql
```

If everything looks well, navigating to `https://example.com/wp-admin` should display the good 'ole WordPress setup screen.
<a name="further-reading"></a>
## Further Reading
^ [ToC](#toc)

- [Docker Compose Documentation](https://docs.docker.com/compose/compose-file/)
- [Docker Quickstart with WordPress](https://docs.docker.com/compose/wordpress/)
- [Nginx Image on DockerHub](https://hub.docker.com/_/nginx/)
- [Enabling SSL on Nginx](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04)
- [Dockerizing Nginx as a Proxy](https://www.digitalocean.com/community/tutorials/docker-explained-how-to-containerize-and-use-nginx-as-a-proxy)