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

Object Partners profile.

One thought on “Local WordPress Development with Docker

  1. Alex Galey says:

    Thank you Hank for this really comprehensive guide explaining how to set-up a local WordPress development server with docker-compose ! I found the part with the db init especially useful 🙂

    Here some issues I faced :
    – wp-cli command didn’t work out of the box due to table prefixes in my db (it’s usual for WordPress db to have them).
    “Error: The site you have requested is not installed.
    Your table prefix is ‘wp_’. Found installation with table prefix: wp1234_.
    Or, run `wp core install` to create database tables.”

    I changed table_prefix value in wp-config.php file of the WordPress container. Do you have an idea for a cleaner way to do it ?

    – hsts seems default in https-portal which made it impossible to accept the security warning in firefox.
    I added max-age=0 directive to disable it :
    https-portal:
    […]
    environment:
    STAGE: local
    […]
    CUSTOM_NGINX_SERVER_CONFIG_BLOCK: add_header Strict-Transport-Security “max-age=0”;

    source : https://github.com/SteveLTN/https-portal

    – Maybe you forgot to tell that one should add the development domain to the hosts file ?

    – development domain localhost.{your domain goes here} redirects to http://www.localhost.{your domain goes here} and I can’t access the website

Leave a Reply

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

Related Blog Posts
Getting Started with CSS Container Queries
For as long as I’ve been working full-time on the front-end, I’ve heard about the promise of container queries and their potential to solve the majority of our responsive web design needs. And, for as […]
Simple improvements to making decisions in teams
Software development teams need to make a lot of decisions. Functional requirements, non-functional requirements, user experience, API contracts, tech stack, architecture, database schemas, cloud providers, deployment strategy, test strategy, security, and the list goes on. […]
JavaScript Bundle Optimization – Polyfills
If you are lucky enough to only support a small subset of browsers (for example, you are targeting a controlled set of users), feel free to move along. However, if your website is open to […]
Creating Mocks For Unit Testing in Go
Unit testing is an important part of any project, and Go built its framework with a testing package; making unit testing part of the language. This testing framework is good for most scenarios, but you […]