Docker Compose - a few tips #1
When following tutorials or hello-world, you will always end up with simple knowledge on how to do the basic stuff with a strict minimum (fewer files as possible, fewer lines of code, ...). The drawbacks are it does not teach us the best way to do things. How to keep things readable, maintainable, flexible, ...
I'll try to provide some useful knowledge about docker compose
.
Table of Contents
- Always prefer the long syntax over the short one
- Split the responsibilities
- Use environment variables
3.1. The compose variables
3.2. The project variables - A bit of help
Always prefer the long syntax over the short one
This is how the vast majority learned to use volumes :
There are several things I dislike here.
- We are using YAML so we should ALWAYS use either
'
or"
when not using a boolean or number-like value. - The
:
is not really readable. Right after.
it is easy to miss. - You can have (but clearly shouldn't)
:
and.
as filename or directory name. This can cause headaches. - If you are really unfamiliar with this syntax what is the source and what is the target ?
- How to apply the readonly ?
And get these examples :
Does project
refers to a volume or a directory ? Which takes precedence ? Will it create it if it doesn't already exists ?
So many questions. Yet "compose" already has a way to ease things.
How does it look ?
This is clearly stating that we are binding the local project
directory to /var/www/html
and this should be read only. You even have very good support from IDE (at least PHPStorm) to autocomplete the available keys.
If a volume named "project" exists, it won't be used for two reasons :
- I wrote
./project
instead ofproject
clearly stating a directory relative to the compose file. - The
type
isbind
so it won't even look in the volume section.
If you like this syntax you can check it out here :
- volumes (BTW always prefer the absolute path instead of relative ones. It will help with the next tips)
- configs
- ports (AFAIK the long syntax is the only way to specify the
mode
) - secrets
- networks (This is the only way of defining aliases on services)
- depends_on (Enables the configuration of additional fields that can't be expressed in the short form)
Split the responsibilities
How do you use compose
? You usually go like this :
$ docker compose up -d
in the directory where your docker-compose.yml
file lives.
But sometimes we need several configs for different installations. The "classic" use case is when using docker-sync which requires you to create another docker-compose
file. Luckily for us, there is a simple way to achieve that with the docker-compose.override.yml. But, this file not being committed, if you want to ease the pain for the team you need to commit a .dist
version of it and ask the developers to copy it. If it changes you need to guide them through how to change it.
Anyway, this is again a lot of pain. Compose offers a way to use more than one file actually. the override
one is just a special case that docker automatically detects. But you could provide several others using -f
like so :
When using multiple files like this they are "merged". The second, override / append (in case of arrays and objects) the first one. The third one does the same to the previously merged file, etc etc.
At first, it seems overly complicated both to set up and to use. Regarding the use, I'll provide you with a way of using multiple files with the same command you are already used to without doing any magic just native compose features. As for the setup this is like any project. In PHP for example we don't hesitate to have one class per file and to split into several classes so they are single responsibility. Easy to read = Easy to debug.
This is the file structure I usually go with locally (see gist) :
The docker-compose.yml
file is the main one. It defines the services that will be common between all my environments (local, prod, else). Usually, it is a very light one.
Then goes the heavy one. The docker-compose.local.yml
which contains all the local logic.
- There is now a
postgres
database (in production I'll go on managed services on AWS for example) - There are mount points (those should not exist in production as the sources should already be built into the container)
- There is a
node
service used to build / watch my assets - etc etc
Finally, I decided to not expose any ports and use traefik as a reverse proxy locally. That way I can serve my local app through valid https (more on that in another post). But others might decide to not use traefik and expose ports in a docker-compose.local.ports.yml
.
Use environment variables
Compose support environment variables out of the box. See more details on the documentation.
But exposing project-related variables globally is not the way to go.
Creating a .env
file at the root of your docker-compose.yml
file with default values. The precedence is .env
variables will be overridden by the globally available ones.
There are two kinds of variables you can put here. The compose ones and the project ones.
The compose variables
Here is the link to the documentation.
What I usually do is this :
# https://docs.docker.com/compose/reference/envvars/
COMPOSE_PROJECT_NAME=myapp
COMPOSE_PATH_SEPARATOR=|
COMPOSE_FILE="./docker-compose.yml|./docker-compose.local.yml|./docker-compose.local.traefik.yml"
COMPOSE_PROJECT_NAME
- By default it uses the directory name you're in. This is useful to remove orphaned services. It also displays nicer in the Docker Desktop UI.COMPOSE_PATH_SEPARATOR
- Useful for theCOMPOSE_FILE
. By default it is:
but I find it less readable. Keep it if you prefer.COMPOSE_FILE
- Previously in this post I stated that using multiple files can be a burden that I'll lift up later. This is it. You define an ordered list of files you want to use.
Now when I run
$ docker compose up -d
What it really does is
$ docker compose \
--project-name myapp \
--file ./docker-compose.yml \
--file ./docker-compose.local.yml \
--file ./docker-compose.local.traefik.yml \
up --detach \
;
The project variables
I previously stated that you should use absolute paths instead of relative ones for example. How to achieve that without forcing all your developers to have the exact same setup ?
Let's add a new variable to the .env
#...
APP_PROJECT_PATH="/Users/me/Projects/RocIT/app"
and change the volume bindings in your docker-compose
files like so :
Now each developer can configure where to look for the sources. This can be applied to any values in the .yml
files.
Another way of fully leveraging those variables is for permissions issues (in a later post).
A bit of help
Now we use the long syntax, we splat our files into multiple ones and we allowed everything to be configured by environment variables. Everything can be committed except for the .env
file of course. So how to generate it easily ?
One of the method would be to use a Makefile
(make a file).
SHELL := /bin/bash
##
## Setup
## -----
##
define DOT_ENV
# https://docs.docker.com/compose/reference/envvars/
COMPOSE_PROJECT_NAME=myapp
COMPOSE_PATH_SEPARATOR=|
COMPOSE_FILE="./docker-compose.yml|./docker-compose.local.yml|./docker-compose.local.traefik.yml"
# Fix permissions issues
APP_USER_ID=$(shell id -u)
# Paths
APP_PROJECT_PATH="$(shell echo "$(shell cd './../' && pwd)/myapp")"
endef
export DOT_ENV
./.env:
@echo "$$DOT_ENV" > "${@}";
.DEFAULT_GOAL := ./.env
now running
$ make
will create the file with the appropriate values so you just have to run docker compose up -d
.