Caddy, Docker, PHP, and WordPress


I recently had a need to fix up a few WordPress sites. Each was running on its own copy of the WordPress source code despite relatively similar configurations. This bugged me, so I elected to look into the newer functionality of WordPress mutli-site. I also took this as an opportunity to explore a few new tricks for serving content and running the latest-and-greatest version of PHP.

The first new addition to my toolbox was Caddy. This is a web server written in Go with a surprising number of interesting features. The biggest draw for me was automatic HTTPS support using the free Let’s Encrypt certificate authority. Also of interest were the HTTP/2 support, the relatively simple configuration file syntax, and of course the “shiny” factor of using something new.

Caddy has good documentation, a pretty vibrant community, and offers a number of useful examples. It also provides a sane systemd unit file for running Caddy as a service in a security conscious manner.

The other thing I decided to explore was Docker. Docker provides for some interesting ways to use software. Rather than rely on the packaged version of PHP that my Linux distribution provides, or dealing with the burden of compilling PHP from source, Docker lets me use other distributions’ versions of PHP easily and in an isolated environment that doesn’t (easily) pollute the rest of my system.

A lot people are doing a lot of interesting things with Docker and containers. Some of these things are less than optimal, like containers running multiple processes, or the ability to ssh into a container, or a host of other (ab)uses of containers. For myself, I’m of the opinion that a container should, as much as possible, do exactly one thing. Even this approach has some challenges, though, so it’s important to recognize that using Docker to solve any particular problem requires some careful analysis. (Consider the PID 1 problem, which is still valid today.)

My first step at using Docker for PHP was to look at the official PHP images on the Docker Hub. Since I had already elected to use Caddy for my web server, I did not need a PHP container with Apache or nginx: I only needed php-fpm. Several official variants exist, but I chose to explore the 7.0-fpm-alpine image. This uses the super small Alpine Linux as the base of the container image, avoiding a lot of bloat.

The official PHP Docker image builds PHP from source. It also provides only a handful of default PHP modules. WordPress requires several more to operate, so I would have to modify the Dockerfile to include the bits I wanted. This wasn’t exactly the easy solution I was looking for with Docker, so I next looked at the official WordPress Docker image. This is a well built container, and follows the Docker best practices pretty well.

Unlike the official PHP image, the official WordPress images do not compile PHP from source. For the Alpine-based container, it uses the Alpine packaged version of PHP, which means adding new PHP modules is relatively easy.

This container includes a copy of the WordPress source code, and extracts it automatically when the container is run. In this way, the container provides a completely self-contained way to deploy and run WordPress. This is very much the Docker ideal. For first time users, this self-contained feature is nice: you don’t need to do anything at all. But it also means that without jumping through a few hoops any files you upload will live inside the container, rather than on your filesystem.

You can use your own version of the WordPress code by mounting a directory on your host into the container’s /var/www/html directory. You could get fancier and mount just /var/www/html/wp-content/uploads, leaving the container to handle the WordPress code but keeping all your uploads safely stored on the host filesystem.

After fooling around with this container for a while, I found myself disliking many of the assumptions in it. I wanted to increase the PHP max upload limit, for example, and enable some means for the container to send emails for password resets, and more. Both of these could be resolved by modifying the Dockerfile, or by mounting a directory full of PHP configs I wanted into the container. But this struck me as fighting against the grain, and not a good use of time or energy.

Using the examples of the official PHP and WordPress containers, I set out to construct my own Dockerfile to build the container that I wanted. I elected to not bundle the WordPress source code into the container itself. Rather, the container was intended to just hold the PHP runtime environment. This allows me to more easily upgrade WordPress source code without rebuilding the container. It also allows me to re-use this PHP container for other things beyond WordPress, like tt-rss or any other PHP scripts I might desire to run.

Because I’m planning on mounting the WordPress code into the container, and because the container may write files to the volume (user uploads for example), it’s important to understand what UID and GID the container will be using, and which UID and GID own the files on the host file system. On Debian-based systems, the www-data user has UID and GID 33. Inside the Alpine container, UID and GID 33 belong to the xfs user. On Fedora based systems, the apache user has UID and GID 48, and no default Alpinx users use these UIDs and GIDs.

What this means is that on Debian-based systems, if you make your WordPress files owned by www-data:www-data you’ll need to remove the xfs user and group from the Alpine container and then create the www-data user and group with the appropriate IDs. Or you could run your php-fpm processes as the xfs user, I suppose, but that seems like it would get confusing. Alternately, you could use nobody to own files consistently between the host system and the container; but this doesn’t quite feel right to me. On Fedora systems, you only need to create the apache user and group with ID 48, and thankfully not worry about removing any existing users from the container environment.

The other interesting thing I did with my Dockerfile was to include ssmtp for sending email. ssmtp does not provide a daemon, and does nothing more than transmit mail from the host to an actual mail server. This makes it easy for WordPress to send password change emails without having to mess with a full-blown MTA, and avoids another daemon running inside the container (or a linked container just to send email).

The resulting container is just under 40 MB.

It’s up to you if you want to run MySQL in a container. The MariaDB container is almost 400 megs, but gives you a single thing to manage. I did use the MariaDB container, and I mount /var/lib/mysql into it, so that my database files would persist beyond the life of the container. This also makes it easier to upgrade the container or switch to a MySQL variant, or abandon a containerized database and switch back to native packages from my host OS, if I so choose, without worrying about exporting my database.

To build and use my Dockerfile, clone it some place convenient and run the following commands:

mkdir skpy-dockerfiles
cd skpy-dockerfiles
git clone .
cd php
sudo docker build -t skpy:php .
sudo docker run -d -v /var/lib/mysql:/var/lib/mysql --name mariadb mariadb:latest
sudo docker run -d -p 9000:9000 -v /var/www/html:/var/www/html --link mariadb --name php --restart=always skpy:php

When installing WordPress, simply use mariadb as the database host. If you need to do manual database activities, you can use docker exec commands to execute mysql from within the container. For example, I take a regular snapshot of my databases from a cron entry on the host machine:

/usr/bin/docker exec mariadb sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --opt wordpress' > /home/skippy/BACKUP/wordpress-${DATE}.sql

One of the nuances about this that I’ve glossed over is that the directory holidng your WordPress code must be at the same absolute path on both the host and inside the container. That is why I’m mounting /var/www/html from the host to the container, rather than using some other directory to store the WordPress code. The reason for this is Caddy. When using the proxy directive, Caddy reads the requested URL and sends it on to the proxy back end based on the document root. If Caddy is configured to use /opt/web/wordpress as the docroot, it will send requests for this directory to the proxy. You could certainly modify the Dockerfile to configure php-fpm to use a different directory inside the container, but I elected for the path of least resistance and just used /var/www/html for both the host and the container.

One might ask at this point: why not run Caddy, or some other httpd daemon, in a linked container? The reason is purely preference. Caddy, specifically, is a single well behaved binary so I don’t see much value in running this single binary inside a container. I also specifically want to server some non-container content from my web sites, so linking data volumes or mounting a slew of host directories into the container seems overkill.

The Caddyfile I use is pretty straightforward. Here are the relevant bits:

# www to no-www https {
} {
  root /var/www/html

  # if you want to block all WP comments, try uncommenting this:
  #internal /wp-comments-post.php

  # WordPress rewrites
  rewrite {
    if {path} not_match ^\/wp-admin
    to {path} {path}/ /index.php?_url={uri}
  # the following two rewrites are only for sub-domain multi-site.
  # if you use top-level domain mapping, you probably don't need these.
  rewrite {
    r ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*)
    to ${2}
  rewrite {
    if {path} not_match ^\/wp-admin
    r ^([_0-9a-zA-Z-]+/)?(.*\.php)$
    to ${2}

  # here is where we send PHP requests to the php-fpm container
  fastcgi / php

  # don't log WP Ajax calls. It clutters up the logs.
  log /wp-admin/admin-ajax.php /dev/null

  log / /var/log/caddy/caddy.log "{when_iso} {hostonly} {remote} {method} {uri} {proto} {status} {size}" {
    rotate {
      size 100 # Rotate after 100 MB
      age 7 # Keep rotated log files for 7 days
      keep 10 # Keep at most 10 log files
  errors {
    log /var/log/caddy/error.log {
      size 50 # Rotate after 50 MB
      age 7 # Keep rotated files for 7 days
      keep 5 # Keep at most 5 log files

This has been working extremely well for me since I set it up. WordPress was as easy to install as it has ever been; and the new multi-site domain mapping no longer requires a plugin.

If you’re running a multi-site WordPress setup, you might want to tweak wp-cron.

I also highly recommend that you install fail2ban on your host, and then install the WP fail2ban plugin for your WordPress site(s). It’s relatively easy to install, and helps protect your site.

Finally, it’s worth noting that this configuration worked for me, based on my biases and preferences. I don’t claim that this setup is superior to any other setup. My needs are also far from mission critical, so I’m willing to deal with a number of operational rough edges (like the PID 1 problem referenced above). As with most things in technology, there’s more than one way to do it. If my experiences help you solve a problem, then I’ll consider it a success all around!

home / about / posts / notes / RSS