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.
- Why not use Homebrew?
- 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.
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:
# PHP + Apache FROM php:7.4-apache # Update OS and install common dev tools RUN apt-get update RUN apt-get install -y wget vim git zip unzip zlib1g-dev libzip-dev libpng-dev # Install PHP extensions needed RUN docker-php-ext-install -j$(nproc) mysqli pdo_mysql gd zip pcntl exif # Enable common Apache modules RUN a2enmod headers expires rewrite # Install Composer COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer # Set working directory to workspace WORKDIR /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:
docker 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.
Now that built our image into a container, we can run an instance of this container with the following command:
docker 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:
docker 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:
docker container ps
It will output something like the following:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 96b0239fafa9 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
Using that we can pass that to the Docker exec command to get an interactive terminal by running:
docker 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,
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:
docker 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.
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.
docker volume create workspace --opt type=none --opt device=/Users/jasonmccreary/workspace --opt o=bind docker 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
version: "3.7" x-defaults: network: &network networks: - net services: php: image: lamp ports: - 80:80 - 443:443 volumes: - workspace:/var/www configs: - source: apache-vhosts target: /etc/apache2/sites-available/000-default.conf - source: php-ini target: /usr/local/etc/php/conf.d/local.ini <<: *network db: image: mysql:latest ports: - 3306:3306 environment: - MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_pwd - MYSQL_USER=dbuser - MYSQL_PASSWORD=dbpass volumes: - data:/var/lib/mysql secrets: - db_pwd <<: *network networks: net: secrets: db_pwd: file: ./mysql/root_password.txt configs: apache-vhosts: file: ./apache/vhosts.conf php-ini: file: ./php/local.ini volumes: workspace: external: true data: external: 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:
docker swarm init
Now we'll run our stack with:
docker 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
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.
.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:
I'll append an entry to the end of the file:
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
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:
docker 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:
docker 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
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.
File sync issues
After upgrading to Docker 2.4 I experienced intermittent file sync issues. I resolved this by disabling "Use gRPC FUSE for file sharing" within the Preferences of the Docker Desktop Client.
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.
- Install PHPMyAdmin to the stack
- Interact with Docker containers with the
- Match the container prompt with my local prompt
Find this interesting? Let's continue the conversation on Twitter.