Docker Compose Tutorial


We now have all our Docker images created, and our new Symfony files ready and waiting to run inside a Docker container.

We have three images:

  • MySQL
  • nginx
  • Symfony

And crucially, they all need to talk to each other in order for any of this to work.

We could try and co-ordinate this by hand. But in the real world, everyone uses docker-compose for this task.

If you haven't already done so, please install Docker Compose before continuing.

docker-compose.yml

I'm going to dive right into this:

# ./docker-compose.yml

version: '3'

services:

    db:
        image: mysql:5.7.19
        hostname: db
        volumes:
          - "./volumes/mysql_dev:/var/lib/mysql"
        environment:
          - MYSQL_ROOT_PASSWORD=password
          - MYSQL_DATABASE=db_dev
          - MYSQL_USER=dbuser
          - MYSQL_PASSWORD=dbpassword

    nginx:
        image: docker.io/codereviewvideos/nginx.symfony.dev
        hostname: nginx
        volumes:
          - "./volumes/nginx/logs:/var/log/nginx/"
          - "./:/var/www/dev"
        ports:
          - 81:80
        depends_on:
          - php

    php:
#        build:
#          context: ./
#          args:
#            WORK_DIR: /var/wwww/dev
        image: docker.io/codereviewvideos/symfony.dev
        hostname: php
        volumes:
          - "./volumes/php/var/cache:/var/www/dev/var/cache/:rw"
          - "./volumes/php/var/sessions:/var/www/dev/var/sessions/:rw"
          - "./volumes/php/var/logs:/var/www/dev/var/logs/:rw"
          - "./:/var/www/dev"
        environment:
          - SECRET_KEY=SOME_NOTSO_SUPERSECRETKEYHERE
          - DB_HOST=mysql
          - DB_PORT=3306
          - DB_DATABASE=db_acceptance
          - DB_USER=dbuser
          - DB_PASSWORD=dbpassword
        depends_on:
          - db

Wew, that's a lot of stuff.

To stress again, using environment variables here may not be the right choice for you.

Whilst fine in development, in production environment variables can lead to inadvertent security exposures. This is because environment variables are written to your log files when an error occurs.

Please consider this warning.

Anyway, one thing that sucks about this current config is the repeated use of the environment variables for both the db and php services.

We can do better.

Let's move these to an env file:

touch .env

Then inside the .env file, add the contents:

SECRET_KEY=SOME_NOTSO_SUPERSECRETKEYHERE
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_DATABASE=db_acceptance
MYSQL_USER=dbuser
MYSQL_PASSWORD=dbpassword

Save and close.

We can now update the docker-compose.yml file to use the .env file instead:

# ./docker-compose.yml

version: '3'

services:

    db:
        image: mysql:5.7.19
        hostname: db
        volumes:
          - "./volumes/mysql_dev:/var/lib/mysql"
        env_file:
          - ./.env

    nginx:
        image: docker.io/codereviewvideos/nginx.symfony.dev
        hostname: nginx
        volumes:
          - "./volumes/nginx/logs:/var/log/nginx/"
          - "./:/var/www/dev"
        ports:
          - 81:80
        depends_on:
          - php

    php:
#        build:
#          context: ./
#          args:
#            WORK_DIR: /var/wwww/dev
        image: docker.io/codereviewvideos/symfony.dev
        hostname: php
        volumes:
          - "./volumes/php/var/cache:/var/www/dev/var/cache/:rw"
          - "./volumes/php/var/sessions:/var/www/dev/var/sessions/:rw"
          - "./volumes/php/var/logs:/var/www/dev/var/logs/:rw"
          - "./:/var/www/dev"
        env_file:
          - ./.env
        depends_on:
          - db

Remember, ./ indicates the current directory. In other words, the .env file should live in the same directory as the docker-compose.yml file for this line to work. It need not do, but be sure to update the path accordingly if not.

Make Your Life Easier

Before we go further, let's update the Makefile to simplify our command line activities

docker_build:
    @docker build \
        --build-arg WORK_DIR=/var/www/dev/ \
        -t docker.io/codereviewvideos/symfony.dev .

docker_push:
    @docker push docker.io/codereviewvideos/symfony.dev

bp: docker_build docker_push

dev:
    @docker-compose down && \
        docker-compose build --pull --no-cache && \
        docker-compose \
            -f docker-compose.yml \
        up -d --remove-orphans

Now, super important: a Makefile uses tabs. Nasty tabs. So be careful if making changes.

We already covered docker_build, docker_push, and bp in the previous video.

The new command: dev, can be run with make dev.

This command, as the name implies, makes our development environment come to life.

To begin with, docker-compose down shuts down any running containers as described in our local docker-compose.yml file. This will not shut down any other containers you may have running in other projects.

If you don't have any running containers already then this command doesn't do very much, but that's fine.

&& simply means also run the following command.

&& \ simply means run the following command, but for readability let me split my command over multiple lines :)

docker-compose build --pull --no-cache

This line would execute any build instructions in our docker-compose.yml file.

As it happens we do have one, but it's commented out:

    php:
#        build:
#          context: ./
#          args:
#            WORK_DIR: /var/wwww/dev

If these lines were active then the build process would take place before our containers could be brought online. This may be useful to you, which is why I have left it in.

Personally, I very rarely use this.

        docker-compose \
            -f docker-compose.yml \
        up -d --remove-orphans

This is effectively one big command split over three lines.

docker-compose is the command line utility needed to work with Docker Compose.

-f docker-compose.yml is how we tell Docker Compose which docker-compose.yml file we want to run.

As it happens, docker-compose.yml is the default file that will be used.

Why I make this explicit is because in a CI pipeline, it's very likely that you will need to override this, and the ordering does matter.

By way of example, a CI pipeline call might look more like this:

        docker-compose \
            -f docker-compose.yml \
            -f docker-compose.ci.yml \
        up -d --remove-orphans

Where the second file - docker-compose.ci.yml - may contain overridden variables / volumes / ports / whatever needed for the CI environment.

up is the command we are running here - docker-compose up.

-d is the detached flag, just like in the docker run commands we have covered so far in this series. If we don't pass in this flag then your terminal window will be overtaken with log output.

--remove-orphans is a rather harsh sounding command :) What this will do is remove any containers that are no longer defined in your docker-compose.yml file. This likely will not impact you, unless your projects start to grow, and you start to add and remove more containers.

Ultimately we just need to run make dev and away we go.

Volumes

For each of the three defined services (db, nginx, php), one of the key areas of config is volumes.

    db:
        volumes:
          - "./volumes/mysql_dev:/var/lib/mysql"

We're going to use bind mounts for simplicity.

Inside our project root, when we run make dev (or the longer full docker-compose...) command, a new directory will be created called volumes.

Inside this new volumes directory, each container can store its data inside a subdirectory.

In this case, our db service will store its data inside volumes/mysql_dev.

Inside the running container, this data will map directly to /var/lib/mysql.

In other words, any database data from the container will really end up in our volumes/mysql_dev directory. This way if the container is deleted, the database data remains in a directory local to our project.

This said, you very likely want to make the following change to your .gitignore file:

# ./.gitignore

# Docker
/volumes/*

We very likely don't want our volume data ending up in our git repository.

Also, if using a .dockerignore file, be sure to add this entry in there, too.

Our nginx config looks similar:

    nginx
        volumes:
          - "./volumes/nginx/logs:/var/log/nginx/"
          - "./:/var/www/dev"

Accessing the log data can either be done via the CLI:

docker-compose exec nginx /bin/bash

$ tail -f /var/log/nginx/symfony_access.log
$ tail -f /var/log/nginx/symfony_error.log

Or substitute this out for whatever name you gave your logs in your nginx image.

Or you can view the logs locally by browsing to your ./volumes/nginx/logs directory. This can be handy as the logs hang around even if the container isn't running.

Also noticed here that we bind mount the project's root directory to /var/www/dev.

As covered, nginx will hand off (or pass upstream) for PHP file requests, but the files still need to be locally available or nginx will have a meltdown.

Kinda neat.

PHP, of course, has the most volumes:

    php:
        volumes:
          - "./volumes/php/var/cache:/var/www/dev/var/cache/:rw"
          - "./volumes/php/var/sessions:/var/www/dev/var/sessions/:rw"
          - "./volumes/php/var/logs:/var/www/dev/var/logs/:rw"
          - "./:/var/www/dev"

Cache, Sessions and Logs are all Symfony specific. If you were using this for a WordPress project you would have to expose different volumes here. Same for Laravel, or whatever.

You don't need to expose these directories for this process to work, but again, accessing this data after a container has been deleted can be extremely useful for debugging. This also impacts production, particularly the Sessions directory - unless you want everyone to be logged out when a new build goes live.

Finally ./:/var/www/dev ensures the local project files that we work on during development are those that are run by the container. This is in place of the files we really copied over during our docker build.

Connectivity

There are two further important parts of the docker-compose.yml file.

Firstly, hostname seems redundant given that we need it for each of the defined services, and its the same as the service name:

    db:
        image: mysql:5.7.19
        hostname: db

If we don't specify a hostname then Docker will generate one for us.

And it won't be pretty.

This is because by default, docker-compose will create a network for us. This is very useful - so much so that we are using it without explicitly stating this fact. We will get to networking later.

The network that docker-compose creates for us has a funky name.

It takes the name of the current directory and then concatenates it with the service name, and then an index.

Assuming our current directory name is docker-symfony-example, our db service would end up with the hostname of:

dockersymfonyexample_db_1

Not what we are after, and importantly, this will break things.

Our nginx config for example, expects to be able to pass upstream to php. Not dockersymfonyexample_php_1.

Anyway, hostname fixes this.

The order in which containers start up is important.

depends_on ensures the containers start in the order we define.

For example, if nginx started but couldn't see php, it may exit. If it exits, then because we haven't specified a restart configuration then it would stay exited. Likely not what we want.

By using depends_on we can control when containers come online, which should hopefully alleviate this problem. Or just use restart: always, and to heck with it :D (no don't do this).

At this point we have a working Dockerised Symfony stack. This is just the beginning, but you should now be able to connect on:

127.0.0.1:81

And hit your Dockerised Symfony stack. Nice.

Code For This Course

Get the code for this course.

Episodes