Docker

Migrating WordPress to Docker and https

I recently completed migrating this blog’s WordPress from a standalone http install, to Docker and https, all running on a new AWS instance. Normally a migration like this is fairly straightforward, but not in this case. It merits a post for the nightmarish process this was, ranging from incorrect documentation for WordPress, it’s lack of readiness for Docker (php edits = bad), and some issues with Traefik. This migration surpassed Redmine as the most painful migration I’ve ever had to deal with.

We’ll start the steps at the point where the server is provisioned, docker is installed, and a database container is running.
For reference, here is my docker-compose.yaml

services:
  ################################
  # MariaDb
  db:
    image: mariadb
    container_name: db
    # Host name on the network
    hostname: db
    restart: always
    networks:
      - default
    ports:
      # [Port exposed] : [Port running inside container]
      - '3306:3306'
    volumes:
      # Where our data will be persisted. This creates the volume and mounts it in container.
      # [physical path]:[path within the container]
      - /dockerdata/mariadb:/var/lib/mysql
    environment:
      # Password for root access
      MYSQL_ROOT_PASSWORD: 'YourRootPasswordHere'

##########
networks:
  # the dockerdata_default network label=default.
  default:
    name: my-default-network
    # external - false, else Traefik can get confused trying to reach containers on the wrong network (a bug I think).
    external: false

Note the explicit entry for the default network. This was one of the issues I ran into with Traefik. Until the default network was explicity configured and made external, Traefik would randomly (varied from one docker-compose up to the next) search the wrong network for containers.

Traefik

I choose Traefik as the proxy because:
1. It can be configured completely within Docker Compose, without separate configuration files
2. Certificate generation is built-in, and automatic.
3. Traefik can detect new containers and automatically start proxying them.

Prepare the Traefik docker folder. My configurations are under /dockerdata.

sudo mkdir /dockerdata/traefik

Create a placeholder for the certificate.

sudo touch /dockerdata/traefik/acme.json && chmod 600 /dockerdata/traefik/acme.json

Docker Networks

An additional network needs to be specified in Compose for use by Traefik and the containers that it will reverse proxy.
The default docker network is retained for use by containers that are outside the scope of Traefik (i.e. not web services), e.g. in my case MariaDB.
I create a second docker network called “web”. Docker containers can communicate with each other over TCP when they share a network. Traefik will also use this network to detect containers as they are started up. As we’ll see later, some containers will need access to both networks. For example, WordPress needs access to the database.

cd /dockerdata
docker network create web

View the defined networks

docker network ls
NETWORK ID     NAME                 DRIVER    SCOPE
98281731fc76   bridge               bridge    local
5af040bbbb09   dockerdata_default   bridge    local
4dcab657b07d   host                 host      local
3bb03a7a0c1c   none                 null      local
65fc2b46f72b   web                  bridge    local

Note that the name of the default network depends on the directory where your “docker-compose.yml” is located.

Configuring Traefik with whoami

Now we’re ready to configure the Traefik container and whoami, which will be used to verify the Traefik configuration.

  ################################
  # traefik
  traefik:
    image: traefik:v2.5
    container_name: traefik
    ports:
      - 80:80
      - 443:443
      # Traefik api
      - 8080:8080

    networks:
      - web

    volumes:
      # So Traefik can listen to Docker events and reconfigure its own internal configuration when
      # containers are created (or shut down).
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

      # Store certificates in ./letsencrypt/acme.json
      - "/dockerdata/traefik:/letsencrypt"

    # ================================
    # For debugging, set logLevel=DEBUG, enable --certificatesresolvers.myresolver.acme.caserver
    command:
      #- --debug=false
      # log.level ERROR|WARNING|INFO|DEBUG
      - "--log.level=DEBUG"

      # We expose the Traefik API to be able to check the configuration if needed.
      # Traefik will listen on port 8080 by default for API request.
      - "--api.insecure=true"

      # Allow Traefik to gather configuration from Docker. e.g. ports, etc. when a container spins Up.
      - "--providers.docker=true"
      # Do not expose containers unless explicitly stated.
      - "--providers.docker.exposedbydefault=false"
      # ================================
      # Entrypoints - ports
      # Port 80
      - "--entrypoints.web.address=:80"
      # Traefik will listen to incoming request on the port 443 (https)
      - "--entrypoints.websecure.address=:443"

      # ================================
      # Redirect all traffic to web (port 80) to websecure (port 443)
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"

      # Certificate Resolvers- responsible for retrieving certificates from an ACME server.
      # handles certificate generation/renewal/disposal for you.
      # Defines myresolver below. Can name this anything.
      # Authentication method - http challenge. Enable an http challenge named "myresolver".
      - "--certificatesresolvers.myresolver.acme.httpchallenge=true"
      # Tell it to use our predefined entrypoint named "web" as defined for entrypoints.web.address above.
      - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
      # TESTING - Uncomment this to get a fake certificate when testing. Delete the acme.json cert file when done.
      #- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
      # The email to provide to LetsEncrypt
      - "--certificatesresolvers.myresolver.acme.email=YourEmailAddressHere"
      # Store the certificate on a path under our volume
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"

    # Logs - max of 10 MB ea, 3 files.
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    restart: always

  ################################
  # whoami
  # a tiny Go server that prints os information and HTTP requests to output.
  # Refer to https://hub.docker.com/r/traefik/whoami
  whoami:
    image: "containous/whoami"
    networks:
      - web

    # These are used by Traefik service. Traefik auto-detects the port this container exposes.
    labels:
      # Explicitly tell Traefik to expose this container
      - "traefik.enable=true"

      # The domain the service will respond to.
      # of the form traefik.http.routers.[dockerservicename].rule
      - "traefik.http.routers.whoami.rule=Host(`blog.outpost4.org`)"


      # Allow request only from the predefined entry point named "websecure"
      - "traefik.http.routers.whoami.entrypoints=websecure"

      # Each router is configured to enable TLS, and is associated to a certificate resolver, in this case myresolver.
      # Tells Traefik to use the certificate resolver named myresolver above.
      - "traefik.http.routers.whoami.tls.certresolver=myresolver"

    restart: unless-stopped

##########
networks:
  # Web network used to allow Traefik to talk to other members of the network.
  # In order for Traefik to find them, they must belong to this network.
  # external - means it was created already (in my case by prior call of "docker network create web").
  # This will include all service destinations that it is to reverse proxy.
  # Includes wordpress
  web:
    external: true

  # the dockerdata_default network label=default.
  default:
    name: my-default-network
    # external - false, else Traefik can get confused trying to reach containers on the wrong network (a bug I think).
    external: false

Again, correct configuration of the two networks is essential or you will get random problems with Traefik.

Start the traefik and whoami containers, view the traefik log.

docker-compose up -d
docker logs --tail="200" -f traefik

Confirm the docker networks are configured correctly.

docker network inspect web

You should see entries in the network web for traefik and whoami.

Now try and browse to your https site.

https://blog.outpost4.org

whoami will output something like this:

Hostname: ae6092ebccef
IP: 127.0.0.1
IP: 172.19.0.3
RemoteAddr: 172.19.0.2:48220
GET / HTTP/1.1
Host: blog.outpost4.org
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cache-Control: max-age=0
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Sec-Gpc: 1
Upgrade-Insecure-Requests: 1
X-Forwarded-For: 71.93.64.102
X-Forwarded-Host: blog.outpost4.org
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 75c1149a9d38
X-Real-Ip: 71.93.64.102

We’ve now verified Traefik is working and we’re done with whoami.
Stop Traefik and whoami.

docker-compose stop whoami
docker-compose stop traefik

Edit docker-compose and remove the whoami service.

WordPress Database

Get a mysqldump of the old site database.

sudo mysqldump --user=root --password=YourDbRootPassword --add-drop-table --add-drop-database  wordpress_db > /WhateverDir/WordpressDump.sql

Place this file in a location where it can be accessed by your database. In my case, this is /dockerdata/mariadb.

Create the WordPress database.

docker exec -it  db  /bin/bash
mysql --user=root --password="YourDbRootPassword"
CREATE DATABASE wordpress_db CHARACTER SET utf8;
quit;

Confirm you see the import file. Note that we’re currently in the docker exec shell for the database container.

ls -l /var/lib/mysql

Import the database.

mysql --user=root --password="YourDbRootPassword"  wordpress_db < /var/lib/mysql/WordpressDump.sql

Verify the import.

mysql --user=root --password="YourDbRootPassword"
SHOW DATABASES;
USE wordpress_db;
SHOW TABLES;

Create the WordPress User.

CREATE USER 'wordpress_user' IDENTIFIED BY 'YourWpUserPassword';

Set the WordPress User privileges.

GRANT ALL PRIVILEGES ON wordpress_db.* TO 'wordpress_user';
SHOW GRANTS FOR wordpress_user;
FLUSH PRIVILEGES;

Exit mysql interpreter and docker exec.

quit;
exit

We’re done with the import file and can delete it.

rm /dockerdata/mariadb/WordpressDump.sql

WordPress Files

For these steps, we migrate the wordpress file directory, create an upload.ini file, and hack the WordPress configuration so that it works in Compose and https.

sudo mkdir /dockerdata/wordpress

Get a zip file of the /var/www/html/wp-content directory from your old site.

sudo mkdir /dockerdata/wordpress/wp-content

Extract the zip file to the wordpress docker folder so that you end up with subdirectories for plugins, themes, uploads. Note that this will be defined later as a persistent mount.

Set the owner – user and group as www-data.

sudo chown -R --changes  www-data:www-data  /dockerdata/wordpress/wp-content

Set permissions on the wp-content directory and children.

chmod 2775 /dockerdata/wordpress/wp-content
chmod -R 775 /dockerdata/wordpress/wp-content

Now we set up the config directory. This will be a persistent mount, containing the wp-config.php and uploads.ini files.

sudo mkdir /dockerdata/wordpress/config
Place a copy of the wp-config.php file from your old site in here.

Create the uploads.ini file.

sudo nano /dockerdata/wordpress/config/uploads.ini

Paste the following. You can adjust settings per your preferences.

file_uploads = On
memory_limit = 512M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 600

Save.

Set ownership and permissions.

chown -R --changes  www-data:www-data  /dockerdata/wordpress/config
chmod 775 /dockerdata/wordpress/config
chmod -R 775 /dockerdata/wordpress/config/*

Now we have to hack the wp-config.php file for things to work correctly in https. Yes, this is a total hack.

sudo nano /dockerdata/wordpress/wp-config.php

Scroll to just above the line

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

Paste the following.

// WordPress hacks - This is where one can place overrides.
define('WP_DEBUG', false);

// Per https://wordpress.org/support/article/administration-over-ssl/#using-a-reverse-proxy
// address the infinite redirect when accessing admin.
define('FORCE_SSL_ADMIN', true);

// 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)
$_SERVER['HTTPS']='on';

Make sure your mysql settings match too, in case they are different from what is shown here. In my case, I needed to change the mysql host name from localhost to the value of the hostname entry in Compose for the database service.

/** MySQL hostname */
define('DB_HOST', 'db');

Save.

Configuring the WordPress Container

Now we’re finally ready to configure the WordPress container.
Edit the docker compose file and add the wordpress service.

  ################################
  # WordPress
  wordpress:
    image: wordpress:5.8.2
    container_name: wordpress
    depends_on:
      - db
      - traefik

    # Include the default network so it can talk to db.  web- traffic routed by Traefik.
    networks:
      - default
      - web

    volumes:
      # Note that this configuration leaves the bulk of /var/www/html in the container.
      # It is is required in order for pull of new image to actually overwrite
      # the files, but retain your themes, plugins, uploads.
      # wp-content
      - /dockerdata/wordpress/wp-content:/var/www/html/wp-content

      # wp-config.php
      # Note that when exposing a single file, the directory it is in *must* exist in the container,
      # either as part of the existing host dir tree, or a volume definition for folder that includes location of this file..
      # Since /var/www/html exists on the host image, we can safely mount just the file.
      # Individual file volumes must be defined with absolute host path.
      - /dockerdata/wordpress/config/wp-config.php:/var/www/html/wp-config.php

      # Php Setting - Media upload file size limit. Expose this so that settings are persistent.
      - /dockerdata/wordpress/config/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini

    environment:
      # [Database compose service Name]:[Port]. Corresponds to "hostname" definition on db service.
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress_db
      WORDPRESS_DB_USER: wordpress_user
      WORDPRESS_DB_PASSWORD: 'YourWpUserPassword'

    # Logs - max of 10 MB ea, 3 files.
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    restart: unless-stopped
    labels:
      # Used by Traefik
      # Explicitly tell Traefik to expose this container
      - "traefik.enable=true"

      # Defines a default docker network to use for connections to all containers.
      # Refer to https://doc.traefik.io/traefik/providers/docker/#network
      # Using network "web" defined in docker networks directive.
      # Traefik picks up random IP at startup and will get confused trying to talk to this service on
      # the wrong (default) network. This statement is required to make it work right.
      # Do an inspect on the network name to see what name is being used in reality, then put that in this statement.
      - "traefik.docker.network=web"

      # Routers - define router "blog"
      # The domain the service will respond to.
      - "traefik.http.routers.blog.rule=Host(`blog.outpost4.org`)"

      # https
      # Allow request only from the predefined entry point named "websecure"
      - "traefik.http.routers.blog.entrypoints=websecure"

      # Each router is configured to enable TLS, and is associated to a certificate resolver, in this case myresolver.
      # Tells Traefik to use the certificate resolver named myresolver above.
      - "traefik.http.routers.blog.tls.certresolver=myresolver"

Launch the containers.

docker-compose up -d

View the docker log to make sure things look ok.

docker logs --tail="200" -f wordpress

Browse to your site. In my case https://blog.outpost4.org.
Confirm you can log in to the WordPress administration.

As for WordPress updates, at the time of this writing, I have not tested it yet. My plan is to update the tag in the compose file and ignore the notices in the admin GUI.