Skip to main content

All you need is docker compose up

Tags: docker

Elegant initialization for compose projects


Docker compose is a great tool for orchestrating containers, used for one thing or another in almost all of my projects. I was very excited to learn that compose is becoming it's own spec and hope that in the future, we will see more tools adopting it properly. (Looking at you, podman 👀)

One key strength of compose is that its own concepts map quite neatly onto the underlying concepts that it manages for you. Volumes are volumes, networks are networks, services are a very thin layer around containers, etc. This makes compose far easier to understand and adopt than kubernetes. The downside of compose - and in turn upside of k8s - is that there are many common problems that it doesn't "natively" solve for you.

One of these things is automating the initialisation of a project. In this post, I want to share some tips and tricks for creating a smoother initial-start experience for the "users" of your compose projects. Most of what I show here is applicable to initialisation in general: building static assets, deriving configuration from the environment, etc. But I will be using "generating a database password" as the working example.

The premise ​

Let's assume our compose project consists of an application that uses a Postgres database. The Postgres container and your app container both need the database password. We don't want to set a default password for obvious reasons, so it should be generated when the project is started for the first time.

What about docker secrets and compose secrets? ​

You might want to point out that docker has a secrets mechanism. Yes that is true, but it is only available in swarm mode. You might also want to point out that docker compose secrets work even if not in swarm mode. That is also true, but you might be as surprised as I was to learn that composes implementation of secrets is very "dumb", I will later get back to why it is much less useful than it could have been.

The easy way out ​

The typical way to handle this problem is providing documentation (and a notoriously unreadable shell script) for generating the password and storing it somewhere. This can be a file mounted in both containers or perhaps you are adding the secret to .env next to the compose file and pass it through via an env var (POSTGRES_PASSWORD in the case of postgres).

yaml
# compose.yaml
services:
  postgres:
    image: postgres
    environment:
      - POSTGRES_PASSWORD

  app:
    image: ...
    environment:
      - POSTGRES_PASSWORD
sh
# .env
POSTGRES_PASSWORD=...

This approach is fine in general, but the instructions (and the obligatory nasty shell script) will grow more as the application grows. In any case, the "user" wanting to deploy the project needs to run at least the shellscript - this is not what we want, we want docker compose up and nothing else!

Building Block One: init services ​

The first step is to take the shell script that prepares the project and integrate it to the compose project as an init service. Init services (or init containers) are not a native concept in compose, but conventionally, they refer to a service with two key properties:

  • It runs a terminating process (as opposed to one running until stopped).
  • Some of the app services depend on it.

Speaking in yaml:

yaml
# compose.yml
services:
  postgres:
    image: postgres
    depends_on:
      init:
        condition: service_completed_successfully

  app:
    image: ...
    depends_on:
      init:
        condition: service_completed_successfully

  init:
    image: ...
    command: python ./preparation_script.py

The depends_on setting for postgres and app ensures that init successfully made all necessary preparations before they are started. In addition we get a controlled environment (container) for our nasty preparation script and can thus afford to make it less nasty — for example a Python script that uses a templating library to generate config files, instead of of a long and verbose sequence of awk and sed.

One important consideration is that the initialisation should now be able to detect whether it already ran previously and ideally exit early as to not slow down subsequent starts of the project.

Building Block Two: Sharing files ​

You might come away from the previous section with the idea of mounting the .env file to the init container and writing the secret there, but that won't work, because by the time the init container writes the secret, the process handling your docker compose up command already started and it won't read changes to .env.

However, it's almost the right idea! You want the init container(s) and service container(s) to both mount a shared file. The init container writes to it and the service containers read them once they get started. As usual, the devil is in the details, so I want to go through some of the possible configurations and their pitfalls.

It's pretty straight-forward: You create a volume and mount it to all relevant containers. You might want to mount them read-only (:ro) to service containers. The example below should work out-of-the-box. Run docker compose up and the output should show that the pg database initializes and the "app" demonstrates that it has access to the password by printing it.

yaml
# compose.yml
volumes:
   config:

services:
  postgres:
    image: postgres
    environment:
       POSTGRES_PASSWORD_FILE: /etc/myApp/pgPassword
    volumes:
      - config:/etc/myApp:ro
    depends_on:
      init:
        condition: service_completed_successfully

  app:
    image: alpine
    volumes:
      - config:/etc/myApp:ro
    environment:
       POSTGRES_PASSWORD_FILE: /etc/myApp/pgPassword
    # This is just to demonstrate that the "service" has access to the password
    command: 'sh -c "echo the pg pass is: $$(cat $$POSTGRES_PASSWORD_FILE) && tail -f /dev/null"'
    depends_on:
      init:
        condition: service_completed_successfully

  init:
    image: python:3.12.8-alpine
    environment:
       POSTGRES_PASSWORD_FILE: /etc/myApp/pgPassword
    volumes:
      - config:/etc/myApp
      - ./prepare_project.py:/prepare_project.py
    command: python prepare_project.py
py
# prepare_project.py
import secrets
from pathlib import Path
import os

if __name__ == "__main__":
  pg_pass_path = Path(os.environ["POSTGRES_PASSWORD_FILE"])

  if not pg_pass_path.exists():
    pg_pass_path.write_text(secrets.token_hex(16))
    print(f"Created pg password at {pg_pass_path}")

There are a few gotchas about this setup that I want to mention. First: Permissions. At the end of the day, volumes are directories on some filesystem that are mounted to both containers. If the containers run with different users, you might have to deal with setting the permissions and ownership correctly.

Second, since we are using a volume with default settings here, docker manages it for you. This has some consequences that may or may not be desirable. The volume will only be deleted if you run docker compose down -v or explicitly delete it via docker volume rm. The volume also cannot be conveniently accessed from the host and is empty when created.

You might thus want to use a directory in your source tree as a volume, this can be implemented like so:

yaml
# compose.yml
volumes:
  config:
    driver_opts:
      type: none
      device: ${PWD}/.config
      o: bind

# ...

The path in driver_opts.device must be absolute, hence the ${PWD}. Alternatively, you can add a bind mount on all your services:

yaml
# compose.yml
services:
  # ...
  anyService:
    # ...
    volumes:
      - .config/:/etc/myApp

And for the sake of completeness: You can also directly bind-mount files:

yaml
# compose.yml
services:
  # ...
  anyService:
    # ...
    volumes:
      - .config/pgPassword:/etc/myApp/pgPassword

Now you can view the generated config files more easily and maintain their initial contents in your source tree. When choosing this approach there are a few things you should note:

  • You should probably gitignore most of these files to avoid accidentally commiting changes made by the init service.
  • Compose will not always create dirs for you. You might want to use a .gitkeep file.
  • The files will potentially be owned by an arbitrary user - especially if you distribute the setup through git. You can usually fix this in your init service.
  • Docker will not "reset" or delete the directory when removing the volume.

What if my database container image doesn't read secrets from files? ​

That would be very odd, given that mounting files is the canonical way kubernetes exposes secrets to pods. Most respectable container images for databases should have a mechanism to support this - there usually also is an env var to customize the path. Double check documentation and if that really isn't supported you can always fall back to customizing the entrypoint and command of the database container: Mount a script that reads the secret file and passes it to the original entrypoint. Ideally use exec to start the original entrypoint so you avoid unnecessarily nesting processes in the container. Then set your script as entrypoint.

Wait, didn't we just implement what docker (compose) secrets do? ​

Caution: Rant ahead.

Unfortunately, no. I haven't worked with docker swarm secrets yet, but I have experimented with secrets in compose and it seems that compose will simply take the secret source specified (env var or file) and create a file with the contents in the respective containers. This file is writable and not shared or "synchronized" with other containers. What's worse: Changes to secrets (or configs) aren't propagated to containers when you run docker compose up — not even when the content of a config is inlined in the compose file!

I personally don't consider this useful. The properties that I would expect from a secret feature would be integration with different backends for persistence (vaults) and mechanisms that help me manage a secret. E.g. a handy shortcut to restarting all containers that use a given secret, which i can invoke after rolling it.

Violating the expectation that docker compose up aligns containers with what is declared in the compose file really seems like a step in the wrong direction.👎

Bonus: Running the initialisation "on first boot" ​

But let's end on a high note. If you are shipping a compose project as part of a VM image, the best way to make it automatically initialize is through a systemd unit with ConditionFirstBoot=yes. For this to work you need to (and generally should for such use-cases) make sure /etc/machine-id is not included in the image.

systemd
[Unit]
Description=MyProject
ConditionFirstBoot=yes
After=docker.service

[Service]
Type=oneshot
WorkingDirectory=/path/to/project
ExecStart=docker compose up -d

[Install]
WantedBy=multi-user.target