Docker Compose - a few tips #1

Docker Compose - a few tips #1
Photo by Mahdi Bafande / Unsplash

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

  1. Always prefer the long syntax over the short one
  2. Split the responsibilities
  3. Use environment variables
    3.1. The compose variables
    3.2. The project variables
  4. A bit of help

Always prefer the long syntax over the short one

This is how the vast majority learned to use volumes :
docker compose short volume syntax - current directory

There are several things I dislike here.

  1. We are using YAML so we should ALWAYS use either ' or " when not using a boolean or number-like value.
  2. The : is not really readable. Right after . it is easy to miss.
  3. You can have (but clearly shouldn't) : and . as filename or directory name. This can cause headaches.
  4. If you are really unfamiliar with this syntax what is the source and what is the target ?
  5. How to apply the readonly ?

And get these examples :
docker compose short volume syntax - project directory

docker compose short volume syntax - project volume or directory

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.
docker compose long volume syntax - project directory and read only

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 :

  1. I wrote ./project instead of project clearly stating a directory relative to the compose file.
  2. The type is bind 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 :
docker compose with multiple files

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.
docker-compose.yml

Then goes the heavy one. The docker-compose.local.yml which contains all the local logic.

  1. There is now a postgres database (in production I'll go on managed services on AWS for example)
  2. There are mount points (those should not exist in production as the sources should already be built into the container)
  3. There is a node service used to build / watch my assets
  4. etc etc
    docker-compose.local.yml

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.
docker-compose.local.traefik.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 the COMPOSE_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

How to use interpolation.

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 :
diff in docker-compose.yml - from hardcoded directory to variable one

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.

Mastodon