Getting started with Docker part 4: Docker Compose

Getting started with Docker part 4: Docker Compose

24 Apr 2022 docker mysql php

Welcome to the fourth and final (phew!) part of my guide to learning Docker, in the context of PHP and MySQL.

Last time out we saw how we could use multiple containers in one Docker app - in our case, one for PHP and one for MySQL.

But we ended up with the problem of having to remember and run quite a few commands - some of them quite lengthy - to spin up our app. Now, we'll see how we can tidy that up and run everything with just one simple command!

Docker Compose 🔗

Docker Compose is a way of defining and running multi-container apps.

So far we've been running containers manually. The more containers in our app, the more containers we have to do this far. And those commands can get lengthy, especially if we also pass in environment variables or specify volumes.

With Docker Compose, we specify all this stuff in a docker-compose.yml YAML file. (YAML, if you don't know, is a data serialisation language, à la JSON.)

Note that Docker Compose doesn't replace Dockerfiles; the former describes the containers used in a multi-container setup, and how they are related, while the latter describes how an image should be built.

That said, they do have some crossover; both are valid places to stipulate environmental variables, for example.

As we'll see, Docker Compose files can reference Dockerfiles as part of a container's specification. Let's get started.

docker-compose.yml 🔗

Create a docker-compose.yml file in the docker directory in our project and give it the following content:

services: # PHP container php-container: container_name: php-container build: dockerfile: Dockerfile ports: - 80:80 volumes: - ../app/index.php:/var/www/html/index.php # MySQL container db-container: container_name: db-container image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: secret MYSQL_USER: app MYSQL_PASSWORD: secret2 MYSQL_DATABASE: todos volumes: - dbdata:/var/lib/mysql profiles: - db volumes: dbdata:

Let's break down what's happening there.

The YAML file is broken down into levels, like a nested JSON file. First we start with a top-level services element. It's under here that we define our two containers.

Most of the container config should be familiar to you from our previous efforts using docker run ... commands. We're specifying the same information here, just in a slightly different format.

For example, where previously we passed in environment variables to the MySQL container via the -e flag, here we have an environment element where we then list the variables and their values. Likewise for specifying the PHP container's port - the -p flag before, and here via the ports element. Same result, different syntax.

Let's run it! First, though, remove any previous containers you have running. As ever, you can do this via the CLI or from within Docker Desktop, as we've seen previously.

Next let's CD into our docker directory from our project root and then we'll spin everything up with the Compose CLI's up command:

cd docker docker compose up -d

We met -d in part 1. As a reminder, it means Docker will cede control back to the terminal after it's finished, so we can run further commands.

Before we head to the browser, we'll need to create our DB table like we did in part 3. Let's once again exec into our DB container:

docker exec -it db-container mysql -u app -p -D todos

Once again, enter the password ("secret2") when prompted then create the DB table:

CREATE TABLE todos ( id INT NOT NULL AUTO_INCREMENT, task VARCHAR(100) NOT NULL, PRIMARY KEY (id) );

Now head to http://localhost in your browser and you should see our app as before, looking and working identically to how it did in part 3!

How much easier than before! All we have to do to spin the whole thing up - no matter how many containers our app ultimately has - via the up command.

How about taking the app down and removing the containers? You won't be staggered to learn that there's a corresponding down command:

docker compose down

That will take down whichever containers were launched with the last-run up command.

Networking with Compose 🔗

But hang on, how come our app works when we didn't set up a network between the containers, like we did in part 3? We didn't run any docket network ... commands, nor did we specify anything about networks in our Compose file.

One of the best things about Compose is it handles networking automatically. When we run docker compose up, a default network is created for the container group, and all the containers are joined to it.

Like before, when we set up the network manually, containers join the network using aliases based on their container name. That's why our code still works; our PHP is trying to connect to MySQL on host db-container, remember...

$db = new mysqli('db-container', 'todo-user', 'todo-pass', 'todos'); ^^^^^^^^^^^^

...and that's the name we gave to the DB container (and, therefore, its network alias.)

This is Compose's default network behaviour, but it is possible to specify our own network config, if we wish. See the Compose networking docs.

Using an environment file 🔗

Remember in part 3 we passed in credentials to the DB container (user, password etc.) and we'd make that more secure later on?

Let's do that now, via an environment file. Create a file named .env in our docker directory. Compose automatically looks for this file, in the same directory as the Compose file, when we "up" our containers. Add the following content:

The idea with env files is they should be excluded fromyour repo, via your .gitignore. That means each developer can have their own env file, perhaps with user-specific credentials in it. Often, they're cloned from a template, usually named .env.example, which is pushed to the repo.

MYSQL_ROOT_PASSWORD = secret MYSQL_USER = app MYSQL_PASSWORD = secret2 MYSQL_DATABASE = todos

We've basically moved our environment variables out of our Compose file and into here, except we're now delimiting name/value with =, not :, (as .env files are not YAML)

Now we want our Compose file to read these variables. We want to pass variables to both containers - the DB container so it can use them to create the database and user, and the PHP container so it can use them to connect to MySQL. Let's rewrite our Compose file as follows:

services: # PHP container php-container: container_name: php-container build: dockerfile: Dockerfile environment: - MYSQL_USER - MYSQL_PASSWORD - MYSQL_DATABASE ports: - 80:80 volumes: - ../app/index.php:/var/www/html/index.php # MySQL container db-container: container_name: db-container image: mysql:5.7 environment: - MYSQL_ROOT_PASSWORD - MYSQL_USER - MYSQL_PASSWORD - MYSQL_DATABASE volumes: - dbdata:/var/lib/mysql profiles: - db volumes: dbdata:

By specifying only env var names, not values, Docker will try to resolve them from incoming environment variables - in our case, from our .env file.

The last thing to do is to update our PHP to read from these variables. Right now, the MySQL connection credentials are hard-coded in our PHP, which isn't great. Let's change our connection line to:

$db = new mysqli( 'db-container', $_ENV['MYSQL_USER'], $_ENV['MYSQL_PASSWORD'], $_ENV['MYSQL_DATABASE'] );

Take the app down and then up again and you should see everything still works!

Compose profiles 🔗

Docker Compose profiles are the answer to the question: "How do I start specific containers and not others?"

In our case, perhaps we'd like to start just the DB container, to run some SQL operations on it, without also starting the PHP container.

We could do this by running docker compose run ... commands (the Compose equivalent to docker run ... commands we met in earlier parts of this guide), but if we wanted to start more than one container, we'd have to do this multiple times.

Better would be to use profiles. You may have noticed earlier that, in our Compose file, we gave the DB container config a profiles element:

profiles: - db

This 'tags' our DB container with a profile name, "db", which we can then reference via the --profile argument when uping our containers, to start just that container.

Let's take down our containers and then up just the DB container:

docker compose down docker compose --profile db up -d

You'll find you can't load your app in the browser because we didn't up the PHP container - but we can talk to MySQL:

docker exec --it db-container mysql -V

That should print out the MySQL version string, showing that our DB container launched!

Containers can belong to multiple profiles, and multiple containers can belong to the same profile. So if we added a third container - say, PHPMyAdmin, which is a MySQL admin tool - we could profile that with "db" too and then uping with the db profile would start both the DB and PHPMyAdmin containers.

Summary 🔗

Docker Compose allows us to easily run multi-container apps. We declare them in the Compose YAML file.

Compose networks containers together by default, and we can pump in environment variables via a .env file.

We can also use profiles to group containers together and start some of them rather than all of them.

So there you have it! Well done for making it this far. It's been a long ride, but hopefully it's given you a full flavour of what Docker - and containerisation more broadly - is about.

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!