Set up muti-container environment using Docker Compose

Set up a multi-container environment with Docker - featured image

I remember this time when I discovered Vagrant. The magic behind this tool and the general idea of scaffolding the whole environment using single command was pretty genius. Moreover, I had programmed on Windows and thankfully I could get rid of the XAMP and any other Windows-oriented web server packages.

In my previous job, my boss showed me a tool called Docker. I instantaneously got the point of the concept behind it and I started exploring the big universe of the possibilities of use. In this article, I’m going to show the most common use-case for docker – a proposed local environment suited for PHP application development.

This article is devoted to the configuration rather than explanation what the Docker really is. I would like to show the actual use-case rather than showing my point of view – I’ll follow it in one of the next articles.

Requirements

If you want to follow this instruction, you have to install:

  • Docker CE – docker engine
  • Docker Compose – tool for orchestrating the multi-container environment
  • PHP and Composer

If you don’t want to follow this article, don’t worry. Everything is prepared and ready to use – just see my GitHub  repository skrajewski/php-app-on-docker on a tag basic-php.

Test application

There is no much sense to create an advanced environment to develop a single-file script. To approach ourselves to the actual situation we’re going to run a Symfony 4 application.

Our application should use PHP 7.2 and should be served by the nginx web server. Moreover, we need also a database to store our data, e.g. MariaDB.

Think about the infrastructural services

All these three services may be considered as infrastructural services. Each of them may live in a dedicated container – we don’t have to mix up the PHP with the web server.

This is our goal. Create a multi-container environment where services will be connected together if needed.

Install Symfony’s website skeleton

In this step, I assume that we have installed and configured PHP and Composer. Of course, we may start from the environment and subsequently run the whole set up within it. In this article, we’ll focus on putting the application into the new environment.

To install Symfony application, run below command


composer create-project symfony/website-skeleton application

Change the working directory to application and let’s go to the environment definition.

Development environment

We should prepare three different containers for each of our services. We don’t want to do it by hand. It’s the place where docker-compose come to the play. By preparing docker-compose.yml file we can describe how our environment should look like.

Create a docker-compose.yml with the below content


version: '3'

services:
    php-fpm:
        image: php:7.2-fpm
    nginx:
        image: nginx:1.11
    db:
        image: mariadb:10

However this config will create a three containers environment, there is a lot of missing configuration yet.

Make services accessible from the host

To reach each of this service we have to know their internal IPs. Unfortunately, they are dynamically assigned so it’s hard to simply prepare a host file. To mitigate this problem we can bind exposed ports to our host machine. Let’s do this!


version: '3'

services:
    php-fpm:
        image: php:7.2-fpm
    nginx:
        image: nginx:1.11
        ports:
            - "8080:80"
    db:
        image: mariadb:10
        ports:
            - "33060:3306"

The syntax is following: "${HOST_PORT}:{SERVICE_PORT}". Now, the web server should be accessible under localhost:8080 and the database under localhost:33060. We don’t need a direct access to php container.

Provide credentials to our database

In order to access our database, we need to set up a password at least for the root user. The documentation of MariaDB image on Docker Hub is pretty good and it shows how to do it using environment variables. Let’s change a docker-compose.yml configuration a little bit.


version: '3'

services:
    php-fpm:
        image: php:7.2-fpm
    nginx:
        image: nginx:1.11
        ports:
            - "8080:80"
    db:
        image: mariadb:10
        ports:
            - "33060:3306"
        environment:
            - MYSQL_ROOT_PASSWORD=root
            - MYSQL_DATABASE=app

Our database server will create a default database called app and set the root password’s to root. Not so secure, but it’s enough for the local development. Keep it simple.

Add a virtual host to nginx container

However nginx is working correctly, it shows nothing interesting than the standard welcome page. We have to add the configuration to let web server knows how to manage request to our application.

Personally, I like to keep all docker related stuff in the directory called .docker which I create in the root of the project. For a better order, I’m going to add subdirectory related to the service to keep all files together.


mkdir -p .docker/nginx
touch .docker/nginx/default.conf

Put the virtual host configuration to the newly created default.conf file.


server {
    # This is the root of our application, we need to mount files in /app
    root /app/public;
    index index.php index.html;
    server_name _;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location = /favicon.ico {
        log_not_found off;
        access_log off;
    }

    location = /robots.txt {
        allow all;
        log_not_found off;
        access_log off;
    }

    location ~ ^/index\.php(/|$) {
        # Instead of using socket, we pass execution of PHP files 
        # to the process on different container using host name
        fastcgi_pass php-fpm:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
        expires max;
        log_not_found off;
    }

    location ~ \.php$ {
        return 404;
    }
	
    error_log /var/log/nginx/project_error.log;
    access_log /var/log/nginx/project_access.log;
}

As we can see in the configuration file, we use a php-fpm located in another container. This approach allows us to e.g. change the PHP version without changes in the web-server.

To make it works, we need to mount both configuration and application files into the nginx container.


version: '3'

services:
    php-fpm:
        image: php:7.2-fpm
    nginx:
        image: nginx:1.11
        ports:
            - "8080:80"
        volumes:
            - ./:/app
            - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    db:
        image: mariadb:10
        ports:
            - "33060:3306"
        environment:
            - MYSQL_ROOT_PASSWORD=root
            - MYSQL_DATABASE=app

Mount the application to the PHP container

In spite of the nginx has access to the application files, the php-fpm container doesn’t know anything about the files. The php-fpm should also have access to application files in order to process them on the request of web-server. Let’s mount the application directory also to this container.


version: '3'

services:
    php-fpm:
        image: php:7.2-fpm
        volumes:
            - ./:/app
    nginx:
        image: nginx:1.11
        ports:
            - "8080:80"
        volumes:
            - ./:/app
            - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    db:
        image: mariadb:10
        ports:
            - "33060:3306"
        environment:
            - MYSQL_ROOT_PASSWORD=root
            - MYSQL_DATABASE=app

In the old good days, docker-compose in version ‘2’ has option volumes_from which allows mounting volumes from the other container. It let us avoiding repetition but unfortunately, for various reasons, this option is no longer supported.

Use our own PHP configuration and extensions

Using the raw PHP image is very limited. The better option is to create own image using the PHP image as a foundation. We can do this by creating a new Dockerfile.

Similar to nginx configuration, let’s put the new file in .docker/php/Dockerfile.


mkdir -p .docker/php
touch .docker/php/Dockerfile

Now, edit newly created Dockerfile and put below content.


FROM php:7.2-fpm

RUN apt-get update && apt-get install -y \
    libmcrypt-dev \
    zlib1g-dev \
    libicu-dev \
    g++

RUN docker-php-ext-install -j$(nproc) iconv mysqli pdo_mysql mbstring intl

RUN yes | pecl install xdebug-2.6.1 mcrypt-1.0.1 \
    && docker-php-ext-enable xdebug mcrypt

Based on php:7.2-fpm we create a new image with some extra extensions, which are needed for our application. If you need more information about the php image, please check the documentation on the Docker Hub. The same thing we may do with the php.ini file, but we skip it for the sake of brevity.

The only thing to do is to use this image in our docker-compose.yml file. Let’s do it.


version: '3'

services:
    php-fpm:
        build:
            context: .
            dockerfile: .docker/php/Dockerfile
        volumes:
            - ./:/app
    nginx:
        image: nginx:1.11
        ports:
            - "8080:80"
        volumes:
            - ./:/app
            - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    db:
        image: mariadb:10
        ports:
            - "33060:3306"
        environment:
            - MYSQL_ROOT_PASSWORD=root
            - MYSQL_DATABASE=app

Configure the application

The application will be running from the container so it may use internal hostnames. For example, to configure a database connection, open your .env file and change the DATABASE_URL to following:


	DATABASE_URL=mysql://root:root@db:3306/app

Because we don’t run any extra services right now, this is the only change in the application.

Initialize the environment

To initialize the whole environment, use a docker-compose tool.


docker-compose up -d

It may take a while since the docker has to build a custom php image. After a few minutes, everything should be done and the application should be accessible under the localhost:8080 url.

The up command is for scaffolding the whole environment. For the normal usage, you should use start/stop commands.

Final result

If you try to reach the localhost:8080, you should see the page similar to the following.

Symfony 4 default page
Symfony 4 default page running on the new environment

To connect easily to the database server outside the environment, you may use the localhost host and the port 33060. For instance, the below screen presents connection details in a Sequel Pro application.

Sequel Pro application - database connection details
Database connection details in Sequel Pro application

Summary

We successfully went through the first part of the environment preparation and configuration. This is the very beginning part of the whole journey. Each environment should be suited for the application it hosts. Maybe you’ll use a different database engine, a different cache storage or a queue solution. The configuration should depend on the needs.

Is it worth to create a universal environment? It depends on how universal it should be. If you want to develop applications in only one framework or one technology, why now. If you would like to create a versatile environment for a bigger spectrum of technologies, it may not make much sense.

That’s the reason why I didn’t write the final configuration with a paragraph of text describing how to use it. Instead, I tried to show you how to set up an environment you really need. I have taken each action as the response for a need.

Preparing the environment is different than the programming, but also makes fun. It’s worth to know what you use it, how, and even why. Stay tuned.

Featured photo by chuttersnap on Unsplash


Did you enjoy this article?
I’ll be grateful If you share it further on social media or if you leave a comment. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.