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.