March 30, 2020

Rapid LAMP Development - Docker Compose & You

If you want a combination of ease-of-use and replication of a deployment environment, there's no better solution than using Docker Compose.

There are a lot of ways to develop a PHP application, you can install PHP locally, run a boxed solution like XAMPP, create Virtual Machines with a full linux server distro on them or even spin up droplets on DigitalOcean - but if you want a combination of ease-of-use and replication of a deployment environment, there's no better solution than using Docker Compose.

If you're unfamiliar with the toolset, Docker is a hypervisor program that allows you to run lean virtual environments in containers - each sharing the host's system-level kernel while remaining functionally separate. No more needing 32GB of RAM to emulate your database, app and mail server at the same time.

Docker Compose takes it a step further and allows you to define an entire virtual ecosystem, each layer of your application from servers, storage volumes and even networks.

I'm going to give you a short walkthrough on creating a Docker Compose environment for a standard LAMP project - I'll be using Laravel for this, but you can re-use these same settings for anything.

Setup

To start using Docker Compose, you'll need to have Docker and the Compose tool installed (or Docker Desktop) for windows. On a Linux machine, Docker will run all of its containers off of the system kernel. On Mac or Windows, Docker Desktop will need to run on a linux virtual machine. You can do this with the default Windows Hypervisor, Virtualbox or other solutions - just follow the installation instructions.

Creating your Dockerfile

Most of the services we'll use are pre-built, but obviously your application will have its own needs. To cover those needs, we'll tell Docker how to set up your application's container with a Dockerfile.

Dockerfiles have their own special syntax that I'll detail along the way. To start with, we need to define a base container to build on top of. This contains information like what packages are installed and the system's configuration. For our purposes, the official PHP + Apache container for the newest version will do nicely. Start by creating a new file in the root of your project's folder (if you're using a Laravel project, this will be the same level as your server.php and composer.json files.) and type in the following content:

FROM php:7.4-apache
Dockerfile

Then we need to create some environment variables for the rest of our setup:

ENV BASE_PATH=/var/www/html
ENV APACHE_DOCUMENT_ROOT="${BASE_PATH}/public"
ENV PATH="${BASE_PATH}/vendor/bin:${PATH}"
Dockerfile

These set up our base path for Apache, where we'll be running scripts from and what directory we'll serve our PHP requests out of. If you're not using a Laravel project, make sure to change the second line to your index directory instead of /public.

Next, we'll tell Docker to install any utilities our app and php extensions might need at the operating system level:

RUN apt-get update && apt-get install -y \
        g++ \
        git \
        zip \
        curl \
        sudo \
        unzip \
        libicu-dev \
        libbz2-dev \
        libpng-dev \
        zlib1g-dev \
        libzip-dev \
        libxml2-dev \
        libjpeg-dev \
        libonig-dev \
        libmcrypt-dev \
        libreadline-dev \
        libfreetype6-dev \
        libjpeg62-turbo-dev \
    && docker-php-ext-configure gd
Dockerfile

You can probably get rid of git, zip and unzip if you don't need them, but I strongly recommend leaving the libraries and other utilities here. Especially sudo, in case you need to interact with your running container and run commands as root.

After that, we'll configure apache to correctly serve our content and parse .htaccess for things like URL rewriting:

RUN a2enmod rewrite headers \
    && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \
    && mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
Dockerfile

Then install our PHP extensions, using the docker-php-ext-install command to compile them from source so we don't have any issues running on the container:

RUN docker-php-ext-install -j$(nproc) \
    gd \
    bz2 \
    zip \
    intl \
    exif \
    iconv \
    bcmath \
    opcache \
    calendar \
    mbstring \
    pdo_mysql \
    && docker-php-source delete
Dockerfile

And finally, install composer globally:

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
Dockerfile

With that, our application server configuration (and the hardest part of this setup) is done. Now the rest.

Docker Compose

Now that our application server is defined in the Dockerfile, we can tell Docker Compose how we want the rest of our environment set up. Create a new file called docker-compose.yml in the same directory as your Dockerfile.

Docker Compose has a lot of options. To keep things simple, we'll go with the default virtual network, an application server, a database server and a mail server powered by MailHog to catch all of your outbound emails.

To start with, we'll define our docker-compose file version:

version: "3.5"
docker-compose.yml

Unike the previous step, this file is pretty small, so I'll include the whole file with each change. Keep in mind that this is a YAML file, so proper indentation is important and you must use spaces instead of tab characters.

Next, we'll define our "services" block, telling Compose what our ecosystem will contain. This block will also ensure that a virtual network for our services is automatically created.

version: "3.5"

services:
  app:
  db:
  mail:
docker-compose.yml

Defining our mail server is the easiest, since mailhog has an official docker container so we'll start with that:

version: "3.5"

services:
  app:
  db:
  mail:
    image: mailhog/mailhog:latest
    ports: ['8025:8025']
docker-compose.yml

This tells compose to pull down the latest version of the official mailhog app container, and to forward its internal port 8025 to our local machine port 8025. Note that this isn't needed for other servers in the virtual network to access the container, this is just where we will access the web interface for mailhog in our browser.

Now we'll set up the database server using MariaDB (The Free as in Freedom version of MySQL):

version: "3.5"

services:
  app:
  db:
    image: mariadb:10.5
    environment:
      - MYSQL_ROOT_PASSWORD=secret
      - MYSQL_DATABASE=app
      - MYSQL_USER=user
      - MYSQL_PASSWORD=secret
    ports: ['3306:3306']
  mail:
    image: mailhog/mailhog:latest
    ports: ['8025:8025']
docker-compose.yml

This sets up the database container using the most recent (at time of this writing) version of the official mariadb container - I didn't want to go with the "latest" tag, as blindly updating database engine versions is generally a bad idea.

We also told the container that when it sets up, it should set the root database user's password to secret, create a database called app and create a privileged user with the name user and password secret. Finally, we forwarded port 3306, the default MySQL port, to our local machine so that we can connect to the database server with the mysql client or other database tools (I recommend JetBrains' DataGrip).

You can change these passwords and ports, or leave them alone - these containers are only accessible on your local machine unless you forward these ports outwards.

Finally, we'll set up the app server:

version: "3.5"

services:
  app:
    build: .
    volumes: ['.:/var/www/html']
    ports: ['80:80']
    depends_on: [db,mail]
    environment:
      - DB_HOST=db
      - DB_PORT=3306
      - DB_DATABASE=app
      - DB_USERNAME=user
      - DB_PASSWORD=secret
      - MAIL_HOST=mail
      - MAIL_DRIVER=smtp
      - MAIL_PORT=1025
  db:
    image: mariadb:10.5
    environment:
      - MYSQL_ROOT_PASSWORD=secret
      - MYSQL_DATABASE=app
      - MYSQL_USER=user
      - MYSQL_PASSWORD=secret
    ports: ['3306:3306']
  mail:
    image: mailhog/mailhog:latest
    ports: ['8025:8025']
docker-compose.yml

To start with, unlike the other services, we don't have a base image to use, instead, we tell docker compose to build from the same directory our docker-compose.yml is in - by default it will select a file named Dockerfile, but if you have multiple, or you want to keep this file elsewhere, you can specify the full path and name of the file here instead of ..

Next, we symlink /var/www/html on the app container (remember, we set that as the root Apache directory) to . as well. This ensures that any changes we make to our local codebase are instantly readable by the server, instead of having to upload or sync them.

Then we open port 80 on the app server to port 80 on our local machine, so that requests to http://localhost will be served by Apache.

Next, we define the other services members that the app server will want to have running before it runs - in this case, db for our database and mail for our smtp server. As you can guess, these match the keys we defined for each service, which also serve as hostnames to those servers within the virtual network.

Finally, we define some environment variables for the app server. The variables I've defined above are specifically for Laravel, if you're using something else, you can adapt them to your needs - these variables simply share the configuration we've set up so that we don't need to define them in a .env or config file.

Boot It Up!

Now our configuration is done, lets get those containers running! Open a command line/terminal inside your project directory and type the following command:

docker-compose up

And then wait. Depending on the speed of your machine and connection, you may be waiting a while. This only happens the first time, but Docker must now download all of the images and code we've specified, then when it reaches the Dockerfile, it must also compile all of the PHP extensions we specified. This is a good time to go grab some coffee.

It's running!

When it's all done, you should be greeted with console output like the above. This console will remain open and provide the STDOUT of all of your servers. You'll also see requests and error messages from apache.

Head on over to http://localhost to see your application running, and http://localhost:8025 to see the mail captured from your application. You can re-use these settings on any LAMP project (and the downloads will be cached for next time).

Happy developing!