Sep 1, 2020

Local WordPress Development with Docker

For the past couple of months I’ve been working on Object Partners’ new WordPress site and I’ve learned a couple of things about doing WordPress development that I thought would be helpful to share. In this post I’m going to focus on how I got my local environment setup.

This was actually the hardest part for me. I had to learn some basics about WordPress and Docker, and refresh myself a bit on reverse proxying for local development. It took me a bit, but now I know I could do it in minutes.

I knew from the start that I wanted to leverage Docker for my local development so that I wouldn’t have LAMP stack remnants sitting on my computer when I was done working on the site. There are a lot of different ways to go about doing this but I chose to create a docker-compose.yml where I could manage all of my containers collectively.

TLDR;

For those who don’t want an explanation here’s the entire docker-compose.yml:

version: "3.8"
services:
  db:
    image: mariadb
    restart: always
    container_name: db
    env_file: .env
    volumes:
      - db:/var/lib/mysq
      - ./database/initdb.d:/docker-entrypoint-initdb.d
    environment:
      MYSQL_RANDOM_ROOT_PASSWORD: "1"
      MYSQL_DATABASE: $MYSQL_USER
      MYSQL_USER: $MYSQL_USER
      MYSQL_PASSWORD: $MYSQL_PASSWORD

  adminer:
    image: adminer
    container_name: adminer
    restart: always
    depends_on:
      - db
    links:
      - db
    ports:
      - 8080:8080
  
  wordpress:
    container_name: wordpress
    image: wordpress:php7.2
    restart: always
    env_file: .env
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: $MYSQL_USER
      WORDPRESS_DB_NAME: $MYSQL_USER
      WORDPRESS_DB_PASSWORD: $MYSQL_PASSWORD
    volumes:
      - ./wp-content:/var/www/html/wp-content
      - wordpress:/var/www/html

  wp-cli:
    image: wordpress:cli-php7.2
    container_name: wp-cli
    env_file: .env
    restart: on-failure
    depends_on:
      - db
      - wordpress
    volumes:
      - wordpress:/var/www/html
    command: wp search-replace 'https://{your domain goes here}' 'https://localhost.{your domain goes here}'
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: $MYSQL_USER
      WORDPRESS_DB_NAME: $MYSQL_USER
      WORDPRESS_DB_PASSWORD: $MYSQL_PASSWORD

  https-portal:
    container_name: https-portal
    image: steveltn/https-portal:1
    links:
      - wordpress
    ports:
      - "80:80"
      - "443:443"
    environment:
      STAGE: local
      DOMAINS: "localhost.{your domain goes here} -> http://wordpress:80"

volumes:
  db:
  wordpress:

For those who don’t want to decipher what I’m doing by looking at the code above what follows is a breakdown service-by-service.

First things first

I feel it’s important to stress that this is only to get a local version of a site running. The assumption is that you already have a functioning WordPress site, like I did with Object Partners’.

Before getting started you’ll want to get yourself a dump of the database. There are lots of great resources out there on how to do that so I won’t cover it here.

Once you have your database dump (a file that ends in .sql or .sql.gz) you’ll want to place it inside your project. I created a database/initdb.d/ folder and moved my dump into there.

I also want to point out that I’m using a .env file to store my secrets. When using a .env with a Docker service you’ll need to assign the path to that file to the env_file property for whichever service needs access to the secrets the .env contains. You can then reference the variables by using a $ prefix.

Finally, if you’re using some sort of version control, you’ll want to ignore your dump and your .env. Best not to leave any copies of a database or secrets lingering on the web for folks to wander across. Database dumps can also add a lot of bloat to your version control. If you’re part of a team there are other ways to share the dump and secrets can often be shared via a password manager or other secure means.

Okay, let’s dive in.

Setting up the database

  db:
    image: mariadb
    restart: always
    container_name: db
    env_file: .env
    volumes:
      - db:/var/lib/mysq
      - ./database/initdb.d:/docker-entrypoint-initdb.d
    environment:
      MYSQL_RANDOM_ROOT_PASSWORD: "1"
      MYSQL_DATABASE: $MYSQL_USER
      MYSQL_USER: $MYSQL_USER
      MYSQL_PASSWORD: $MYSQL_PASSWORD

A few things worth pointing out here. First I’m using MariaDB as opposed to MySQL. MariaDB tends to be a little more performant than MySQL so I tend to opt for it instead. Use whichever you prefer, the setup will be the same.

Second, notice the volume that is linking my database/initdb.d directory to the initdb.d in the container? When the container starts up it’ll use the dump that is in that directory to create the database that’ll be used for the WordPress site. The other volume is a virtual one that’s used for keeping the database around between starts/stops of our services so we don’t have to import each time.

Third, there are a lot of environment variables here. If you’re confused as to what these are, have a look at the Docker Hub docs for MySQL. When specifying a MYSQL_DATABASE with MYSQL_USER and MYSQL_PASSWORD the user that is created will have superuser privileges and a database will be created with the specified name. The MYSQL_RANDOM_ROOT_PASSWORD will print out to the service’s console a randomly generated password that can be used for root access. It’s not really needed in this case but it’s nice to have for emergencies.

It’s important here that the name of the database that was dumped from the production site matches with the MYSQL_DATABASE environment variable. If you’re not sure what it’s called you can often find it in the wp-config.php file located in the root of the WordPress project directory.

Setting up Adminer

Adminer is an alternative to the oft-used phpMyAdmin. Adminer seemed to have a simpler interface and all I really needed it for was as a GUI for managing my database. If you prefer to use phpMyAdmin you’ll need to modify this section.

Adminer won’t really be needed when setting up the project so this part can be omitted, if desired. I find it helpful to have around when the need arises so I tend to keep it.

adminer:
  image: adminer
  container_name: adminer
  restart: always
  depends_on:
    - db
  links:
    - db
  ports:
    - 8080:8080

The adminer service has the db as a dependency and they’re linked together. This means that Docker won’t start adminer until after the database is started and it’ll link the two together so Adminer can use the created database. Port 8080 on this container is being linked to the same port on my computer. This way I can navigate to localhost:8080 to access the Adminer interface.

Setting up WordPress

I suppose we should get around to actually creating our WordPress service, huh?

 wordpress:
    container_name: wordpress
    image: wordpress:php7.2
    restart: always
    env_file: .env
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: $MYSQL_USER
      WORDPRESS_DB_NAME: $MYSQL_USER
      WORDPRESS_DB_PASSWORD: $MYSQL_PASSWORD
    volumes:
      - ./wp-content:/var/www/html/wp-content
      - wordpress:/var/www/html

Like Adminer, this service depends on the db service.

It’s important to note that I’m using PHP 7.2 for this service; matching the version the production site is using. Not doing so can lead to bugs, locally. Remember that the point of this is to get a local copy of the production site running; best to match them up in this case. This can be modified to match whichever PHP version your project is using.

The WORDPRESS_DB_HOST environment variable tells WordPress where to look for the database it’ll be connecting to. In this case it’ll be looking at the db service directly. The other three variables are aliases for the ones set on the db service.

The first volume listed here is probably the most important. It sets the the service to mirror whatever is placed in the wp-content of our WordPress project to the same directory within the service. This means that any update to the theme or plugins in the project will be directly reflected on the local site.

The second volume is virtual, similar to the database. Its importance will become clear in the next service.

Setting up WP-CLI

When working on a preexisting site, like I was, you’ll need to replace all of the domain names with whatever you’ve opted to use as your local. For me, I opted to go with https://localhost.objectpartners.com. This means I needed to change every reference of https://objectpartners.com to the one prefixed by localhost. After talking to some of my colleagues I found out that the WordPress CLI has a search-replace function that can be used to do this.

wp-cli:
    image: wordpress:cli-php7.2
    container_name: wp-cli
    env_file: .env
    restart: on-failure
    depends_on:
      - db
      - wordpress
    volumes:
      - wordpress:/var/www/html
    command: wp search-replace 'https://{your domain goes here}' 'https://localhost.{your domain goes here}'
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: $MYSQL_USER
      WORDPRESS_DB_NAME: $MYSQL_USER
      WORDPRESS_DB_PASSWORD: $MYSQL_PASSWORD

This service is a bit unique. I only had a single purpose for the WordPress CLI: to update the domain name. Once that was done I didn’t want it to keep around using my computer’s resources so I set its restart property to on-failure. This means that this service will continue to restart until it succeeds. For this service success means running the command property which is set to the WordPress CLI command for searching and replacing; in this case the domain name is being replaced with the local one.

Here’s where that wordpress virtual volume comes in handy. WordPress CLI only works if it detects a running WordPress site so it needs access to the WordPress instance running in the wordpress service. With the two sharing their /var/www/html/ directories they’re virtually running in parity with one another.

The WordPress CLI image is, in essence, a WordPress image so it too needs access to the database, which is why the environment variables and PHP version are identical to the wordpress service.

Setting up the proxy

You’ll want to create some reverse proxy so that references to https://localhost.{your domain}/{some asset} resolve. If you don’t, your site might not even work from the onset since your browser won’t understand that the URL you entered is a reference to the wordpress services’ port:80.

I used the https-portal image for setting up my proxy. If you have the know-how and want to do this on your own, you’re welcome to it. For me, it was unnecessary overhead.

https-portal:
  container_name: https-portal
  image: steveltn/https-portal:1
  depends_on: wordpress
  links:
    - wordpress
  ports:
    - '80:80'
    - '443:443'
  environment:
    STAGE: local
    DOMAINS: 'localhost.{your domain goes here} -> http://wordpress:80'

This service is waiting for the wordpress service to startup. They’re also being linked together.

Here the ports 80 and 443 on the service are being tied to the same ports on my computer. The STAGE: local environment variable is used by https-portal for determining what environment is being worked in since some will need to be setup differently.

Finally, the DOMAINSenvironment variable assignment is for telling https-portal that port 80 on the wordpress service should be aliased to localhost.{your domain goes here} so that navigating to that subdomain will resolve to the local WordPress site.

Handling the virtual volumes

As I’ve already mentioned, there are a couple of virtual volumes being used: db and wordpress. So they’ll need to be added to the docker-compose.yml, too.

volumes:
  db:
  wordpress:

Almost there!

Now that the file is complete all that is needed is to navigate to the root of the WordPress project directory, where the docker-compose.yml is located, and run docker-compose up -d. This runs docker-compose in detached mode.

If at this point you immediately attempted to navigate to https://localhost.{your domain goes here} things will probably look a little broken. This is because it takes a bit for the database to get all setup and import the dump from the production site. It also takes a bit for https-proxy to complete its setup script on the first run. Once the database is setup WordPress CLI will update all of the domains to reference your local copy and you should be off to the races. Enjoy your locally running WordPress site!

About the Author

Hank Andre profile.

Hank Andre

Sr. Consultant

Hank has a passion for puzzles and by a stroke of serendipity found himself developing software. Since he has some minute experience in graphic design he initially felt drawn to the frontend, where he was (and is) passionate about accessibility, performance, and general user experience. In recent history he’s begun to find himself dabbling more and more with Node.js. Hank loves to teach, talk, and laugh.

 

Most of Hank’s posts are preview on his personal site first: hankandre.com

Leave a Reply

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

Related Blog Posts
Using Conftest to Validate Configuration Files
Conftest is a utility within the Open Policy Agent ecosystem that helps simplify writing validation tests against configuration files. In a previous blog post, I wrote about using the Open Policy Agent utility directly to […]
SwiftGen with Image & Color Asset Catalogs
You might remember back in 2015 when iOS 9 was introduced, and we were finally given a way to manage all of our assets in one place with Asset Catalogs. A few years later, support […]
Tracking Original URL Through Authentication
If you read my other post about refreshing AWS tokens, then you probably have a use case for keeping track of the original requested resource while the user goes through authentication so you can route […]
Using Spring Beans in a Kafka Streams ExceptionHandler
There are many things to know before diving into Kafka Streams. If you haven’t already, check out these 5 things as a starting point. Bullet 2 mentions designing for exceptions. Ironically, this seems to be […]