WordPress on Docker: The 1-2-3 approach

There’s an official WordPress docker image on the hub. Which means I have no good excuse to go make my own. Here’s my bad excuse: The official approach contains Apache and WordPress files all mashed up in one image. This feels icky to me partly because I don’t know Apache, having decided early on to hitch my wagon to Nginx, partly because it feels un-containerly to have everything in one big pot.

I cannot argue the merits and demerits of the official image with regards to performance, scalability, or security. What I saw was an opportunity to solve the problem in a way that felt more correct to me – 1 network, 2 volumes, 3 containers,  – that would also work as a learning experience. Here’s how I did it.

Disclaimer: I am neither a sysadmin nor a network professional of any kind. The following setup may be very bad practice indeed or riddled with security holes so it should probably only be used as a way to learn about docker and LEMP. For the same reason I am not publishing the images or compose files.

WordPress requires

  1. a web server
  2. a PHP processor
  3. a MySQL server
  4. the WordPress package files (php, html, css, etc.)

Because I’m doing this from scratch in 2018 I want to avoid using legacy approaches, i.e. links (rather than networks), data containers (as opposed to volumes) and in general comply with best practices.

1 through 3 will require their own containers, whereas 4 will live on a volume of its own. All containers will share a dedicated docker network for reasons that we will discuss shortly. Finally, the three applications may require added volumes for config files (in case it turns out that ‘baking in’ config files in the image will not do).

For 1 I will use Nginx simply because it’s what I know. The containerized web server will bind port 80 to some high number port on the host to which  the host will forward requests.

2 can be in the form of a module such as Apache’s mod_php or a standalone process like PHP-FPM. Since Nginx has no PHP processing capabilities of it’s own, I’ll go for the standalone approach.

3 can be either MariaDB or Oracle’s MySQL. I picked MariaDB due to an admittedly somewhat kneejerk anti-Oracle stance. The images probably differ more than the actual applications themselves and as will become apparent I did have enough issues with the hub MariaDB image to warrant creating a child image. Go with what works for you.

4 will be placed on a named docker volume. This means that WordPress will be able to update without updating the image or separating core updates from plugin/theme updates as I believe the official image does. Again I cannot say for sure which is the more secure route, only that this separating out of user content felt needlessly complex. My guess would be that updating the image and thus web server, PHP processor and WordPress core all in one would ensure a) compatibility between the three and b) less work for the maintainer (one update, not three). Since I would still need to update the Nginx and PHP-FPM images (for other containzed apps relying on them) as well as staying on top of plugin updates, I don’t see that much of an advantage here.

Note that this is not intended as an all-bases-covered tutorial in setting up a docker application. If what is required is a quick WordPress-on-Docker fix, I recommend the official image. It does what it says on the tin. In the following I will go over the bits that required me to stop and think about but a lot of the ‘mundane’ docker stuff I will pass over.

Network

I’m opting for a dedicated subnet for my WordPress setup for a number of reasons. First and foremost, it will allow me to access the containers by name rather than IP address. I’m hazy on the details but essentially, I can ping container_a from container_b without knowing container_a’s IP address. As I have seen IP addresses vary from startup to startup even with the same docker-compose file (due to shifting order of startup, I presume?), this seems helpful. Secondly, the less we need to publish on the host machine’s ports the better. The only things we actually need to expose/publish is the nginx container’s port 80; other stuff like the mysql server’s port 3306 or php’s port 9000 only need to be accessed by other container’s in our little subnet. And finally, it just feels more correct to have it cordoned off in it’s own subnet.

docker network create --subnet=xxx.xxx.xxx.0/24 --gateway=xxx.xxx.xxx.1 wp_net

I create the network while specifying the scope of the subnet – the nonfixed 8 bits – and the gateway. I don’t have to specify subnet or gateway – Docker would do that for me if I left it out – I just have a weird preference for a specific subnet and a slightly OCDish approach.

Mariadb

Let’s start at the bottom of the pyramid: the database. As with any database for any purpose, keeping your data files in the container is bad idea, seeing as they will vanish as soon as the container goes down. So I start by creating a dedicated database volume that will be mounted to /var/lib/mysql. Good news is I don’t have to prep it in any way.

The main challenge with getting mariadb to work as I wanted was to allow other containers (well, the php one at least) to find and access it. There are three ways to do this (when you exclude the deprecated links method):

  1. Find a way to ensure that the mariadb container gets a fixed IP address.
  2. Allow access to the mariadb container from a large range of IP addresses.
  3. Allow access to the mariadb container for  a user on a named host.

To be honest, I never really explored 1. While it’s a perfectly common solution with a standard DHCP server like the one on my router, it just didn’t really enter my mind. I did find solutions for the second on StackOverflow but the idea felt unsatisfactory. I don’t think the security implications are that serious but it seemed wrong to just throw the door open to any container, anybody managed to get on to the subnet.

The third approach should have been easy. First I create the database and a named user on a named host

create database my_wp_db;
create user 'myuser'@'myphpcontainer.wp_net';

Then grant access to said user on said host and watch everything fall into place:

grant all privileges on my_wp_db.* to 'myuser'@'myphpcontainer.wp_net' identified by 'mypassword';

But I kept running in to an issue it took me some time to troubleshoot. Php had no problem finding the named database container or it’s port 3306 but the docker logs kept telling me that mariadb actively denied access to the user on the php container. Why? Show grants for myuser told me the grants were in place. Work, damn you, work.

Turns out, the docker guys have put a config file in place to stop such shenanigans, docker.cnf:

[mysqld] 
skip-host-cache
skip-name-resolve

skip-name-resolve means well, what it says. Mariadb will not try to resolve what IP address myphpcontainer.wp_net corresponds to or whether it matches the IP address of the php container client that tries to access it. skip-host-cache simply means that it will not cache those non-existent lookups either. 

Fortunately, all it takes is creating my own docker image that removes those restrictions with the following line in Dockerfile

RUN rm /etc/mysql/conf.d/docker.cnf

I was curious and a little anxious though, for surely there would have to be a pretty good reason for such a drastic step? Ehhhh…

But why is this option by default?

docker relies on the host DNS
containers uses the DNS configured in /etc/resolv.conf at creation time
if that DNS becomes unreachable (eg. disconnection, connection to a
new wifi, …) that DNS won’t work anymore
mysql authentication lags, waiting for client hostname resolution.

My 2ยข.
Peace, R.

ioggstream, https://github.com/docker-library/mysql/issues/154#issuecomment-211026309

I can’t see that being a problem here, in any case, so I run with my child image without the docker.cnf file.

PHP

The WordPress PHP setup wasn’t without speedbumps, either. I chose to use the Debian based php:fpm image as the base as the Nginx-FPM combo is what I’m used to. I also felt more comfortable working with debian than alpine linux and I wasn’t overly concerned with ressources as I’m not dploying hundreds of containers, just the one. Seeing as the PHP images are extremely barebones, it seems as though it is entirely expected that few will actually use the image as is but use them to build their own child images.

Though it is not restricted to Docker but indeed has been a mainstay of my WordPress setup issues, I wanted to tackle the issue of max upload size. Sure, it can be healthy to force yourself to upload images no greater than 1 Mb in size, sometimes you do need more. A medium sized GIF looping about ten seconds can easily come in at 5 Mb. The reason I’ve never got round to solving this problem is in all likelihood that it requires fixing in WordPress and Nginx as well as PHP. As for PHP the following settings in php.ini (or a sourced sub-config file) helped:

memory_limit = 215M
post_max_size = 210M
upload_max_filesize = 205M

I’m no PHP expert but as far as I understand memory_limit sets an upper limit for objects contained in memory and so the upper limit for uploads unless you write parts of the file to disk as it comes in. Within that space you have post_max_size, i.e. the maximum size of a post request (allowing some extra space for headers and stuff) and within that you have upload_max_filesize, or the max size of a file to upload (again, allowing some space for things other than the file itself).

More Docker-specific is the fact that as mentioned, the image by itself is kinda useless as any PHP application I’ve ever come across requires one or more extensions. The only way to add them is to create a child image that installs them. In addition to this, I was unable to locate an obvious directory to mount with custom ini files (/usr/local/etc/php-fpm.d would seem a likely candidate but it’s full of default config files already) which again pushed me towards the child image solution.

As for WordPress specifically, any recent install requires the mysqli extension. Confusing this issue was that WordPress apparently falls back on the older (deprecated?) mysql extension in case mysqli isn’t found. So my logs reported that the mysql extension was needed and missing. Fast forward a few hours spent scouring the internets for mysql for PHP 7.2 when the extension seems to have been left behind when the move to from PHP 5.6 to PHP 7 was made. Also throw an hour or two into the bin, spent trying to find the right .deb package to install when in fact I should have been using the image specific, docker-centric docker-php-ext-install tool to install it from some central PHP repository/equivalent to pypi and npm, like so:

RUN docker-php-ext-install mysqli

While this was sufficient for the WordPress install process, I quickly found that image editing capabilities (e.g. cropping an uploaded header image) were lacking from this build. Apparently WordPress relies on an extension called gd for this which again uses various libraries for png and jpeg specific tasks. I must admit that at this point I’m purely at the mercy of StackOverflow as I know naught about neither php nor image processing. Here’s what I copy-pasted into my Dockerfile and what did the trick:

# System image processing libraries
RUN apt-get update && \ apt-get install -y libfreetype6-dev libjpeg62-turbo-dev libpng-dev
# PHP extensions
RUN docker-php-ext-configure gd --with-freetype-dir=/usr/include/ \
--with-jpeg-dir=/usr/include/ &&
docker-php-ext-install gd

Nginx

I built my own nginx child image with a custom nginx.conf file intended to be used in conjunction with a volume mounted on /etc/nginx/sites-enabled. I specifically opted to build this on the foundations of the nginx:stable image because version 1.14 (i.e. stable as of late 2018) matches what the Ubuntu 18.04 repositories holds and the fewer variations in the setup, the better.

Following up from the PHP settings for max upload size, I also needed to instruct Nginx to increase the allowance for uploads. The following instruction is permissible in the overall http context and in individual server blocks:

client_max_body_size 215M;

For Nginx to reach my PHP-FPM container I only needed to specify the name of the container and the standard port number for fpm (9000) in the .php-catching location block.

fastcgi_pass wp_phpfpm:9000;

There is an argument to be made that sockets are a better mechanism for connecting to the php processor – even in Docker – but using ports requires no setup and takes advantage of my subnetwork. I may return to this if I find the time and a way of testing the difference in responsiveness.

The real challenge, however, was how to get Nginx and PHP to agree on scripts. First, I had to understand that the contents of scripts weren’t actually being passed around over port 9000, only the paths. First order of business therefore was to ensure that the WordPress volume was mounted on the same mount point in both the Nginx container and the PHP processor container so that paths being transferred from one to the other would still point to the script in question. In other words, I just needed to recycle the same docker run --volume parameter or docker-compose line.

Making sure that each container had the right permissions probably also helped. While the php:fpm image doesn’t use Docker to drop root, it comes with the following parameter setting line:

ENV PHP_EXTRA_CONFIGURE_ARGS --enable-fpm --with-fpm-user=www-data --with-fpm-group=www-data --disable-cgi

Which seems to result in php-fpm running similarly to nginx: A root main process handing off tasks to worker processes running as not-root. As a consequence I made sure that my nginx.conf file also used the www-data user and group for worker processes

user    www-data;
pid     /var/run/nginx.pid;

and that indeed the WordPress volume’s files were owned by www-data:www-data.

A lot has been said about the difference between the nginx files fastcgi_params and fastcgi.conf, the essence of which is that the former does NOT contain the parameter SCRIPT_FILENAME. However, the fastcgi_params file included in the official docker image does have the parameter and the image leaves out fastcgi.conf altogether.

I prefer to set this in the virtual server/location block itself as these, unlike the fastcgi_params file, are not part of the image itself. This way I can also keep the SCRIPT_FILENAME parameter together with the fastcgi_split_path_info directive and the PATH_INFO parameter.

The idea with fastcgi_split_path_info, as I understand it, is to close a potential security hole. Let’s say an attacker somehow manages to get a file on to the server (e.g. upload an image file to create an account) and that that ‘.jpg’ file is actually malicious code. The attacker would then want that code run through php-fpm to execute it but since it’s a ‘.jpg’ file it doesn’t trigger the php location block. However, if the location block for various reasons accepts files not just ending in .php but also files such as somefile.php/innocuous_jpeg.jpg we open ourselves up to bad things (of course, this assumes that the attacker also has managed to create a folder called ‘somefile.php’ and place the uploaded file into it) I don’t allow for accounts, uploads or anything but better safe than sorry. Here’s the full SCRIPT_FILENAME setup:

fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;

fastcgi_split_path_info resets the variables $fastcgi_script_name and $fastcgi_path_info so that the first contains everything up until ‘.php’ and the latter contains everything after. This should prevent .jpg files from getting executed as code while preserving any relevant information that may be contained by whatever follows ‘.php/’.

Obviously the Nginx container is the only one of the bunch with a need to publish ports. I still haven’t found a good mnemonic device for remembering that the docker run --publish parameter takes host port number colon container port number. Except for singing the Who song 5:15 (… inside, outside, leave me alone…) and then reversing the order. Or just remembering that the order feels incorrect (I’m on the outside looking in?) Or mentally connecting it with the same feeling of wrongness that I have with the parameter order of ln.

WordPress

As I mentioned earlier the WordPress files themselves were put on a volume and chown’ed to www-data:www-data. In a single test instance I had an issue with the installer being unable to create wp-config.php (which was then created manually from wp-config-sample.php) but I haven’t been able to replicate it since.

Needless to say the database details entered in wp-config.php or during the installer needed to match those I had put into mariadb. It should be noted, though, that the name of the container sufficed as DB_HOST, i.e. no need to append the name of the network (.wp_net) onto the host name.

The settings in the WordPress application also held the final key to increasing the maximum file size for uploads. Finding Network settings (on a multisite install, probably general settings on a standard install), I navigated down to Upload settings and increased the number in Max upload file size.

As I run sites on two different domains I cannot just rely on multisite but also need two separate WordPress installs. I have argued with myself whether or not that warrants duplicating this entire setup – which isn’t that hard to do – but ultimately have come down on the side of “no, that’s gross overkill”. Instead the WordPress volume simply contains two subdirectories, with their own copy of WordPress. Similarly, the nginx sites-enabled/conf.d directory holds two .conf files with virtual servers having their own root in their respective subdirectories, and Mariadb hosts two separate databases. A big win for convenience and ressource management, a slight loss for security?

Volumes

The remaining question is whether I need additional volumes to hold configuration files. The options for config files in decreasing order of convenience (of making changes on the fly) are as follows:

  • Config files reside in mounted directory on host
  • Config files reside in data volume
  • Config files are baked into image

The advantage of data volumes over mounted directories is that they are not “host dependent”. The advantage of baking in config files is avoiding littering the docker volumes output and making portability harder. The key criterion is obviously “how often do I need to change the config?”. Constantly rebuilding images in order to bump a 4 to a 5 to a 6 quickly becomes old.

Mariadb saves it’s configuration in the database so that’s a non-issue. What about php and nginx? To cut to the chase I opted for “baking” in the php case and – at least in the early days while preferences are still shifting – a mounted volume in the case of nginx. I can see sites, web server redirects, locations and so on shifting far more frequently than things like well, max upload sizes.

A thing to note with Nginx in particular, is that the official images seem to encourage mounting a volume solely for the head nginx.conf file. Just running the image without any parameters results in it complaining about a missing /etc/nginx/conf.file/nginx.conf. If I run it while mounting a directory containing an nginx.conf file to /etc/nginx/conf.file it works. My child image however, seems to change something so that my baked in /etc/nginx/nginx.conf suffices. I have, however, been unable to find any documentation of this behaviour.

Summing up

And that’s it. Whatever port I picked on the host now gives me access to a new WordPress install. I am saving the challenge of proxying requests from the host to the published port (and adding encryption) for another post as it’s quite a lot of work in it’s own right.

Hopefully this has been educational for others. While I don’t recommend this approach if you haven’t spent a fair amount of time with docker, I will gladly help with what I can if you’ve gotten stuck somewhere along the way. Let me know in the comments.

VOHBURG flickr photo by tsbl2000 shared under a Creative Commons (BY-NC-ND) license

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.