WordPress is easy. Docker is easy. There's a ready-made official container for WordPress... so why isn't this easy to do? In a word: networking.
Getting computers to talk to each other is an age old problem and virtualization hasn't changed that one bit. Running your code in a Docker container is effectively the same thing as running it in any other virtual machine or on another computer altogether. Under the hood on a Mac, Docker just uses VirtualBox. It's not all bad. Docker actually does the hard work of "making networking easy" but networking is still so difficult that you are bound to run into a few snags, particularly for local development.
Using virtual containers for local development can take much of the hassle out of configuring your dev environment and sharing it with your team. The toolbox for working with Docker is getting much better; with docker-compose and docker-machine it's getting trivial to manage your dev environment in the same repo where you manage your code (at least for getting started). Docker Hub makes it easy to manage your own repositories of servers and even keep them private.
All good? Not quite.
Docker is still designed primarily to be used by a professional who already knows how to manage servers in the cloud. Docker actually starts making more sense the closer you get to using it in production -- in particular, some of the local development roadblocks start to look like useful features in a cloud environment. There's a lack of documentation because of the newness of the technology and the general audience it serves. Anyone who is used to the extremely kind and coherent documentation that comes from PHP or MDN or from the WordPress community will already be familiar with how jarring it is to look up documentation about UNIX commands or configuring servers. Docker is all about UNIX commands and configuring servers and the documentation isn't always illuminating from a developers perspective. (Apologies in advance if you are a server pro and think the documentation is perfect because it doesn't waste any breath coddling the newbies).
For a developer who wants get up and running with docker there are a number of pitfalls. Digging through the issues is a dizzying experience for someone just trying to get a local development environment set up.
There's also a few terrible roadblocks.
This is written assuming you want to develop WordPress on a Mac using Docker to manage your local dev environment. You should have already installed the Docker toolbox. There is a really great tutorial for getting docker working smoothly on OS X. But it's probably best to just install the Docker toolbox for Mac. It will install VirtualBox for you if you don't already have it.
Currently Docker has a confusing ecosystem of command line tools. The good news is that they are rapidly working to upgrade everything to a more idiot-proof work flow. The bad news is they aren't there yet. In the meantime we're stuck with a few different commands and tools required to get things going. You'll be using a few different commands:
NOTE: Make sure you've at least done step 1.
You need to use docker compose for local dev. It allows you to set up a simple docker-compose.yml
file that will bundle all of the ugly docker
cli commands together for you. If you see a tutorial that has you typing docker ...
on the command line there's probably a better way to do that inside a docker-compose.yml
file. You'll see a bunch of documentation about firing up an image with something like: docker run --name wordpressdb mysql
. Don't be confused! If you were deploying to a production environment the docker
cli would make sense. For local development you should use docker-compose
.
Using docker-compose
you can get a server running in no time at all (sort of). There's actually a great tutorial that walks through the basics of using the official WordPress image. There's also a very light tutorial in the docker compose manual for getting started with docker compose and WordPress (although it's low on details and slightly out of date).
Docker has recently moved to a version 2 file format and it breaks a few things (namely, env variables for linked containers). If you're following an example that doesn't have version: '2'
at the the top of the docker-compose.yml
file it will work slightly differently. It's recommended to switch to version 2 because you get other cool features that you'll want to utilize later... so you're going to have to familiarize yourself with the differences while the documentation catches up.
If you want to start with the official WordPress image there's an unmerged pull request with an example docker-composer.yml
showing the new v2 syntax. It's essentially the same except there are more environment settings that are required compared to the Sitepoint tutorial linked above.
Create a docker-compose.yml
file. You'll want to open your terminal and do something like this:
$ mkdir tldr
$ cd tldr
$ touch docker-compose.yml
$ subl docker-compose.yml
Paste this into docker-compose.yml
version: '2'
services:
wordpress:
image: wordpress
ports:
- "8080:80"
volumes:
- data-web:/var/www/html/wp-content/uploads:rw
environment:
WORDPRESS_DB_PASSWORD: example
MYSQL_PORT_3306_TCP_PROTO: tcp
WORDPRESS_DB_HOST: db
db:
image: mariadb
volumes:
- data-db:/var/log/mysql
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
data-web: {}
data-db: {}
Docker is designed for people who know how to manage a computer from the command line. That means you have to configure your local environment or remember a bunch of commands. This can be confusing, especially if you set up docker a while ago and don't remember each step of the Docker machine get started tutorial. Later we'll be creating a simple way to write down and re-use all of the common commands that we need to run. They are part of the code you need to run your project so you should treat them as such and manage them with version control. More on that later.
With your docker-compose.yml
file created you should be able to start your server up easily from the command line in the same directory. Except... you can't run docker-compose up
from the command line until you set the docker environment variables.
$ docker-compose up
ERROR: Couldn't connect to Docker daemon - you might need to run `docker-machine start default`.
You need to ignore that error message -- it's misleading. The most likely problem is that your environment doesn't have all of the docker environment variables. (Be sure you have installed the docker toolbox). Thankfully Docker makes it easy with the docker-machine env default
command. TODO: add that command to your bash profile so you don't ever run into this again. FIXME: how to do that?
It's easier if you just run that command every time you try to run docker-compose
. It will look like this:
$ eval $(docker-machine env default) && docker-compose up
Creating network "tldr_default" with the default driver
Creating tldr_db_1
Creating tldr_wordpress_1
Attaching to tldr_db_1, tldr_wordpress_1
...
You should have a development server running from the previous step. What now? We would like to view our new WordPress site in a browser. Most tutorials completely skip this step or guide you into some complicated mess of looking up IP addresses. Networking is hard. Thankfully you can skip all of that and use a simple tool called Docker port forwarder which makes all of your docker images appear on your Mac's localhost.
Let's try opening our WordPress instance in Chrome (pro tip: you can do that form the command line).
$ open -a Google\ Chrome http://localhost:8080
That probably didn't work. Your docker images get weird IP addresses by default. Docker manages this for you and it's easy enough to get it working in a production environment. For development the random IP addresses are really annoying.
Now let's install docker-pf
and try it again.
$ npm install -g docker-pf
Start the port forwarder. This will forward the ports from those random IP addresses to your localhost so that it's easy to find your services. You need docker-pf
running for it to work. We'll cover how to make this slightly easier later on.
$ docker-pf
Now that the ports are forwarded we should be able to open our dev environment in Chrome.
$ open -a Google\ Chrome http://localhost:8080
That should be much better. Of course now we're in a small pickle. Our simple "up" command is getting messy.
$ eval $(docker-machine env default) && docker-compose up -d && docker-pf
In the docker-compose.yml file, we've added a port forward to the host machine so that the MySQL instance can be accessed via a GUI like Sequel Pro (which we use) or a similar tool. It serves the instance at the standard port (3306), and can be accessed using 127.0.0.1 as the host. TODO environment variables should feed into this so that we can only use this on dev, maybe staging.
We've added a neat shortcut to Sequel Pro by stealing this excellent shell script and adding it to our NPM shortcuts below. Once the environment is up and running, you can simply run npm run docker:db:open
and, assuming you have Sequel Pro installed, it'll open up right to your WordPress DB. Bitchin'.
One of the easiest ways to keep track of and run common commands is using a package.json
file and NPM. It is now common practice to utilize NPM as a build tool. Even though we're working with PHP, it's still going to be easier to store our commands in a package.json file. Once you've got a good flow going you may enjoy creating a bunch of shell scripts for managing these tasks. For now, NPM will be the easiest.
Let's initialize NPM inside the tldr
folder we created earlier. Follow the instructions, it doesn't really matter how you fill it out until you feel like publishing this package to NPM, which we won't be doing. If you want to, just hit the return key and use all of the default answers.
$ npm init
In the package.json file under scripts
there should already be a test command (that doesn't do anything). You can add others that are more useful. You could run tests using npm run test
. Let's add one called docker:env
and run it using npm run docker:env
.
$ subl package.json
Add a new docker:env
command:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"docker:env": "eval $(docker-machine env default)"
},
Now we can run it, although it won't really do anything useful just yet.
$ npm run docker:env
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"docker:env": "eval $(docker-machine env default)",
"docker:pf": "npm run docker:env -s && docker-pf 0<&- &>/dev/null & \n",
"docker:pf:down": "kill $(ps aux | grep docker-pf | grep -v grep | awk '{print $2}') || true",
"docker:up": "npm run docker:pf -s && docker-compose up -d",
"docker:down": "docker-compose down && npm run docker:pf:down -s",
"docker:shell": "docker exec -it $(docker ps --format \"{{.ID}}\" --filter \"name=wordpress\") bash"
},
Command | Description |
---|---|
npm run docker:env |
Sets the docker environment variables. This doesn't work exactly like you'd expect when you run it through NPM. It doesn't actually set the variables in your shell but in the environment that NPM is executing within. This is most useful when combined with other commands. |
docker:pf |
Starts the docker-pf port forwarding script in background mode. |
docker:pf:down |
Kills the docker-pf port forwarding script. |
docker:up |
Starts the port forwarding script and starts your servers. |
docker:down |
Stops the servers and stops the port forwarding script. |
docker:shell |
Launches an interactive shell in the docker machine. |
Adding your files to a docker image is dead simple and extremely versatile. You can mount individual files, or a whole folder. You can mount a folder within a mounted folder, you can mount folders from one container to the other. The only restriction is that the mounted volume must be on the same network as the container or it must be on the host. There is a poorly documented restriction that -- specifically on a Mac -- if you mount a folder from the host it will only be writable by root
or the docker:staff
user within the container. This is the biggest pitfalls of all for Mac users. In particular it's painful for WordPress development (because WordPress likes to write files).
What does this mean? It means that it's easy to mount your code from your laptop into your docker container but the docker container won't be able to write to it unless you fiddle with it first. But even then the fix seems a little wrong. There are many threads on this but the most famous one is ticket #581. More on volumes below.
If we wanted to be particularly lazy, we could mount all of WordPress like this: ./wordpress/:/var/www/html/:rw
. That's actually something fun to try because it shows just how much magic the WordPress image is doing under the hood.
Open your terminal and navigate to the tldr
folder we created earlier. We need to create an empty wordpress
folder. When you mount a folder it needs to exist, but it's cool if it's empty.
$ mkdir wordpress
Then edit your docker-compose.yml
file to mount your empty wordpress
folder as /var/www/html/
.
version: '2'
services:
wordpress:
image: wordpress
ports:
- "8080:80"
volumes:
- ./wordpress/:/var/www/html/:rw
environment:
WORDPRESS_DB_PASSWORD: example
MYSQL_PORT_3306_TCP_PROTO: tcp
WORDPRESS_DB_HOST: db
db:
image: mariadb
volumes:
- data-db:/var/log/mysql
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
data-web: {}
data-db: {}
Now we can launch our docker image and see what happens. Make sure to bring down any previously running containers, otherwise you'll get a warning.
$ npm run docker:down && npm run docker:up
$ open -a Google\ Chrome http://localhost:8080
Surprise! Your empty folder is now filled with a vanilla install of WordPress. Why? How? I thought that it wouldn't be able to write to volumes mounted from a Mac host? Well, the WordPress image does some checks when it starts up using an entrypoint.sh
script that runs as the root
user. This is the script that installs WordPress on the image. And since we mounted our folder where the image normally puts WordPress, it put WordPress in our folder! What's more is that the script looks to see if WordPress already exists in that folder before copying any files into it. In production you could mount your own WordPress installation like we have done here and everything will just work.
Note: the entrypoint.sh
script does some specific checks for the existence of WordPress and if you're doing something exotic, like using Bedrock Wordpress, you'll get some unexpected results -- because it won't find the specific files it looks for -- unless you overwrite the entrypoint.sh
script. More on that later.
You can view the site in a browser and it should work fine. But try to install a plugin through the admin and you'll see the dreaded "FTP" page that indicates that WordPress doesn't have write permissions. Additionally, if you go through the WordPress install wizard the wp-config.php
won't be updated either -- no writes from WordPress itself because it runs as www-data:www-data
, well after the entrypoint.sh
has done its work. If you bring the site down and start it back up we'll be back at square one; staring at the install wizard. Again, in production this won't be such a problem because it will be able to perform writes properly on your mounted persistent data volume.
Let's augment our docker-compose.yml
file again. For simplicity we'll presume you've got some local files that look like this the following directory structure. Basically, you should have something that looks like a vanilla WordPress install on your local.
tldr/
wordpress/
wp-admin/
wp-content/
plugins/
themes/
upgrade/
wp-includes/
docker-compose.yml
package.json
Now we can add a new volume to our docker-compose.yml
file, like ./wordpress/wp-content/themes/:/var/www/html/wp-content/themes/:ro
. The WordPress container will automatically snag the latest WordPress and place it inside of /var/www/html/
within the container. All we have to do is mount the files we wish to change into that folder and the container will serve our local files instead of the files from the vanilla WordPress install. The benefit here is that it's best practice to leave the vanilla WordPress files untouched, usually you only need to edit files in the wp-content
folder.
Note: When you mount a docker volume you can specify :ro
for "read-only" or :rw
for "read-write". If you're mounting a volume from a Mac host the :rw
won't work exactly as expected for the reasons outlined above and below. Specifying :ro
will prevent even the root
user from writing to the folder.
Below I'm mounting only the themes
folder and the wp-config.php
file. As a best practice you should individually mount only the files and folders that are unique to your project. Keep in mind that WordPress won't be able to write to folders you mount from a Mac host. It's fine to be surgical about it and mount very specific things. Also, the uploads folder is mounted to a docker data volume. TODO: I can't find a good explanation on how they work.
- TODO: What files and folders does someone need to mount for a typical vanilla WordPress install?
- TODO: What's the best way to manage plugins? There is a good example of doing this with an environment variable in the visiblevc/wordpress-starterwordpress-starter but that's extra
entrypoint.sh
script magic. - FIXME: This doesn't actually work as expected because the default
entrypoint.sh
script tries to overwrite the/var/www/html
folder if it doesn't detect WordPress. Our mounted files and folders will already exist by the time the script runs, but since the script didn't detect a full WordPress install it will overwrite the entire/var/www/html/
directory, including the files we mounted. If you mount them as:ro
they won't be overwritten but the entrypoint script will fail and your server won't start properly. In a production environment you'd probably just mount all of WordPress to have more control over which version is running, etc. For local development you'd want this to just work. The means we're going to need to craft our ownentrypoint.sh
script to change the default behavior.
version: '2'
services:
wordpress:
image: wordpress
ports:
- "8080:80"
volumes:
- data-web:/var/www/html/wp-content/uploads:rw
- ./wordpress/wp-config.php:/var/www/html/wp-config.php
- ./wordpress/wp-content/themes/:/var/www/html/wp-content/themes/:ro
environment:
WORDPRESS_DB_PASSWORD: example
MYSQL_PORT_3306_TCP_PROTO: tcp
WORDPRESS_DB_HOST: db
db:
image: mariadb
volumes:
- data-db:/var/log/mysql
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
data-web: {}
data-db: {}
- An advanced example of how volumes work
- The original volumes proposal
- The original volumes problem
- The problem exists in kitematic as well.
- Everyone is having the same problem.
As mentioned above, volumes mounted from a Mac host are only writable from root
or the docker:staff
user. The best work around for this is discussed in the official docker Mysql image repo. In ticket #99 someone is trying to mount a volume into their mysql container so that all of the database writes will be written to the host machine. Conceptually, this is the easiest way to persist the database when you're working locally with docker -- except it doesn't work because Mysql can't write to the mounted folder. In a server environment the mounted volume might be on a persistent storage container in your cloud. On your local machine that seems like overkill and most users would rather just store it on their host machine. Because the host machine is a Mac, the file permissions don't match up and the permissions default to root
and the docker:staff
user (which is problematic because the mysql container writes files as the mysql
user). The suggested workaround is to have the mysql
user within the container have the same privileges as the docker:staff
user. This feels a little hacky but it's reasonably easy to do. This same approach would solve our WordPress problems, we could extend the official WordPress image and alter the user permissions for the www-data:www-data
user to match the docker:staff
user.
But there's actually an easier solution... don't mount volumes that the server needs to write to. In practice there should rarely be a case where the server is writing changes to your files that you'll want to commit to your repository. For WordPress development that isn't 100% true but it's a good rule of thumb. Mounting writable volumes is hard, and it's bad practice to write to them anyway. In the MySQL example, mounting a writable volume is the only way to have your data persist properly in a cloud environment. After looking through some other WordPress Docker setups it seems that for local development it may not be necessary to have the DB mounted locally at all. It's probably easy to just use sql dumps so that you can easily control which mock data to load.
TODO: finalize this approach into a workable solution. It will require creating our own entrypoint
script.
Here's an outline of our approach:
- It's possible to
ADD
files from your host machine. This copies them into the container instead of mounting them. The one caveat is that once you copy files into the container you can't easily edit them. You only want to do this for files that don't change -- like vanilla WordPress. - For WordPress, only mount the files and folders that you need to edit during development. This will make it easy change your files and see the results on the dev server instantly without having to restart anything.
- If you need to grab a server-written file you can always use
docker cp
to copy it from the container to the host. You could also write a command that did this for you if you regularly needed to pull down or push up some specific files. - For the database, it's probably better to have an entry point script that loads a
backup.sql
from a mounted data directory. - You can easily write a command to back up the database and store it on the host using
docker run
and even edit ournpm run
commands to automatically back up the db before callingdocker-compose down
. Since it's a dev database it's probably not critical to have the real mysql data directory unless you had a clear idea of what you were going to do with it. For production you'd follow best practices for persisting your mysql data volume in your cloud.
Because you can mount files into container as much as you please you can do some pretty crazy stuff.
To get any further with our installation, we need to replace the entrypoint.sh
script that the official WordPress image uses with one that plays nicer with our desired workflow. There's actually great example in ticket #99 of overriding the default entrypoint from your docker-compose.yml
file. We need to make our own shell script, mount it into the wordpress image and add an entrypoint
to our docker-compose.yml
file to execute that script before it starts the server.
First we need to create our shell script. In our tldr
folder from below, let's make one.
$ touch run.sh
$ chmod +x run.sh
$ subl run.sh
Add some content to our shell file. Below is a minimal file that does nothing and won't really work.
#!/bin/bash
set -e
# shell magic goes here
exec "$@"
Update our docker-compose.yml
file to use our new entrypoint, skipping the default entrypoint. We need to mount the entrypoint script with ./run.sh:/run.sh:ro
, we need to add an entrypoint
and redeclare the command
from the default Dockerfile.
version: '2'
services:
wordpress:
image: wordpress
ports:
- "8080:80"
volumes:
- data-web:/var/www/html/wp-content/uploads:rw
- ./wordpress/wp-config.php:/var/www/html/wp-config.php
- ./wordpress/wp-content/themes/:/var/www/html/wp-content/themes/:ro
- ./run.sh:/run.sh:ro
environment:
WORDPRESS_DB_PASSWORD: example
MYSQL_PORT_3306_TCP_PROTO: tcp
WORDPRESS_DB_HOST: db
entrypoint:
- /run.sh
command:
- apache2-foreground
db:
image: mariadb
volumes:
- data-db:/var/log/mysql
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
data-web: {}
data-db: {}
With this in place our server should start up just fine and we should see an error page ("You don't have permission to access / on this server.") in the browser. This is a good thing! We got our server to run and we bypassed all of the junk that the default entrypoint script usually does for us. Of course this means we didn't actually get wordpress installed, but that's ok! We can fix that in the next steps by adding things to our custom entrypoint.
$ npm run docker:down && npm run docker:up
$ open -a Google\ Chrome http://localhost:8080
At this point we're somehwat bending over backwards to use the default WordPress image when it ultimately does things we don't want it to do. If you're not an expert of shell scripts, starting from a blank slate can be daunting. So, what next? Why not create a new image that does roughly what the WordPress image does and alter the parts of it that don't work the way we wany. If it wasn't explicitly stated before, we're trying to get the Bedrock version of WordPress running, so we're going to have to make a few changes.
There are a couple of good examples of a Dockerfile
for WordPress. The officical WordPress Dockerfile, the WordPress Starter from visiblevc are good starts but the Bedrock Docker image from pcfreak tackles our Bedrock-specific problems using the official Wordpress image as the starting point. Ultimately it's a matter of taste how to blend all of these together. Each of them has a not-quite-perfect mix of features. For now we'll ignore that all of the images I'm referencing are using Apache even though all of the cool kids are using Nginx and PHP-FPM.
As a rough start, let's create a new folder servers/wordpress/
and add some files. Presume we're in our tldr
forlder from before. You can see examples of these files in this repository. They're too long to paste here.
$ mkdir -p servers/wordpress
$ touch servers/wordpress/docker-entrypoint.sh && chmod +x servers/wordpress/docker-entrypoint.sh
$ touch servers/wordpress/Dockerfile
$ touch servers/wordpress/php.ini
version: '2'
services:
wordpress:
# use our custom wordpress image
build:
context: ./servers/wordpress/
dockerfile: Dockerfile
ports:
- "8080:80"
volumes:
# mount your bedrock site as /var/www
- ./bedrock/:/var/www/:rw
links:
- db:mysql
environment:
# TODO: how do these interact with the .env file? Seem to override it.
# TODO: WP_ENV: development|staging|production should `cp .env.${WP_ENV} .env`
DB_NAME: wordpress
DB_USER: root
DB_HOST: mysql:3306
DB_PASSWORD: pass
WP_HOME: http://localhost:8080
WP_SITEURL: http://localhost:8080/wp
db:
image: mariadb
volumes:
# mount your sql backup files to be imported on server launch
- ./data/:/docker-entrypoint-initdb.d/
# mount mysql data files to a docker volume
- data:/var/log/mysql
environment:
#MYSQL_DATABASE: wordpress
MYSQL_ROOT_PASSWORD: pass
volumes:
#TODO: what precicely does this directive do?
data: {}
You can see these new scripts in the package.json
in this repository. Here is the updated docs below
Command | Description |
---|---|
npm run docker:env |
Sets the docker environment variables. This doesn't work exactly like you'd expect when you run it through NPM. It doesn't actually set the variables in your shell but in the environment that NPM is executing within. This is most useful when combined with other commands. NOTE: hmmmm. This needs explored. |
npm run bedrock:init |
Downloads a fresh copy of Bedrock WordPress to a local bedrock directory. |
npm run docker:pf |
Starts the docker-pf port forwarding script in background mode. |
npm run docker:pf:down |
Kills the docker-pf port forwarding script. |
npm run docker:build |
Builds or rebuilds the images. Useful when you make changes to your Dockerfile or any of the files that are mentioned in your Dockerfile , like the docker-entrypoint.sh . |
npm run docker:up |
Starts the port forwarding script and starts your servers. |
npm run docker:up:daemon |
Starts your servers as a background daemon. |
npm run docker:down |
Stops the servers and stops the port forwarding script. |
npm run docker:down:dump |
Dumps the DB to your local and then stops the servers. |
npm run docker:shell |
Launches an interactive shell in the docker machine running WordPress. |
npm run docker:shell:wordpress |
Launches an interactive shell in the docker machine running WordPress. |
npm run docker:shell:db |
Launches an interactive shell in the docker machine running your DB. |
npm run docker:db:dump |
Zips up any existing dumps and then dumps the current schema and data to your local data directory. |
npm run docker:db:open |
Launches Sequel Pro with an open connection to your current WordPress MySQL database. |
npm run docker:db:dump:schema |
Zips up any existing schema dumps and dumps the current schema to your local data directory. |
npm run docker:db:dump:data |
Zips up any existing data dumps and dumps the current data to your local data directory. |
npm run docker:db:roll:schema |
Zips up any existing schema dumps and labels them with a timestamp in your local data directory. |
npm run docker:db:roll:data |
Zips up any existing data dumps and labels them with a timestamp in your local data directory. |
In a fresh directory we can get WordPress running very quickly.
$ git clone https://github.com/heygrady/docker-wordpress-starterkit.git tldr && cd tldr
$ docker-machine start default || true && eval $(docker-machine env default)
$ npm run bedrock:init
$ npm run docker:up:daemon
# wait for everything to initialize, might take a bit
# it runs composer install for you
$ open -a Google\ Chrome http://localhost:8080
# go through the install completely
$ npm run docker:db:dump
$ npm run docker:down
# now when you bring the servers back up
# you'll have your data in place
$ npm run docker:up:daemon
# need a MySQL GUI?
$ npm run docker:db:open