Getting started with Docker part 4: Docker Compose
24 Apr 2022
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 Dockerfile
s; 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 Dockerfile
s 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:
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:
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:
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...
...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.
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:
This 'tags' our DB container with a profile name, "db", which we can then reference via the --profile
argument when up
ing our containers, to start just that container.
Let's take down our containers and then up just the DB container:
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:
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 up
ing 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!