Installing Apache, MySQL, and PHP on macOS using Docker

Main Thread 14 min read

I know, Docker, gross right? Suppress that reaction for a few paragraphs…

For the last 8 years I've held one of the top search results for Installing Apache, PHP, and MySQL on Mac OS X. It wasn't until installing macOS Catalina that I began to move away from the preinstalled development tools I had preached for so many years.

The primary reason was the need for a newer version of PHP. I held hope the next version of macOS might adopt a modern version of PHP. However, it looks like macOS Big Sur will not upgrade PHP. In fact, Apple has added a warning about using the preinstalled PHP version and plans to no longer include it in future versions of macOS. All of which set the internet ablaze. Which 75% of is powered by PHP.

For those reasons, I am finally making the switch to using Docker for local development with Apache, MySQL, and PHP on macOS. This post will outline the process for a basic setup using Docker.

Before moving on to the actual implementation, let me address the two questions I still receive after all these years.

  1. Why not use Homebrew?
  2. Why use Docker?

Homebrew is a package manager for macOS. And when it works, it works. But when it doesn't you're going to burn a day searching the web trying to figure out some obscure error message. And you may get it working again. But it's only a matter of time until you receive another obscure error and burn another day. And when you upgrade macOS, you'll receive another error and the solutions before no longer work.

Yes yes, I know you don't have any problems. But it's happened to me enough times over multiple versions and multiple years. I've given it a chance. I don't want to waste anymore time on it.

If I'm going to spend days learning something, I'd rather learn something which brings value beyond a single purpose. Something I can use elsewhere or again, beyond my Mac. And Docker can be used for so much more than local development on macOS.

In fairness, I tried using Docker multiple times before. Similar to Homebrew I'd run into issues. But I didn't really give it a chance. In addition, Docker has made advancements since I tried over the years. Most notably having a default client for most platforms, including macOS and Windows.

The reality is, Docker is a simple client install and then a couple commands from the command line. Once using Docker, you have access to countless images to create all sorts of development environments, running things beyond Apache, MySQL, and PHP. You can set up a complete infrastructure which perfectly mimics your production environment running load balances, cache servers, queue workers, and more.

So, to address the matter simply - if I'm going to learn something I want to get the most return on my time investment. These days, I think learning a ubiquitous tool like Docker provides a far better return on my investment than learning how to wrangle a package manager on my local macOS.

Yes I know there's MAMP, Valet, and whatever other hotness. But they all run Homebrew underneath. With Docker I can take my image and provision local development environment, a production environment, a GitHub action, and so much more.

With that said, let's move on to getting a local development environment running Apache, PHP, MySQL on your Mac using Docker.


Installing Docker

Since this is a tutorial for macOS, download the Docker Desktop for Mac.

However, if you are using another platform, such as Windows, you may still follow along with this tutorial. That's another benefit of Docker. Once you have Docker installed locally, you can run anything you want.

Creating an image

With Docker installed locally, we need to tell Docker what type of server we want to run. We do this with an image file. Yes, I'm taking a few liberties with those terms.

There are all sorts of images available. As you become more proficient with Docker you can find (or create) one to better suit your application needs.

The one I'm offering web server running PHP and Apache. This effectively replaces the technologies which were originally installed on macOS by default.

Here are the specs for this image:

  • Ubuntu 18.0
  • PHP 7.4
  • Apache 2.4

In addition, this includes the latest version of Composer (2.0) and Git.

All this goes in a Dockerfile. Here's the one we'll be using:

1# PHP + Apache
2FROM php:7.4-apache
3 
4# Update OS and install common dev tools
5RUN apt-get update
6RUN apt-get install -y wget vim git zip unzip zlib1g-dev libzip-dev libpng-dev
7 
8# Install PHP extensions needed
9RUN docker-php-ext-install -j$(nproc) mysqli pdo_mysql gd zip pcntl exif
10 
11# Enable common Apache modules
12RUN a2enmod headers expires rewrite
13 
14# Install Composer
15COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
16 
17# Set working directory to workspace
18WORKDIR /var/www

You are welcome to copy the file above. However, it would be better to download my local-docker-stack repo as it will contain all the files we'll use within this tutorial.

I like to put this in my workspace as I share this image across all my projects. But if you have a single project or specific requirement, you're welcome to put this Dockerfile directly within your project folder.

What about MySQL?

Similar to the way we installed Apache, MySQL, and PHP on macOS, we will install MySQL separately. In this case, we'll pull in the latest official image for MySQL 8.0. Then we'll run these two side-by-side - more on that in a bit.

Building the Docker image

Now that we have an image we may turn this into a runnable server by running:

1docker build -t lamp -f images/Dockerfile-php-apache .

Before dissecting the command itself, let's talk a little bit about what it does.

Running this command will generate an executable version of our image. It builds a server if you will. Something we may run and interact with. This is what Docker calls a container.

Looking at the command we pass it the path to a Dockerfile. In this case, the images/Dockerfile-php-apache from within local-docker-stack repo. However, you may change this to wherever you store this Dockerfile.

We also set the -t option to gives our container a name. This makes it easier to identify when we run other Docker commands later.

Running the ~~server~~ container

Now that built our image into a container, we can run an instance of this container with the following command:

1docker run -d -p 80:80 lamp

With any luck this should spin up our web server running Apache and PHP in the background using the -d option. We can verify this by running the following Docker command:

1docker container ps

Since we mapped web port 80 with the -p option, we should also be able to open a browser and visit http://127.0.0.1/. You probably see an Apache error page. But hey, it's a start.

Interacting with the server

Cool. Our server is running. For the most part, you can carry on developing as normal and interact with the server via the browser.

However, at some point you'll need to interact with the server directly. So using the same command above we can get a reference to the specific running container instance. This was the ID column from the command we just ran.

So let's run it again:

1docker container ps

It will output something like the following:

1CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
296b0239fafa9 lamp "docker-php-entrypoi…" 6 seconds ago Up 5 seconds 0.0.0.0:80->80/tcp unruffled_grothendieck

We want the value from the ID column. In this example, it's 96b0239fafa9.

Using that we can pass that to the Docker exec command to get an interactive terminal by running:

1docker exec -it 96b0239fafa9 /bin/bash

Let's take a sec to dissect this command. It allows us to run an interactive terminal within the container instance we specified using the Bash shell.

Of course, you could build an image with whatever shell you like. But again since macOS defaults to Bash, that's what I'm using here.

Let's throw a few commands at it like php -v to see the PHP version and composer -V to see the Composer version. Then we'll exit the terminal with exit or by pressing Ctrl + D.

What's nice is this environment can mirror your production environment. It ideally has the same paths, operating system, software, and versions your production server has. So Docker simulates your actual application environment versus running Apache, PHP, and MySQL locally on macOS would have.

What's not nice about this, is the same thing that's not nice about Docker. It can be slow. You may notice a file system lag when interacting with files or installing things locally. For example, a file intensive command like composer install. Or even worse, npm install.

For those reasons, whenever possible I may still run these commands locally. Especially npm install as that may require system level components which are easier to install locally than on the container.

Fortunately such operations are not that common. So I've learned to live with it. Use the opportunity to take a break, stretch, or check email.

Alright, before moving on let's stop this container by running:

1docker container stop 96b0239fafa9

Mapping local files

Since we are editing files locally, we'll want to map these files to these Docker containers. We may do so by using volumes. These share the local filesystem with the Docker filesystem.

In this case, I want to share my workspace. This is the folder where I store all my web projects. For me it's ~/workspace. For you it can be anywhere you want. Just be sure to replace it with your path in the following references.

We'll also want to make a folder that will store the MySQL data. To do that we can run the following command.

1mkdir ~/data

Again, you are welcome to change this path just update any references accordingly.

Setting up the volumes

We're going to jump ahead just a bit and map these two external volumes for Docker which we'll use in the next section.

The following commands will create a volume named workspace mapping to /Users/jasonmccreary/workspace and a volume named data mapping to /Users/jasonmccreary/data. We may then reference these volumes by name instead of always typing their paths. Again, please change the paths accordingly.

1docker volume create workspace --opt type=none --opt device=/Users/jasonmccreary/workspace --opt o=bind
2docker volume create data --opt type=none --opt device=/Users/jasonmccreary/data --opt o=bind

Running the full LAMP stack

So far we've only run our web server. We haven't run the MySQL server. We need to have our complete Apache, MySQL, PHP stack running if we're going to develop locally on macOS.

We could do this with multiple docker run commands. Passing the -d option to run in the background, along with multiple options such a -p to map the ports and -v to set up the volumes. But that would be annoying.

Instead, we can define our stack with a Docker Compose file. Essentially this file defines all the same options we would pass to docker run, but in a single place. This not only provides us with a full picture of our stack, but also a new set of commands we may run to easily manage the entire stack.

Let's take a look at this docker-compose.yml file:

1version: "3.7"
2 
3x-defaults:
4 network: &network
5 networks:
6 - net
7 
8services:
9 php:
10 image: lamp
11 ports:
12 - 80:80
13 - 443:443
14 volumes:
15 - workspace:/var/www
16 configs:
17 - source: apache-vhosts
18 target: /etc/apache2/sites-available/000-default.conf
19 - source: php-ini
20 target: /usr/local/etc/php/conf.d/local.ini
21 <<: *network
22 
23 db:
24 image: mysql:latest
25 ports:
26 - 3306:3306
27 environment:
28 - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_pwd
29 - MYSQL_USER=dbuser
30 - MYSQL_PASSWORD=dbpass
31 volumes:
32 - data:/var/lib/mysql
33 secrets:
34 - db_pwd
35 <<: *network
36 
37 
38networks:
39 net:
40 
41secrets:
42 db_pwd:
43 file: ./mysql/root_password.txt
44 
45configs:
46 apache-vhosts:
47 file: ./apache/vhosts.conf
48 php-ini:
49 file: ./php/local.ini
50 
51volumes:
52 workspace:
53external: true
54 data:
55external: true

This may look a bit daunting. Honestly, we don't need to know all of the details. At a high level, we see we're setting up our lamp image and the official mysql image to run together. They'll do so under the same network. And we're passing all those options for the ports and volumes we talked about earlier.

You'll also notice we're defining a few additional settings. For example, some configuration files for PHP and MySQL, as well as a secrets file containing the root password for MySQL and a generic database user.

Again, all of these are included in the jasonmccreary/local-docker-stack repo for you to download.

Now that we have this file, we may run a single command to start the entire stack of these services running within the same network.

First, we need to initialize Docker for our stack. To do so, we'll run the following command just once:

1docker swarm init

Now we'll run our stack with:

1docker stack deploy -c docker-compose.yml dev

This takes the path to our docker-compose.yml file and a name of the stack. In this case, I simply named it dev. But you can call it whatever you want.

To see both containers running, we may run the docker container ps command from earlier. And based on its output, we may use the container ID to interact with either of the containers in the stack by passing it to docker exec.

Directing web traffic

Even though everything's running, our server is likely not directing web traffic to the appropriate location. Similar to our local install before we need to direct web traffic to our Docker web server.

Similar to configuring Apache virtual hosts on macOS, I do this by editing my hosts file. The only difference now is I use a .wip extension, rather than a .local. This sometimes conflicted with Bonjour and local macOS networking. I liked .dev, but Google took it.

While .wip isn't my first choice, so many extensions exist now. So .wip won mostly being a fun acronym, three letters, and available. Again, you're welcome to choose any available extension you like.

To edit the hosts file, run:

1sudo /etc/hosts

I'll append an entry to the end of the file:

1127.0.0.1 jasonmccreary.wip

In this case, the entry is for viewing this very blog in my local development environment. This handles directing traffic to Docker (technically localhost, but that's where Docker is listening). Now we need to set up Docker to receive and handle the traffic accordingly.

We actually configured our lamp service to load the virtual hosts from a local apache/vhosts.conf. This file is built by a simple shell script, also found within the apache folder. This script concatenates all my virtual host configuration files within my workspace under ~/workspace/apache-vhosts into a single vhosts.conf file.

Anytime I'm working on a new web project, I create a new virtual hosts file. Then I can run this script to pack them all down into a single file which will be configured.

Stopping and restarting our stack

To stop the stack, the best option is to run the following command:

1docker stack rm dev

This is safe as it brings the services down gracefully. However, you may simply quit Docker Desktop Client as well. This could result in data loss by abruptly stopping a service. Sometimes I have noticed some network issues. But typically running this command resolves them.

To start the server, we run the same compose command as before:

1docker stack deploy -c docker-compose.yml dev

Remember, running this command will start new containers with new IDs. So be sure to run docker container ps to get the latest ID to pass to the other commands like docker exec.

Otherwise, Docker will remember everything else. The volumes will mount with all your files and databases. The web server will boot with your Apache virtual hosts. And, of course, your hosts file remains the same.

Closing

I start with this tutorial because I believe it's an easy way to get started with Docker. It also most closely resembles the previous installation of PHP, MySQL, and Apache on macOS locally.

Admittedly, I've also taken some liberties with the terms that Docker gurus may not agree with. But it's a start as you get familiar with using Docker.

I encourage you to get familiar with the different commands. Learn the terms. Tweak this setup. Feel some of the pain. Once you do, you should have enough of a foundation to do even more.

You may also review the following articles below which include some additional services and minor tweaks to make your local Docker development environment even better.

I want to thank Ralph Schindler, Chris Fidao, and Dana Luther for answering countless questions I've asked over the last year. Without their help, this tutorial would not exist.

Find this interesting? Let's continue the conversation on Twitter.