Skip to content

Working with Docker

It is becoming more and more common to deploy containerized applications - releases are well suited for these environments as well!

This guide will walk you through the best way to automate the build of a Docker image containing just your release. By avoiding the need to include build tools and other compile-time concerns in the final image, the images are lighter and the attack surface is smaller. When combined with base images like Alpine Linux, you can even further reduce the size and attack surface of the final image.

Info

This guide assumes you are already familiar with building a release, if you have not seen how to do that, I would recommend visiting the Walkthrough guide first.

Tip

If you’d like to see an example project which makes uses of the information in this guide, check out distillery-test. It’s a great way to try things out without needing to create a new project!

The Dockerfile

In the root of your project, create a new file named Dockerfile with the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# The version of Alpine to use for the final image
# This should match the version of Alpine that the `elixir:1.7.2-alpine` image uses
ARG ALPINE_VERSION=3.8

FROM elixir:1.7.2-alpine AS builder

# The following are build arguments used to change variable parts of the image.
# The name of your application/release (required)
ARG APP_NAME
# The version of the application we are building (required)
ARG APP_VSN
# The environment to build with
ARG MIX_ENV=prod
# Set this to true if this release is not a Phoenix app
ARG SKIP_PHOENIX=false
# If you are using an umbrella project, you can change this
# argument to the directory the Phoenix app is in so that the assets
# can be built
ARG PHOENIX_SUBDIR=.

ENV SKIP_PHOENIX=${SKIP_PHOENIX} \
    APP_NAME=${APP_NAME} \
    APP_VSN=${APP_VSN} \
    MIX_ENV=${MIX_ENV}

# By convention, /opt is typically used for applications
WORKDIR /opt/app

# This step installs all the build tools we'll need
RUN apk update && \
  apk upgrade --no-cache && \
  apk add --no-cache \
    nodejs \
    yarn \
    git \
    build-base && \
  mix local.rebar --force && \
  mix local.hex --force

# This copies our app source code into the build container
COPY . .

RUN mix do deps.get, deps.compile, compile

# This step builds assets for the Phoenix app (if there is one)
# If you aren't building a Phoenix app, pass `--build-arg SKIP_PHOENIX=true`
# This is mostly here for demonstration purposes
RUN if [ ! "$SKIP_PHOENIX" = "true" ]; then \
  cd ${PHOENIX_SUBDIR}/assets && \
  yarn install && \
  yarn deploy && \
  cd .. && \
  mix phx.digest; \
fi

RUN \
  mkdir -p /opt/built && \
  mix release --verbose && \
  cp _build/${MIX_ENV}/rel/${APP_NAME}/releases/${APP_VSN}/${APP_NAME}.tar.gz /opt/built && \
  cd /opt/built && \
  tar -xzf ${APP_NAME}.tar.gz && \
  rm ${APP_NAME}.tar.gz

# From this line onwards, we're in a new image, which will be the image used in production
FROM alpine:${ALPINE_VERSION}

# The name of your application/release (required)
ARG APP_NAME

RUN apk update && \
    apk add --no-cache \
      bash \
      openssl-dev

ENV REPLACE_OS_VARS=true \
    APP_NAME=${APP_NAME}

WORKDIR /opt/app

COPY --from=builder /opt/built .

CMD trap 'exit' INT; /opt/app/bin/${APP_NAME} foreground

Tip

This guide uses Alpine Linux, but you can use a different base image, you can find official Elixir base images here.

Warning

Make sure that the version of Linux that you use for the final image matches the one used by the builder image (in this case, elixir:1.7.2-alpine, which uses Alpine Linux 3.8). If you use a different version, the release may not work, since the Erlang runtime was built against a different version of libc (or musl in Alpine’s case)

Info

Our use of yarn above is optional, you can use whatever your project uses, just modify the Dockerfile as necessary. The choice to use yarn over npm is to take advantage of Yarn’s significantly faster dependency fetching.

Tip

While this Dockerfile enables REPLACE_OS_VARS, you will probably want to take advantage of the config provider for Mix.Config instead, see the Handling Configuration document for more information.

To prevent reperforming steps when not necessary, add a .dockerignore to your project with the following:

1
2
3
4
5
6
7
8
9
_build/
deps/
.git/
.gitignore
Dockerfile
Makefile
README*
test/
priv/static/

Feel free to extend it as necessary - ideally you want to ignore anything not involved in the build.

Building the image

To help automate building images, it is recommended to use a Makefile or shell script. I prefer to use Makefiles for this purpose generally. The following is a simple Makefile which will build our image, and produces friendly help output when you run just make in the project directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.PHONY: help

APP_NAME ?= `grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g'`
APP_VSN ?= `grep 'version:' mix.exs | cut -d '"' -f2`
BUILD ?= `git rev-parse --short HEAD`

help:
    @echo "$(APP_NAME):$(APP_VSN)-$(BUILD)"
    @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

build: ## Build the Docker image
  docker build --build-arg APP_NAME=$(APP_NAME) \
    --build-arg APP_VSN=$(APP_VSN) \
    -t $(APP_NAME):$(APP_VSN)-$(BUILD) \
    -t $(APP_NAME):latest .

run: ## Run the app in Docker
  docker run --env-file config/docker.env \
    --expose 4000 -p 4000:4000 \
    --rm -it $(APP_NAME):latest

Now that those files have been created, we can build our image! The next step is to run the build:

1
$ make build

Warning

If make reports an error mentioning multiple target patterns, you need to ensure the Makefile is formatted with tabs not spaces.

If make ran successfully, you now have a production-ready image!

Running the image

Our next step is to test out our image! We’re going to assume that your app was built using the config provider for Mix.Config, which would look like the following in your rel/config.exs:

1
2
3
4
5
6
7
8
9
release :myapp do
  # snip..
  set config_providers: [
    {Mix.Releases.Config.Providers.Elixir, "${RELEASE_ROOT_DIR}/etc/config.exs"}
  ]
  set overlays: [
    {:copy, "rel/config/config.exs", "etc/config.exs"}
  ]
end

The config file referenced here (rel/config/config.exs) should look something like the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use Mix.Config

config :myapp, MyApp.Repo,
  username: System.get_env("DATABASE_USER"),
  password: System.get_env("DATABASE_PASS"),
  database: System.get_env("DATABASE_NAME"),
  hostname: System.get_env("DATABASE_HOST"),
  pool_size: 15

port = String.to_integer(System.get_env("PORT") || "8080")
config :myapp, MyApp.Endpoint,
  http: [port: port],
  url: [host: System.get_env("HOSTNAME"), port: port],
  root: ".",
  secret_key_base: System.get_env("SECRET_KEY_BASE")

For convenience when testing locally, create a file, config/docker.env, with the content below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
HOSTNAME=localhost
SECRET_KEY_BASE="u1QXlca4XEZKb1o3HL/aUlznI1qstCNAQ6yme/lFbFIs0Iqiq/annZ+Ty8JyUCDc"
DATABASE_HOST=db
DATABASE_USER=postgres
DATABASE_PASS=postgres
DATABASE_NAME=myapp_db
PORT=4000
LANG=en_US.UTF-8
REPLACE_OS_VARS=true
ERLANG_COOKIE=myapp

This file will be used to automatically export all of the system environment variables used to configure our application.

We’re going to use Docker Compose for running our app locally, so create another file in the project root, called docker-compose.yml, with the following content:

1
2
3
4
5
6
7
8
9
version: '3.5'

services:
  web:
    image: "myapp:latest"
    ports:
      - "80:4000" # In our .env file above, we chose port 4000
    env_file:
      - config/docker.env

Notice above that we are telling Docker Compose to use the docker.env file we created above, this is how those values end up exported in the running container.

If we depend on other services, a database for example, we can start them here as well. First, we just need to add the service description for the database:

1
2
3
4
5
6
7
8
db:
  image: postgres:10-alpine
  volumes:
    - "./volumes/postgres:/var/lib/postgresql/data"
  ports:
    - "5432:5432"
  env_file:
    - config/docker.env

Warning

Be careful what you name the service! This name will be the hostname used to talk to the service. In this case, it will be db. You will also need to make sure the name used matches what is in config/docker.env.

Notice again that we’re feeding the service docker.env so that we can configure it.

The only other step needed is to make db a dependency for web, like so:

1
2
3
4
5
services:
  web:
    depends_on:
      - db
    # snip..

To start everything, simply run docker-compose up or docker-compose up -d if you want to start as a daemon.

You should now be able to open your browser to http://localhost:4000 to see the running app.

Tip

You can also use Docker Swarm, by first initializing Swarm:

1
$ docker swarm init

And then deploying a new stack:

1
$ docker stack deploy -c docker-compose.yml myapp

This approach requires some minimal adjustments to our docker-compose.yml file, see the Deploying To Digital Ocean guide to learn more.