Elixir development with Docker

When I start a new project or get setup to contribute to an existing one, the first thing on my mind is: show me the Dockerfile.

Why?

One of the main purposes of Docker is packaging an application with its dependencies into a portable image that can be run on most any host, thus simplifying deployments. Yet when it comes to development of these applications, Docker is often not part of the picture. Why go through the headache of installing version managers and other dependencies when Docker exists?

Even for simple libraries there’s still great value in using Docker so you can easily test your application against multiple versions of the language or framework.

Anatomy of an Elixir Dockerfile

Elixir version as a build arg

If you’re developing a library, you should be testing it against all the versions of Elixir that you support. This can be done by using docker build args to parameterize the base image tag.

ARG elixir_ver=1.10

FROM elixir:${elixir_ver}-slim

The elixir_ver build argument defaults to 1.10 and is used in specifying the base image tag. This can be easily overridden when building the image.

docker build --tag my_app_elixir_1.7 --build-arg elixir_ver=1.7 .

Installing hex and rebar3

By default, hex and rebar3 are not available in the base Docker image for Elixir. These are necessary for fetching dependencies.

RUN mix local.hex --force && \
    mix local.rebar --force

Storing dependencies and build artifacts in the image

By default, mix stores fetched dependencies in ./deps and build artifacts in ./_build.

There are two problems with this when using Docker: performance and build artifacts across versions.

The 2017 article Docker on Mac Performance describes the performance problem well. The short of it: on non-Linux hosts, bind mounts are slow since Docker is running in a virtual machine and mounts into VMs are done with local network shares. Building a slew of dependencies over a bind mount is slow.

The other issue is the build artifacts themselves. If you run your app, stop it, switch Elixir versions, then try to run your app again, it will detect that the build artifacts were built with a different version of Elixir and recompile them. This creates all kinds of headaches especially if you’re using a language server like ElixirLS that will constantly trigger recompiles.

Overriding the defaults

These paths can be overridden using the mix environment variables MIX_DEPS_PATH and MIX_BUILD_PATH.

ENV MIX_DEPS_PATH=/opt/mix/deps
ENV MIX_BUILD_PATH=/opt/mix/build

Caveat: MIX_DEPS_PATH is only supported as of Elixir 1.10. You can still use this with previous versions of Elixir though by reading it in your mix.exs file:

def project do
  [
    app: :my_app,
    elixir: "~> 1.7",
    deps_path: System.get_env("MIX_DEPS_PATH") || "./deps"
  ]
end

Compiling in the image

For quick startup of your app, tests, or iex: pre-compile the dependencies. This requires that app configuration be present along with the mix.exs and mix.lock files.

If you’re only going to be running tests in the image, consider setting MIX_ENV=test and only fetching dependencies for this environment.

ENV MIX_ENV=test

WORKDIR /opt/code

COPY config ./config
COPY mix.exs mix.lock ./

RUN mix do deps.get --only $MIX_ENV, deps.compile

Putting it all together

With all these requirements taken care of, here’s the final Dockerfile:

ARG elixir_ver=1.10

FROM elixir:${elixir_ver}-slim

RUN mix local.hex --force && \
    mix local.rebar --force

# this env var is only picked-up automatically by Elixir >= 1.10
# for previous versions, set it in mix.exs file in `project/0`:
#   deps_path: System.get_env("MIX_DEPS_PATH") || "./deps"
ENV MIX_DEPS_PATH=/opt/mix/deps

# build inside the image and not on the mounted volume to prevent
# recompiles with different Elixir versions
ENV MIX_BUILD_PATH=/opt/mix/build

ENV MIX_ENV=test

WORKDIR /opt/code

# add config before compiling. it will likely affect compile-time options
COPY config ./config
COPY mix.exs mix.lock ./

RUN mix do deps.get --only $MIX_ENV, deps.compile

VOLUME /opt/code

Makefile

Now that we have a nice Dockerfile, let’s make it easy to use with a Makefile. A Makefile is another developer treat to keep your documentation short and sweet and your steps to get up and running even easier.

Taking the example of a paramaterized Dockerfile for testing against multiple Elixir versions, let’s add some targets to use them.

ELIXIR_VER := 1.10
DOCKER_TAG := myapp_elixir_$(ELIXIR_VER)

.PHONY: docker/build docker/test docker/iex

docker/build:
  docker build --tag $(DOCKER_TAG) --build-arg elixir_ver=$(ELIXIR_VER) .

docker/test: docker/build
  docker run --rm -v $(PWD):/opt/code $(DOCKER_TAG) mix test

docker/iex: docker/build
  docker run --rm -it -v $(PWD):/opt/code $(DOCKER_TAG) iex -S mix

Now to build the base image and run your unit tests, a developer just needs to run:

make docker/test

If you want to change the Elixir version, pass it as an argument:

make docker/test ELIXIR_VER=1.7

Wrapping up

Having a Dockerized development environment allows for easy testing of your app against different Elixir versions and reduces friction for developers getting started with contributing to your project. Use a Makefile to make common development tasks even easier.