Docker-izing WordPress

In this post we’ll be setting up Docker and deploying WordPress on my docker host. This involves setting up a Nginx reverse-proxy with a valid TLS Certificate which will map HTTP requests to to their respective services, and deploying a working WordPress consisting of a MySQL database and the WordPress container powered by Apache.

Before I even get started on working on the docker host. I first configure my firewall to allow access on port 80 and 443 to docker1. This is a simple port forwarding rule.

Packets from the Internet now hit the docker1 host on 80 and 443

In addition to this, if I want to be able to reach the site from inside my own network I need to tell my own DNS that it should direct me to the internal IP of the Nginx server.

Adding a host override in my local DNS

I also have to configure the VM to allow packets on 80 and 443 to enter my server.

firewall-cmd --permanent --add-port=80/tcp
firewall-cmd --permanent --add-port=443/tcp
firewall-cmd --reload
firewall-cmd --list-ports

With this completed packets from the internet now reach services on my docker host which are listening on 80 or 443. Now on to the main event.

After the OS is installed on the VM it is a simple matter to install the docker service.

# curl -fsSL | sh
# systemctl enable docker
# systemctl start docker

The script at automatically configures the yum repository and installs the docker service. I will be using docker-compose as well to configure the multiple services I will be running. So let’s install that:

# curl -L "$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# chmod 755 /usr/local/bin/docker-compose

Now we have the tools to put together our WordPress site. docker-compose is used to group containers which make a up a complete service. While I could deploy the Nginx container along with the WordPress and MySQL containers, I am keeping them separate since Nginx will serve other services beyond WordPress. It will be the load balancer for all of

Let’s look at the WordPress docker-compose.yml:

version: '3'
     image: mysql:8.0.16
     container_name: mysql
     command: --default-authentication-plugin=mysql_native_password
     restart: always
       MYSQL_ROOT_PASSWORD: secret-pw
       MYSQL_DATABASE: wordpress
       MYSQL_USER: wordpress
       MYSQL_PASSWORD: wordpress
       - /ds1/wordpress_mysql:/var/lib/mysql
       - ./my.cnf:/etc/mysql/my.cnf
       - wordpress-net
     image: wordpress:5.2.1-apache
     container_name: wordpress
     restart: always
       - mysql
       WORDPRESS_DB_HOST: mysql:3306
       WORDPRESS_DB_USER: wordpress
       WORDPRESS_DB_PASSWORD: wordpress
       WORDPRESS_DB_NAME: wordpress
       WORDPRESS_CONFIG_EXTRA: "define('WP_HOME',''); define('WP_SITEURL','');"
       - /ds1/wordpress_wp-content:/var/www/html/wp-content
       - wordpress-net

As I’ve been leading onto, mysql and wordpress are their own services. Each one running in an independent container. So let me break it down a bit:


  • I specifically pick a version number for the MySQL image. Preventing accidentally upgrading just because the latest tag was moved.
  • I name the container mysql for convenience, if you are running multiple instances, you probably want to leave this off. This considerably shortens docker commands on these containers.
  • Since I’m using MySQL 8 I add --default-authentication-plugin=mysql_native_password to the startup command.
  • I set the container to restart if it exits. This also serves to restart the container on reboot.
  • I’m passing some environment variables into MySQL which create the desired database. These are defined in the containers docs. Don’t worry hackers, that’s not my actual password.
  • For volumes, I have set the data directory to be on the file system, which makes data persist between deployments. Very desirable! I’m also passing in a configuration file, which tweaks the database to use less memory. Note: this is likely not a production friendly change but I set performance_schema = 0 I have not really analyzed what this does other than to reduce memory usage on my limited environment. This config file exists in the same directory as the docker-compose.yml.
  • Finally the container is added to it’s own network for communication with WordPress, the app to database communication never leaves docker.


  • Again choosing a specific version, which does happen to be the latest.
  • Name for convenience.
  • Setting depends_on: mysql to start the mysql container first. This could be improved on with a wrapper script. As WordPress will throw errors while waiting for the database to come up.
  • More environment variables. More docs!
  • Note: The HOST configuration is using ‘mysql’ as the hostname, this is due to naming the service at the top of the file, and using a docker network which provides DNS resolution for the service names.
  • WORDPRESS_CONFIG_EXTRA is set to take care of the URL rewrite I am doing. Since Nginx will be used for more than just WordPress. I will map this service to
  • Add a volume to persist the uploaded content.
  • Connect the container to the same network as the MySQL service.

After this I execute docker-compose up -d to start the app. Now since I have not exposed any ports on the host. The app is actually inaccessible. I need to deploy the Nginx reverse-proxy to accept requests and pass them to WordPress.

Here’s the Nginx docker-compose.yml:

version: '3'
    image: nginx:1.17.0
    container_name: nginx
#This should be commented during initial ssl generation
#    command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./data/certbot/conf:/etc/letsencrypt
      - ./data/certbot/www:/var/www/certbot
      - 80:80
      - 443:443
    image: certbot/certbot
#This should be commented during initial ssl generation
#    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
      - ./data/certbot/conf:/etc/letsencrypt
      - ./data/certbot/www:/var/www/certbot
      name: wordpress_wordpress-net

Things get a little interesting here with the TLS certificate generation:

  • The command additions are used to set the server to renew the TLS cert when it’s time. These should be commented out during the initial creation.
  • nginx.conf which is the configuration file for Nginix is loaded the from same directory as the docker-compose.yml
  • A couple of data directories are created to share the certs between the certbot and Nginx containers.
  • I map the docker host’s HTTP(80) port and HTTPS(443) port to the Nginx container. It will handle all requests for the domain.
  • We also attach this container to the WordPress network to allow our connections.

Here’s the nginx.conf:

events {}
http {

server {
    listen 80;
    location / {
        return 301 https://$host$request_uri;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;

server {
    listen                    443 ssl;
    server_name     ;
    ssl_certificate           /etc/letsencrypt/live/;
    ssl_certificate_key       /etc/letsencrypt/live/;
    include                   /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam               /etc/letsencrypt/ssl-dhparams.pem;
    add_header                Strict-Transport-Security "max-age=604800";

    location / {
      return 301 https://$host/wordpress$request_uri;

    location /wordpress/ {
      proxy_pass http://wordpress:80/;

      proxy_set_header X-Forwarded-Host $host;
      proxy_set_header X-Forwarded-Proto https;
  • The server listens on both 80 and 443.
  • 80 simply redirects all requests except for the Let’s Encrypt agent challenge URL to HTTPS.
  • 443 is configured to pick up TLS certs from certbot.
  • It has a redirect for to /wordpress/
  • The /wordpress/ context is configured to terminate the TLS connection and pass the HTTP connection to the WordPress container.

Now we have a bit of a catch-22 here. The Nginx server wont start because there are no certs in the specified locations, and certbot can’t generate the certificates because it needs Nginx to proxy it the challenge request.

A couple of scripts come into play here:


if ! [ -x "$(command -v docker-compose)" ]; then
  echo 'Error: docker-compose is not installed.' >&2
  exit 1

staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits

if [ -d "$data_path" ]; then
  read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
  if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then

if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
  echo "### Downloading recommended TLS parameters ..."
  mkdir -p "$data_path/conf"
  curl -s > "$data_path/conf/options-ssl-nginx.conf"
  curl -s > "$data_path/conf/ssl-dhparams.pem"

echo "### Creating dummy certificate for $domains ..."
mkdir -p "$data_path/conf/live/$domains"
docker-compose run --rm --entrypoint "\
  openssl req -x509 -nodes -newkey rsa:1024 -days 1\
    -keyout '$path/privkey.pem' \
    -out '$path/fullchain.pem' \
    -subj '/CN=localhost'" certbot

echo "### Starting nginx ..."
docker-compose up --force-recreate -d nginx

staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits

echo "### Deleting dummy certificate for $domains ..."
docker-compose run --rm --entrypoint "\
  rm -Rf /etc/letsencrypt/live/$domains && \
  rm -Rf /etc/letsencrypt/archive/$domains && \
  rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot

echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
for domain in "${domains[@]}"; do
  domain_args="$domain_args -d $domain"

# Select appropriate email arg
case "$email" in
  "") email_arg="--register-unsafely-without-email" ;;
  *) email_arg="--email $email" ;;

# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi

docker-compose run --rm --entrypoint "\
  certbot certonly --webroot -w /var/www/certbot \
    $staging_arg \
    $email_arg \
    $domain_args \
    --rsa-key-size $rsa_key_size \
    --agree-tos \
    --force-renewal" certbot

echo "### Reloading nginx ..."
echo "Do this: docker-compose exec nginx nginx -s reload"

I execute these in two parts because it seemed to work better this way. The first script downloads two of the configuration files for nginx.conf, and generates dummy certs so that Nginx can start. It then starts the app. In the second script, we trigger certbot to generate the TLS certs, which makes use of the challenge URL setup in Nginx. After this I need to manually execute the reload for it to take effect though the following command: docker-compose exec nginx nginx -s reload

To enable the auto-renew for the TLS cert, un-comment the lines in docker-compose.yml now and restart using: docker-compose up -d

At this point I can finally access the WordPress page, and my TLS cert is marked as valid in Chrome.

Valid at last.


Finally, let’s also check how secure our TLS setup is. There are various tools out there to perform tests, but I chose to use Qualys SSL Server Test. Lots of information to be gleaned here if you want to harden your setup further than what is provided by default with Let’s Encrypt. Suggestions from these types of tests would be implemented in the nginx.conf for the reverse-proxy. Tightening some of these settings could limit the number of clients which can connect to your web apps. For instance disabling TLSv1.0, or TLSv1.1 will prevent older browsers without support for TLSv1.2 from accessing your site.

Not too shabby.

Let’s recap by tracing how your HTTP request got to this page today.

Your browser asks for the DNS record associated with This DNS record ultimately points to the IP address of my ISPs modem. This is passed to my router which is configured to relay all traffic on 80 or 443 to this VM. firewalld has been configured to allow these connections, and the traffic makes it to the Nginx container since it has been mapped to the host’s 80 and 443 ports. Nginx then maps the /wordpress/ requests to the WordPress container. Which makes connections to the MySQL database to retrieve data required to form the page which is then returned back to you. This communication is all secured by being encrypted using the TLS certificate over HTTPS.

Leave a comment

Your email address will not be published. Required fields are marked *