Using dynamic build-args in docker compose to change behavior in a Dockerfile

Background

My project started with cookiecutter-django a few years ago, and the compose portion of the project has had numerous changes over time as I learned more about the technology and as the project goals became more clear.

The result was that I had four different environments (Local, Staging, Demo, Production) with a confusing build structure. All of the environments shared chunks of configuration, and Staging, Demo, and Production in particular were nearly copies of eachother in most respects other than the Traefik config, which included the unique domain name information for each server environment. There were folders for each environment with its configuration files, but some of these were shared. Just a mess overall. It looked something like this:

.
├── compose
│   ├── demo
│   │   └── traefik
│   │       ├── Dockerfile
│   │       └── traefik.yml
│   ├── local
│   │   ├── django
│   │   │   ├── celery
│   │   │   │   ├── beat
│   │   │   │   │   └── start
│   │   │   │   └── worker
│   │   │   │       └── start
│   │   │   ├── Dockerfile
│   │   │   ├── entrypoint
│   │   │   └── start
│   │   ├── postgres
│   │   │   ├── Dockerfile
│   │   │   └── extensions.sql
│   │   └── traefik
│   │       ├── Dockerfile
│   │       └── traefik.yml
│   ├── production
│   │   ├── django
│   │   │   ├── celery
│   │   │   │   ├── beat
│   │   │   │   │   └── start
│   │   │   │   └── worker
│   │   │   │       └── start
│   │   │   ├── Dockerfile
│   │   │   ├── entrypoint
│   │   │   └── start
│   │   └── traefik
│   │       ├── Dockerfile
│   │       └── traefik.yml
│   └── staging
│       └── traefik
│           ├── Dockerfile
│           └── traefik.yml
├── .env
├── demo.yml
├── local.yml
├── production.yml
└── staging.yml

I wanted to simplify the docker compose setup considerably - for my own wellbeing and sanity!

So today I sat down and did some work. In the process, I removed the Demo environment completely (merged into Staging), simplified the folder structure using a flatter approach where I focused on the type of docker container and better use of envionmental variables.

The final structure looks like this:

.
├── compose
│   ├── django
│   │   ├── celery
│   │   │   ├── beat
│   │   │   │   └── start
│   │   │   └── worker
│   │   │       └── start
│   │   ├── Dockerfile
│   │   ├── entrypoint
│   │   ├── start.local
│   │   ├── start.remote
│   ├── postgres
│   │   ├── Dockerfile
│   │   ├── extensions.sql
│   └── traefik
│       ├── Dockerfile
│       ├── traefik.production.yml
│       └── traefik.staging.yml
├── .env
├── docker-compose.base.yml
├── docker-compose.local.yml
├── docker-compose.remote.yml

A snag in the plan

One snag I ran into was keeping things DRY.

  • I wanted a slightly different django start command between environments. When developing locally, I use a very simple gunicorn configuration, but on remote machines (staging or production) I'm using multiple workers, different logging, and may eventually want to change other things independent of our local dev approach.
  • When running on a remote server, Traefik will need different configurations via the traefil yaml file to describe the load balancing between the services and domains on that server and environment.

I ended up using two environmental variables to distinguish the general 'type' of environment (HOSTTYPE with values of "local" or "remote") from the environment name (HOSTNAME with values of "local", "staging", or "production"). Here, a "remote" HOSTTYPE includes any staging or production server.

Implementation

The .env file

If an .env file with a set of KEY=value environmental variables is included at the same directory level as the compose file, these environmental variables should be imported and can then be used by docker compose (ref). My experience was that if I did not explicitly add the env_file argument to point to this file within the compose file (as I have done below), I received UNBOUNDED VARIABLE errors suggesting the variables were not actually getting passed to the Dockerfile.

# ...

HOSTTYPE=remote
HOSTNAME=staging

# ..

The Compose file

I currently have the following compose files:

  • docker-compose.base.yml
  • docker-compose.local.yml
  • docker-compose.remote.yml

The first file is used as a base from which the other three are extended. In the past, you could simply use the extends keyword within a child compose file to extend from a base compose file, but that option went away after Compose file version 2.1.

The current official method is to call both the base file and the extended file in commands to docker compose. For instance, to bring up the remote compose containers, we need to use the base and remote compose files: docker compose -f docker-compose.base.yml -f docker-compose.remote.yml up -d

Here we are passing the HOSTNAME and HOSTTYPE environmental variables (which come from the .env file) as build arguments that can be read and used in the Dockerfiles when the image is built.

  • The Dockerfile for the django service will select the correct start command file to include on the server, based on the HOSTTYPE (local/remote).
  • The Dockerfile for the traefik service will select the correct traefik yaml file to include on the server, based on the HOSTNAME (staging/production).
services:

  django:
    build:
      context: .
      dockerfile: ./compose/django/Dockerfile
      args:
        HOSTTYPE: ${HOSTTYPE}
    env_file:
      - .env
    image: my_django_image
    # other config options

  traefik:
    build:
      context: .
      dockerfile: ./compose/traefik/Dockerfile
      args:
        HOSTNAME: ${HOSTNAME}
    env_file:
      - .env
    image: my_traefik_image
    # other config options

The Dockerfile

We declare the name of the build argument we want to use via ARG SOMEARGNAME. Remember that we are passing this name from the compose file in build > args > SOMEARGNAME. We can then use ${SOMEARGNAME} to refer to the interpreted argument value within the Dockerfile.

Here is an excerpt from the django Dockerfile.

# ...

ARG HOSTTYPE
COPY ./compose/django/start.${HOSTTYPE} /start

# ...

And here is an excerpt from the traefik Dockerfile.

# ...

ARG HOSTNAME
COPY ./compose/traefik/traefik.${HOSTNAME}.yml /etc/traefik

# ...

Conclusion

This works well in my case, but like anything in tech it is not one-size-fits-all. Hopefully this gives you the resources to do something similar if you so choose.

Other approaches

I also considered using conditional statements to set the value, which would have allowed us to use a single environmental variable [HOSTNAME][`HOSTNAME`].

Something like this pseudocode:

if ${HOSTNAME} = "local"
    COPY ./compose/django/start.local /start
else
    COPY ./compose/django/start.remote /start

Problems with this approach:

  • Using conditionals in a Dockerfile prevents caching in the docker compose build step, potentially slowing builds down.
  • Because we're converting 'staging | production' to 'remote' in a somewhat opaque manner, this approach seemed less clear in my mind than simply using two environmental variables.