An average web dev.

Simple PHP/MySQL development environment with Docker

TLDR: If you don’t want to follow the steps, you can find the whole setup here.

Once in a while you need to start a fresh project and the initial setup is always tedious. Because I am lazy and I don’t want to go through that process more than once, I started working on a little project that has a minimal boilerplate for a PHP and MySQL development environment inside Dockers.

The main components are:

  • Apache 2.4 web server with PHP 7.4 and Xdebug
  • MySQL 8
  • Mailcatcher

…of course, you can add as many components as you need.

Prerequisites

  • Linux environment(I haven’t tested this configuration on Windows, but it might work).
  • Docker: Installation guide
  • Docker Compose: Installation guide
  • Some experience working with CLI

Why Docker?

I cannot explain better than docker.com what Docker is, so:

Docker is an open platform for developing, shipping, and running applications. Docker enables you to separate your applications from your infrastructure so you can deliver software quickly. With Docker, you can manage your infrastructure in the same ways you manage your applications. By taking advantage of Docker’s methodologies for shipping, testing, and deploying code quickly, you can significantly reduce the delay between writing code and running it in production.

The above explanation is basically saying that Docker is here to make your life easier. Once you have a setup in place, it is very easy to move it to another machine, share it with somebody else, etc.

Why Docker Compose?

Because docker-compose offer us an easy way of tying the components together.

From docker.com:

Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.

Let’s get started

We will start by defining the Apache image, then continue with docker-compose.yml definition and finish with some scripts that will help us to use the environment. First, let’s create the following folder structure in an empty directory

.
└── dev
    ├── servers
    │   └── web
    └── vhost

Defining Apache with PHP image

Of course, there is an official PHP-Apache image already build on Dockerhub, but we will need some tools that are not part it.

First, let’s create a Dockerfile at ./dev/servers/web/Dockerfile. A Dockerfile basically defines how a Docker image needs to be build. If you want to learn more about Dockerfiles, please go to the official documentation.

The first directive that we want to add is the base image that we want to build on top of. I chose PHP 7.4 but you can try with an older version(but not < 7).

FROM php:7.4-apache

Now that we have the base image set up, we will continue to add layers until we have all that we need.

Let’s add some PHP extensions. We will make use of docker-php-ext-install helper script that exists inside the base image.

RUN set -eux \
    && docker-php-ext-install \
    pdo \
    pdo_mysql \
    tokenizer

Next, we need to install some packages that will be required when using composer.

RUN set -eux \
    && apt-get update \
    && apt-get install -y \
    git \
    zip \
    unzip \
    && rm -rf /var/lib/apt/lists/*

Because we want to be able to debug the application, we need to install Xdebug.

RUN set -eux \
    && yes | pecl install xdebug \
    && echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)" > /usr/local/etc/php/conf.d/xdebug.ini 

And of course, Composer.

RUN set -eux \
	&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
	&& php composer-setup.php --install-dir=/bin --filename=composer \
	&& php -r "unlink('composer-setup.php');"

Now, we will need some sort of a hack in order to keep the correct file permissions. When you will want to edit your files, you will probably do that with your own user(host machine user). Because that user will not exist inside the docker container, we need to create it and assign the same user/group id as your host machine user. Belive me, it will make sense after we finish.

RUN set -eux \
    && usermod -u ${HOST_USER_ID} www-data \
    && groupmod -g ${HOST_GROUP_ID} www-data \
    && chown -R www-data:www-data /var/www

Because user and group ids may vary(in linux, the ids will usually start from 1000), we supply them by some environment variables. Those variables need to be set up as arguments in the Dockerfile. Paste this right before the FROM directive:

ARG HOST_USER_ID
ARG HOST_GROUP_ID

Finally, we will enable some Apache modules and add an xdebug configuration.

RUN a2enmod rewrite
RUN a2enmod ssl
RUN a2enmod headers

ADD xdebug-extra.ini /usr/local/etc/php/conf.d/xdebug-extra.ini

The Xdebug configuration at ./dev/servers/web/xdebug-extra.ini.

; Specifies the key which you need to use to be able to start debugging
xdebug.idekey=DEV_DEBUG

; Enables xdebug.
xdebug.remote_enable=on

; Specifies the port on your machine to which xdebug will connect
xdebug.remote_port=9000
xdebug.remote_connect_back=1
xdebug.remote_autostart=1

; Does not truncate output of var_dump
xdebug.var_display_max_data = -1
xdebug.var_display_max_children = -1
xdebug.var_display_max_depth = -1

The Dockerfile setup is finished. Our webserver contains the following components:

  • Apache 2.4 with rewrite, ssl and headers modules enabled
  • PHP 7.4 with pdo, pdo_mysql and tokenizer modules enabled
  • Xdebug
  • Composer
  • Git
  • Zip

Here is the whole file:

FROM php:7.4-apache

ARG HOST_USER_ID
ARG HOST_GROUP_ID

# Install required PHP excensions
RUN set -eux \
    && docker-php-ext-install \
    pdo \
    pdo_mysql \
    tokenizer

# Install required utilities
RUN set -eux \
    && apt-get update \
    && apt-get install -y \
    git \
    zip \
    unzip \
    && rm -rf /var/lib/apt/lists/*

# Install xdebug
RUN set -eux \
    && yes | pecl install xdebug \
    && echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)" > /usr/local/etc/php/conf.d/xdebug.ini 

# Install composer
RUN set -eux \
	&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
	&& php composer-setup.php --install-dir=/bin --filename=composer \
	&& php -r "unlink('composer-setup.php');"

# Create wwww-data with host UID and GID
RUN set -eux \
    && usermod -u ${HOST_USER_ID} www-data \
    && groupmod -g ${HOST_GROUP_ID} www-data \
    && chown -R www-data:www-data /var/www

# Enable Apache modules
RUN a2enmod rewrite
RUN a2enmod ssl
RUN a2enmod headers

ADD xdebug-extra.ini /usr/local/etc/php/conf.d/xdebug-extra.ini

Docker Compose configuration

The docker-compose.yml will contain 3 services. The web server, the database server and the mailcatcher. We will start by creating ./docker-composer.yml and seting up the version.

Content of ./docker-compose.yml:

version: '3'

Then we will create our first service, the webserver:

services:
  webserver:
    container_name: webserver
    env_file:
      - .env
    build:
      context: dev/servers/web
      args:
        HOST_USER_ID: ${HOST_USER_ID:-0}
        HOST_GROUP_ID: ${HOST_GROUP_ID:-0}
    ports:
      - '80:80'
    volumes:
      - ./:/var/www/html
      - ./dev/vhost:/etc/apache2/sites-enabled
    networks:
      app_net:

The webserver is build from dev/servers/web and will have the HOST_USER_ID and HOST_GROUP_ID as arguments(remember that we need the host machine user/group id in order to build the image). As you can see, the user and group id are initialized with the help of some environment variables. In order to have them, you need to create ./.env file with the following contents:

HOST_USER_ID=1000
HOST_GROUP_ID=1000

Please replace the values with your actual user and group id(can be found by executing id).

Also, the webserver will expose port 80 to the outside. This will give you the ability to see your application from your host machine on http://localhost or from another machine on your network. Next we need to map the current directory to /var/www/html inside the container. Mapping the entire directory will give you the flexibility of keeping the app wherever you want. Another volume map will bring the apache configuration inside the container. Right now our vhost folder is empty, so we need to create ./dev/vhost/default.conf. The vhost configuration is minimal, so you can extend it however you need.

Contents of ./dev/vhost/default.conf:

<VirtualHost *:80>
       DocumentRoot /var/www/html/src/public
       # This is the entry point in the app
       DirectoryIndex index.php
       # And this is the standard lumen config
       <Directory /var/www/html/src/public>
           Options +FollowSymlinks +Indexes
           AllowOverride All
       </Directory>
  
       ErrorLog /var/log/apache2/error.log
       CustomLog /var/log/apache2/access.log combined
</VirtualHost>

As you can see, the DocumentRoot is set up as /var/www/html/src/public. If you are planning to place the app in another location, please change the path.

The next service in our docker-compose.yml will be the database service. Be careful to add it as a child of services

  database:
    container_name: database
    image: mysql:8
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - 3306:3306
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=dev
      - MYSQL_USER=dev
      - MYSQL_PASSWORD=dev
    volumes:
      - database:/var/lib/mysql
    networks:
      app_net:

Here we do not need to build a database image because we can make use of the official MySQL image. The only important matter here is to create a database volume that will keep our data. If we do not do this, we will loose our data when the container is deleted. The database credentials are also using some environment variables that the MySQL image knows how to handle.

The last service will be the Mailcatcher. This server will help us avoiding the mistake of sending emails in the wild. If we setup the mailcather as our mail server, it will catch all emails that are sent and we can view them at http://localhost:1080.

  mailserver:
    container_name: mailserver
    image: schickling/mailcatcher
    ports:
      - 1080:1080
    networks:
      app_net:

The only thing that we need to do is expose port 1080.

Finally, let’s setup the database volumes used above and the network.

volumes:
  database:
    driver: local

networks:
  app_net:
    driver: bridge

The whole docker-compose-yml content:

version: '3'
services:
  webserver:
    container_name: webserver
    env_file:
      - .env
    build:
      context: dev/servers/web
      args:
        HOST_USER_ID: ${HOST_USER_ID:-0}
        HOST_GROUP_ID: ${HOST_GROUP_ID:-0}
    ports:
      - '80:80'
    volumes:
      - ./:/var/www/html
      - ./dev/vhost:/etc/apache2/sites-enabled
    networks:
      app_net:

  database:
    container_name: database
    image: mysql:8
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - 3306:3306
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=dev
      - MYSQL_USER=dev
      - MYSQL_PASSWORD=dev
    volumes:
      - database:/var/lib/mysql
    networks:
      app_net:

  mailserver:
    container_name: mailserver
    image: schickling/mailcatcher
    ports:
      - 1080:1080
    networks:
      app_net:

volumes:
  database:
    driver: local

networks:
  app_net:
    driver: bridge


We are almost ready to use the developement environment but because we are lazy, we need some helper scripts to do that. The first one is the stat.sh script.

Create ./dev/start.sh file with the following contents:

#!/usr/bin/env bash

set -eu

cd "$( dirname "${BASH_SOURCE[0]}" )" && cd ..

./dev/stop.sh

mkdir -p ./src

docker-compose up -d --build

This script will start the services in detached(-d) mode. The --build flag is used for building the images each time we start the environment. That is how we make sure that if we make a modification to the webserver Dockerfile, a new image will be built based on it. Also this script is creating a src directory if it does not exists. This directoy will hold our web app.

Then, ./dev/stop.sh

#!/usr/bin/env bash

set -eu

cd "$( dirname "${BASH_SOURCE[0]}" )" && cd ..

docker-compose down

This script is putting the services down.

The last script is for entering the web container. We will always want to enter the web container as the same user of our host machine because we do not want to mess up with file permissions issues. Now you can take a step back and look at our webserver Dockerfile again. That is why we have made that odd user hack.

The contents of ./dev/enter-web.sh:

#!/usr/bin/env bash

set -eu

docker exec -it -u "$(id -u):$(id -g)" webserver bash

Now the setup is complete. If you followed along, you should have this folder structure:

.
├── dev
│   ├── enter-web.sh
│   ├── servers
│   │   └── web
│   │       ├── Dockerfile
│   │       └── xdebug-extra.ini
│   ├── start.sh
│   ├── stop.sh
│   └── vhost
│       └── default.conf
└── docker-compose.yml

Using the development environment

The development environment is ready to use. We just need to start our services and create an application. I’ll be creating a Laravel application as an example.

Let’s start the services:

$ ./dev/start.sh

It will take a while for this script to launch the services for the first time. The webserver image must be build and the database image must be pulled from Dockerhub. The next time you use this script, it should take only seconds.

After the script has finished make sure that all services are running by executing docker ps. The output should look similar to this:

CONTAINER ID        IMAGE                    COMMAND                  CREATED             STATUS              PORTS                               NAMES
85939bc75f1b        schickling/mailcatcher   "mailcatcher --no-qu…"   8 minutes ago       Up 8 minutes        1025/tcp, 0.0.0.0:1080->1080/tcp    mailserver
12b2491192fd        php-dev-env_webserver    "docker-php-entrypoi…"   8 minutes ago       Up 8 minutes        0.0.0.0:80->80/tcp                  webserver
debb7026f52a        mysql:8                  "docker-entrypoint.s…"   8 minutes ago       Up 8 minutes        0.0.0.0:3306->3306/tcp, 33060/tcp   database

Next let’s create an actual application and see how it works.

$ ./dev/enter-web.sh
$ composer create-project laravel/laravel src

After Laravel is installed we need to take a final step and configure the database and mailserver settings. Go and edit the ./src/.env.

DB_CONNECTION=mysql
DB_HOST=database
DB_PORT=3306
DB_DATABASE=dev
DB_USERNAME=dev
DB_PASSWORD=dev

MAIL_MAILER=smtp
MAIL_HOST=mailserver
MAIL_PORT=1025 // 1025 is the SMTP port that the Mailcather is listening to

As you can see, we have set up the database host as database and mailserver host as mailserver. This works because the containers are in the same network and can talk to each other using container_name as hostnames.

This is it. Now you have a basic working PHP development environment with MySQL and a mail server.

The whole setup can be found here.

Have fun :)