Better Programming

Advice for programmers.

Follow publication

How To Run Your Entire Development Environment in Docker Containers on macOS

Casey McMullen
Better Programming
Published in
13 min readDec 15, 2019

I’ve written several articles about setting up a PHP localhost development environment on various macOS machine versions using Homebrew, including a couple of articles for running MySQL 8.0 in a Docker container.

Now that I’ve upgraded to macOS 10.15 Catalina, with its read-only file system volume, I decided to lift my development environment entirely off of macOS and run it inside Docker containers. This opens up all kinds of possibilities. First, not only should I be able to run the exact same development environment across all version of macOS, but I should also be able to run these exact same containers on Ubuntu desktop if I chose to—or any other OS capable of hosting Docker. Second, by creating my development environment in containers I can easily set up my entire development environment on a new machine in a matter of minutes with only a few Docker commands.

In addition, by putting my Apache/PHP environment inside of a container, I’m able to run additional containers side by side with different versions of PHP or containers running Wordpress.

What We’re Going to Do

In this article, we’re going to create and launch three Docker containers. The first will contain our Redis server that we’ll use for PHP session management. By running the Redis server separately, we will more closely mimic a production environment.

The second container will follow the instructions I previously provided in my articles How to Run MySQL in a Docker Container on macOS with Persistent Local Data, and be a MySQL 8.0 server operating as a localhost MySQL instance using Native Password Authentication.

The third and final container will be our Apache/PHP 7.2 localhost server. To accomplish this, we’re going to use a Dockerfile that will bring together all of the components we want in our development environment, including the Xdebug, Redis, and Igbinary PHP extensions.

All three of these containers will be joined to the same Docker network so that Docker’s native DNS functions can come into play and the containers can talk to each other by name rather than IP address.

Let’s get started!

Housekeeping

Download and install docker

If you haven’t already, install the latest version of docker for mac from the stable channel: https://docs.docker.com/docker-for-mac/install/

Create local binding folders

We’re going to create some folders in our macOS user folder that will be used to hold config files for the Redis and MySQL containers as well as folders to hold the Apache and PHP log files so they’re easy to find and access. These will be bound during the Docker RUN statement to the appropriate folders inside the containers.

We’re also going to create a folder to hold the websites we build and this will be bound to the /var/www/html folder inside the PHP container.

Open Terminal and enter the commands below. Make sure to substitute your own macOS user name for [your_username].

Note. Personal preference: I like to gather up my development tool items into a folder called Develop. Feel free to adjust the location to your own liking.

mkdir /Users/[your_username]/Developmkdir /Users/[your_username]/Develop/docker_configs
mkdir /Users/[your_username]/Develop/docker_configs/mysql
mkdir /Users/[your_username]/Develop/docker_configs/redis
mkdir /Users/[your_username]/Develop/mysql_datamkdir /Users/[your_username]/Develop/logs
mkdir /Users/[your_username]/Develop/logs/apache
mkdir /Users/[your_username]/Develop/logs/php
mkdir /Users/[your_username]/Sites

Because the /Users folder is already recognized by Docker for file sharing by default, there is nothing we need to do within the Docker file sharing preferences.

Create a Redis config file

We’re going to create a config file for our Redis server. This is mostly just a placeholder file for you in the event you want to change other settings later on, but in this example, we are going to make sure we comment out the line to bind the Redis server to listen on a particular IP address. We’re going to allow the server in our development environment to listen on all IP address ports on the Docker network we’ll create in a later step.

Now we’re going to create a local redis.conf file that contains the settings we want to use to modify the config inside the Redis server container during Docker run time.

nano /Users/[your_username]/Develop/docker_configs/redis/redis.conf

Add the following two lines to your file:

# bind 127.0.0.1
protected-mode yes

Control + o to save.
Control + x to exit.

Create a MySQL config file

Now we’re going to create a my.cnf file that will contain a single setting that will set our MySQL 8 server instance to run using native password authentication rather than the default caching_sha2_password.

nano /Users/[your_username]/Develop/docker_configs/mysql/my.cnf

Add the following two lines to your file:

[mysqld]
default-authentication-plugin=mysql_native_password

Control + o to save.
Control + x to exit.

Create a PHP landing page

Let’s create an index.php landing page that we can use to verify our PHP server is working later.

echo "<?php phpinfo();" > ~/Sites/index.php

Disable the macOS version of Apache

macOS 10.15 Catalina comes with Apache pre-installed. However, instead of using the delivered version we’re going to be running Apache inside of a container and then bind port 80 to it.

If you already have the pre-installed version of macOS Apache running, it will need to be shutdown first and any auto-loading scripts removed. It doesn’t hurt to run both of the following commands, even on a fresh install.

Please note the second command is a single line that has wrapped due to page width constraints in Medium. Make sure to copy the entire line.

sudo apachectl stopsudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist 2>/dev/null

That’s it. We’re done with housekeeping. We have all of our local folders and files in place and we’re ready to start launching containers.

Create a Docker Network

Docker has built-in DNS. So we’re going to create a local network for our Docker containers to live in. By doing this, all of our containers will be able to communicate with each other by name rather than IP as long as all of the containers are added to the same network. All of the containers in my examples will be part of the same network.

You can name your network anything you want. Since we’re building a development environment, I will name it dev-network.

docker network create dev-network

Launch a Redis Server Container

Use the following command to launch the Redis server container we’ll be using for PHP session management:

docker run --restart always --name redis-localhost --net dev-network -v /Users/[your_username]/Develop/docker_configs/redis/redis.conf:/usr/local/etc/redis/redis.conf -d redis:5.0.6

Here’s what each of the parameters means:

  • --restart always will restart this container any time Docker is started, such as on laptop reboot or if Docker gets shut down and started again. Leave this parameter out if you want to start your own containers every time.
  • --name redis-localhost assigns this name to your container instance.
  • --network dev-network will join this container to the docker network that we created in the prior step.
  • -v /Users/[your_username]/Develop/docker_configs/redis/redis.conf:/usr/local/etc/redis/redis.conf will bind the config file inside the container to the config file we provided.
  • -d will run the container in detached mode, so that it runs in the background.
  • redis:5.0.6 indicates the official DockerHub Redis version tag 5.0.6 is the one to install.

Unless you already have the Redis Docker image downloaded to your laptop, the first time you run the above command it will download it. After that subsequent runs will be much faster.

You can run the following command to see if your container is running:

docker ps

Launch a MySQL 8.0 Server Container

Use the following command to launch the MySQL server container. Remember to substitute in your Mac user name for [your_username] and your favorite MySQL root password for [your_password].

docker run --restart always --name mysql-localhost --net dev-network -v /Users/[your_username]/Develop/mysql_data/8.0:/var/lib/mysql -v /Users/[your_username]/Develop/docker_configs/mysql:/etc/mysql/conf.d -p 3306:3306 -d -e MYSQL_ROOT_PASSWORD=[your_password] mysql:8.0

The parameters are all pretty well explained above for the Redis server and in my other articles so I’ll skip that detail here. Just note that you’re binding your mysql_data/8.0 folder to /var/lib/mysql inside the container, which will provide your persistent data after container reboots. You’re also binding your docker_configs/mysql folder to /etc/mysql/conf inside the container, which will override the MySQL server settings.

You can run the following command to see your current list of containers:

docker ps

Launch an Apache/PHP 7.2 Server Container

In this step, we’re going to use a Docker file to build a custom image that will contain Apache and PHP 7.2 along with the Xdebug, Igbinary, and Redis PHP extensions from PECL.

Once the build is complete we’ll launch the image into a container.

Create the Dockerfile

Begin by creating a location to store your Dockerfile that will become your Docker development environment:

mkdir /Users/[your_username]/Develop/docker_dev_env

Use the code found in the following Gist file to create a file called Dockerfile (with no file extension) and save it to the directory you created above.

If you use VSCode as your editor, I highly recommend installing the Docker extension. It enables color-coded syntax editing in Docker files.

Before we run the build command on this Dockerfile, let’s talk through what it’s going to do:

FROM php:7.2-apache# run non-interactive. 
ENV DEBIAN_FRONTEND=noninteractive
# update OS and install utils
RUN apt-get update; \
apt-get -yq upgrade; \
apt-get install -y --no-install-recommends \
apt-utils \
nano; \
apt-get -yq autoremove; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*

In this first section, we’re pulling from the official PHP Docker Hub repository, specifically pulling the 7.2-apache tag.

Then we’re indicating we want to run this Dockerfile in non-interactive mode, which suppresses prompts.

Next we run the basic Debian Linux updates and upgrades, install apt-utils and nano in our container and the do some housekeeping.

# make sure custom log directories exist
RUN mkdir /usr/local/log; \
mkdir /usr/local/log/apache2; \
mkdir /usr/local/log/php; \
chmod -R ug+w /usr/local/log
# create official PHP.ini file
RUN cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini

In the next section we create custom log directories for Apache and PHP. These will map to the local folders we created in the Housekeeping steps at the top of this article.

Then we copy the provided development version of the php.ini file to a runnable version of php.ini.

# install MySQLi
RUN docker-php-ext-install mysqli
# update PECL and install xdebug, igbinary and redis w/ igbinary enabled
RUN pecl channel-update pecl.php.net; \
pecl install xdebug-2.7.2; \
pecl install igbinary-3.0.1; \
pecl bundle redis-5.0.2 && cd redis && phpize && ./configure --enable-redis-igbinary && make && make install; \
docker-php-ext-enable xdebug igbinary redis
# Delete the resulting ini files created by the PECL install commands
RUN rm -rf /usr/local/etc/php/conf.d/docker-php-ext-igbinary.ini; \
rm -rf /usr/local/etc/php/conf.d/docker-php-ext-redis.ini; \
rm -rf /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

In the next section we’re installing PHP Extensions. First, we install the mysqli extension, which doesn’t install by default with PHP 7.2 in this case.

Next we use PECL to install Xdebug, Igbinary, and Redis with Igbinary set as the serializer. Then we enable those extensions in Docker.

The PECL install creates additional ini files that we delete because the reference to these extensions will be done in our own custom php.ini overrides.

# Add PHP config file to conf.d
RUN { \
echo 'short_open_tag = Off'; \
echo 'expose_php = Off'; \
echo 'error_reporting = E_ALL & ~E_STRICT'; \
echo 'display_errors = On'; \
echo 'error_log = /usr/local/log/php/php_errors.log'; \
echo 'upload_tmp_dir = /tmp/'; \
echo 'allow_url_fopen = on'; \
echo '[xdebug]'; \
echo 'zend_extension="xdebug.so"'; \
echo 'xdebug.remote_enable = 1'; \
echo 'xdebug.remote_port = 9001'; \
echo 'xdebug.remote_autostart = 1'; \
echo 'xdebug.remote_connect_back = 0'; \
echo 'xdebug.remote_host = host.docker.internal'; \
echo 'xdebug.idekey = VSCODE'; \
echo '[redis]'; \
echo 'extension="igbinary.so"'; \
echo 'extension="redis.so"'; \
echo 'session.save_handler = "redis"'; \
echo 'session.save_path = "tcp://redis-localhost:6379?weight=1&timeout=2.5"'; \
} > /usr/local/etc/php/conf.d/php-config.ini

In the next section above we’re creating a php-config.ini file in the php/conf.d folder inside the container. These configuration settings will override the php.ini folder at run-time.

Things to note:

We’re pointing error_log to the log folders we created earlier in this Docker file.

I made the following Xdebug overrides:

xdebug.remote_port = 9001
xdebug.remote_host = host.docker.internal
xdebug.idekey = VSCODE

This will enable remote debugging from VSCode into the Docker container (I use VSCode). If you use an IDE other than VSCode (e.g., PHPStorm, etc.), then you may need to experiment with these settings to get your Xdebug to work properly. However, because I’ve changed the port to 9001 rather than use the default port of 9000, PHPStorm may work out of the box.

In VSCode, just make sure you’ve got your Xdebug port pointed to 9001. Use the following VSCode launch.json file settings:

{
"version": "0.2.0",
"configurations": [

{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"port": 9001,
"log": false,
"pathMappings": {
"/var/www/html/":"/Users/[your_username]/Sites/"
}
},
{
"name": "Launch currently open script",
"type": "php",
"request": "launch",
"program": "${file}",
"cwd": "${fileDirname}",
"port": 9001
}
]
}

In addition we made the following Session overrides:

session.save_path = "tcp://redis-localhost:6379?weight=1&timeout=2.5"

This points our PHP session save path to the redis-localhost Redis server container we created earlier in this article.

# Manually set up the apache environment variables
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /usr/local/log/apache2
# Configure apache mods
RUN a2enmod rewrite
# Add ServerName parameter
RUN echo "ServerName localhost" | tee /etc/apache2/conf-available/servername.conf
RUN a2enconf servername
# Update the default apache site with the config we created.
RUN { \
echo '<VirtualHost *:80>'; \
echo ' ServerAdmin your_email@example.com'; \
echo ' DocumentRoot /var/www/html'; \
echo ' <Directory /var/www/html/>'; \
echo ' Options Indexes FollowSymLinks MultiViews'; \
echo ' AllowOverride All'; \
echo ' Order deny,allow'; \
echo ' Allow from all'; \
echo ' </Directory>'; \
echo ' ErrorLog /usr/local/log/apache2/error.log'; \
echo ' CustomLog /usr/local/log/apache2/access.log combined' ; \
echo '</VirtualHost>'; \
} > /etc/apache2/sites-enabled/000-default.conf

In the final section we’re implementing our Apache server overrides, enabling the rewrite and servername modules, and creating 000-default.conf file override.

Build the Development Environment Image

Using terminal, navigate into the new docker_dev_environment folder you created that holds your Docker file.

cd /Users/[your_username]/Develop/docker_dev_env

Now we’re going to run the Docker build command that will use the Dockerfile you just created to create a new custom image. We’re going to name our image dev-environment.

docker build -t dev-environment .

When you see the message, Successfully tagged dev-environment:latest your image will be built and ready to launch into a container.

If you want to see a list of the Docker images you have so far, you can use the following command:

docker images -a

This will show you a list that looks something like this:

Launch the Development Environment Image into a Container

Now we’re going to launch our new dev-environment image into a container.

docker run --restart always --name www-localhost --net dev-network -v /Users/[your_username]/Sites:/var/www/html -v /Users/[your_username]/Develop/logs:/usr/local/log -p 80:80 -d dev-environment

Let’s break down these Docker run settings:

  • --restart always will restart this container any time Docker is started, such as on laptop reboot or if Docker gets shut down and started again. Leave this parameter out if you want to start your own containers every time.
  • --name www-localhost assigns this name to your container instance.
  • --network dev-network will join this container to the docker network that we created in the prior step.
  • -v /Users/[your_username]/Sites:/var/www/html will bind the /Sites folder we created in the Housekeeping section at the top of this article to the /var/www/html folder inside the container. This sets your Sites folder as the root web directory.
  • -v /Users/[your_username]/Develop/logs:/usr/local/log will bind the /Develop/logs folder to the custom logs folder we created inside the container in the Docker file. Now your Apache and PHP logs will be easily accessible in Finder in your user folder.
  • -p 80:80 will bind port 80 on macOS to port 80 inside the container.
  • -d will run the container in detached mode, so that it runs in the background.
  • dev-environment tells Docker to launch the container using the development environment image we created in the prior step.

Verify Your Environment is Working

At this point you ought to be able to open a browser and navigate to your localhost. The index.php file we created in the Housekeeping steps should load right up. If everything is working you should see a screen similar to this one:

You should be able to scroll down through your PHP info and see that Igbinary, MySQLi, Redis, and Xdebug are all loaded and running.

Review

I know there seems to be a lot of steps here, but it’s really not too complex. It essentially breaks down into these six steps:

  • Housekeeping: Create all of the folders and config files that you’ll bind to in the Docker RUN commands. Now that you have these config assets built in the /Develop folder, I would recommend just saving that folder somewhere and you’ll have it for future loads.
  • Create a Docker network.
  • Launch the Redis container.
  • Launch the MySQL container.
  • Build the dev-environment image.
  • Launch the dev-environment container.

The beauty of running your development environment in Docker containers is that now you can literally set up your dev environment in minutes on a fresh load. You can also now freely run different containers with different versions of PHP or Wordpress without having to worry about settings on your local macOS. You could even run these containers side by side by binding to different ports. You could run your custom PHP container on port 80 and a Wordpress container on port 8080 and both could share the same MySQL and Redis containers.

Finally, running your dev environment in Linux containers like this will more likely replicate what’s running in production.

Useful Docker commands

Here’s a list of Docker commands I keep handy while experimenting with different changes in my Dockerfile:

  • View all images:
    docker images -a
  • Delete orphaned image artifacts (handy when a Docker build fails part way through):
    docker system prune
  • Remove Docker images:
    docker rmi [image_name]:[tag_name]
  • Bash into a running container:
    docker exec -it [containter_name] /bin/bash

That’s it! I hope this article helped you find a new way to run your PHP development environment on macOS. Please visit my other articles to learn other tips and techniques!

Casey McMullen
Casey McMullen

Written by Casey McMullen

Co-Founder & CEO at Another™ : Web Developer : Tech Geek : Guitar Player

Write a response