Deploying a Phoenix 1.6 app with Docker and Elixir Releases
Tags: docker, elixir, phoenix, web
Published: 2021-10-11 09:40:13

---
title: Deploying a Phoenix 1.6 app with Docker and Elixir Releases
published: true
description: Deploy a modern Phoenix 1.6 app using Docker containers and Elixir Release
tags: docker, elixir, phoenix, web
//cover_image: https://direct_url_to_image.jpg
canonical_url: https://blog.miguelcoba.com/deploying-a-phoenix-16-app-with-docker-and-elixir-releases
series: Deploying Elixir
---
I'll explain how to deploy a Phoenix 1.6 application with Docker and Elixir Releases

## Prerequisites

Follow the previous guides to prepare your Phoenix 1.6 image to [use Elixir Releases](https://blog.miguelcoba.com/preparing-a-phoenix-16-app-for-deployment-with-elixir-releases).

## Build process

Docker builds images in steps, each step generating an image layer as a result. If you organize your steps wisely, you can avoid rebuilding layers and the build process will be faster. For example, if one step downloads dependencies for the project and the next step compiles the code, the first time you build it, both steps will be executed and two layers will be generated. But if you later make a change in the source code and rebuild the Docker image, you'll see that the second step will be executed but the first one will be skipped. A new second layer will be generated too, to replace the old second one. The first step won't be executed, because no changes to the dependencies were made and therefore there is no need to regenerate its image layer. The net effect is that you get a faster build time the second time.

The Docker image we are going to create for the Phoenix app will take advantage of this.

There is an additional feature we'll use. Docker allows you group steps into build stages. One build stage can refer to previous build stages. We will use one stage to do build the elixir release and another stage to run the release. 

## Docker image stage for building the release

First let's create a `Dockerfile` in the root of the project:

```Docker
ARG MIX_ENV="prod"

# build stage
FROM hexpm/elixir:1.12.3-erlang-24.1.2-alpine-3.14.2 AS build

# install build dependencies
RUN apk add --no-cache build-base git python3 curl
```

The first line declares an [argument](https://docs.docker.com/engine/reference/builder/#arg) that can be passed to the docker builder at build time: `MIX_ENV`. Similar to command line arguments, it allows us to change the behavior of the build. We are also giving it a default value of "prod", so that we don't have to specify it every time.

Second line starts a build stage specifying a base image to build upon. It names the build stage as `build`. The base image we use has the same versions we use in our project: Elixir 1.12.3 and Erlang 24.1.2. Alpine is a small linux distribution with fast startup time and small memory usage.

The `RUN` command installs some basic build tools into the Alpine Linux image, creating a docker image layer as a result.

```Docker
# sets work dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force
```

This creates a working directory for all the subsequent commands to work on. Then installs `hex` and `rebar`. 
As we seldom change the build tools or the hex or rebar install commands, this image layer won't be regenerated often.

```Docker
ARG MIX_ENV
ENV MIX_ENV="${MIX_ENV}"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
```

This new layer will contain the mix dependencies for the environment we choose. You can see we are setting the `MIX_ENV` environment variable to the value of the `MIX_ENV` variable. Although they have the same name, they are different things. One is the value we pass from outside to the docker when we start the build. The other is an environment variable that will exist during the image build process. 

But there is a catch. The `MIX_ENV` argument we declared in the first line of the Docker file doesn't exist anymore after the FROM line. We can use its default value if we redeclare it after a FROM as we are doing here. The effect is that the docker variable `MIX_ENV` will have a value again in this build stage.
 
Note that if we change the mix.ex or mix.lock files, this step will generate a new layer. Otherwise it will remain unchanged.

```Docker
# copy compile configuration files
RUN mkdir config
COPY config/config.exs config/$MIX_ENV.exs config/

# compile dependencies
RUN mix deps.compile
```

New layer. This one copies the compile time configuration files and then start the dependencies compilation. Again, if we don't change the config files content, this layer won't be regenerated.

```Docker
# copy assets
COPY priv priv
COPY assets assets

# Compile assets
RUN mix assets.deploy
```

This step compiles the web assets. Again, no changes in priv/ or assets/ means no new layer is created here.

Now we can compile the project binaries.

```Docker
# compile project
COPY lib lib
RUN mix compile
```

We already have a compiled binary, but Elixir Release allows to configure things for runtime.

```Docker
# copy runtime configuration file
COPY config/runtime.exs config/

# assemble release
RUN mix release
```

So this step is generating a new layer that will only be rebuilt if there are changes in the runtime configuration. The output of this step is an assembled release.

At this point we have built our Elixir/Phoenix release. But we only need the output binaries that were generated into the `_build/prod/rel/saturn` directory. Everything else is of no use for us in the production server. We can discard anything else for running the release.

## Docker image stage for running the release

We will use a new image stage for running the release. The release, as the Elixir documentation says, has anything required to run the project, including the Erlang virtual machine. That means that the hosting image doesn't need to have it installed separately. That's an extra opportunity to shrink the image size.

```Docker
# app stage
FROM alpine:3.14.2 AS app

ARG MIX_ENV

# install runtime dependencies
RUN apk add --no-cache libstdc++ openssl ncurses-libs
```

This starts a new build stage named `app`. It uses a plain Alpine Linux base image without Elixir in it. Next, it redeclares the `MIX_ENV` docker variable so that it can be used later.
Then installs the runtime dependencies and nothing else.

```Docker
ENV USER="elixir"

WORKDIR "/home/${USER}/app"
```

For security reasons we will to run the release binary using a non-privileged user in the Linux Alpine image. We are going to create a user named `elixir` for that. We declare an environment variable `USER` and the work dir pointing to a directory inside that user home. This user doesn't exist in the alpine base image we are using. Let's create it:

```Docker
# Create  unprivileged user to run the release
RUN \
  addgroup \
   -g 1000 \
   -S "${USER}" \
  && adduser \
   -s /bin/sh \
   -u 1000 \
   -G "${USER}" \
   -h "/home/${USER}" \
   -D "${USER}" \
  && su "${USER}"
```

With the user created we can copy the build stage binaries into this build stage:

```Docker
# run as user
USER "${USER}"

# copy release executables
COPY --from=build --chown="${USER}":"${USER}" /app/_build/"${MIX_ENV}"/rel/saturn ./
```

As you can see, we are switching to the newly created user and copying the assembled release into the workdir and changing ownership of the files to the new user.

```Docker
ENTRYPOINT ["bin/saturn"]

CMD ["start"]
```

The last thing is to tell docker to run the container as an executable by telling it which command to run in ENTRYPOINT. The CMD specify default parameters to pass to the command in ENTRYPOINT in case we don't set any when running the container.

The `Dockerfile` should be like this:

```Dockerfile
ARG MIX_ENV="prod"

# build stage
FROM hexpm/elixir:1.12.3-erlang-24.1.2-alpine-3.14.2 AS build

# install build dependencies
RUN apk add --no-cache build-base git python3 curl

# sets work dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

ARG MIX_ENV
ENV MIX_ENV="${MIX_ENV}"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV

# copy compile configuration files
RUN mkdir config
COPY config/config.exs config/$MIX_ENV.exs config/

# compile dependencies
RUN mix deps.compile

# copy assets
COPY priv priv
COPY assets assets

# Compile assets
RUN mix assets.deploy

# compile project
COPY lib lib
RUN mix compile

# copy runtime configuration file
COPY config/runtime.exs config/

# assemble release
RUN mix release

# app stage
FROM alpine:3.14.2 AS app

ARG MIX_ENV

# install runtime dependencies
RUN apk add --no-cache libstdc++ openssl ncurses-libs

ENV USER="elixir"

WORKDIR "/home/${USER}/app"

# Create  unprivileged user to run the release
RUN \
    addgroup \
    -g 1000 \
    -S "${USER}" \
    && adduser \
    -s /bin/sh \
    -u 1000 \
    -G "${USER}" \
    -h "/home/${USER}" \
    -D "${USER}" \
    && su "${USER}"

# run as user
USER "${USER}"

# copy release executables
COPY --from=build --chown="${USER}":"${USER}" /app/_build/"${MIX_ENV}"/rel/saturn ./

ENTRYPOINT ["bin/saturn"]

CMD ["start"]
```

Now that we have a Dockerfile ready, let's build the image:

In the root of the project run this:

```bash
docker image build -t elixir/saturn .
```

This will build the image following the Dockerfile commands and will tag it with the name `elixir/saturn`. You'll see a lot of messages while it is being built. Also, the first time you run it will take some time as it will download the alpine images we specified in the FROM commands but the following times it will use a local cache and it will be faster.

You can check your local images with:

```bash
docker image list
REPOSITORY                      TAG            IMAGE ID       CREATED         SIZE
elixir/saturn                   latest         02d3d6d963f5   5 seconds ago   23.3MB
```

Now it is time to run a container based on this image we created. So far we have been using the PostgreSQL database installed locally, and we used a DATABASE_URL like this:

```bash
export DATABASE_URL=ecto://postgres:postgres@localhost/saturn_dev
```

But that is not going to work when we start the container for our app. The localhost address is going to point to the same linux instance the app is running in and that container has no PostgreSQL installed. We need to change the url to point to a database that is outside the docker image.

One option is to use the IP address our laptop has because that is accessible from the docker container. You could do something like this:

```bash
ifconfig en0
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
	options=400<CHANNEL_IO>
	ether 6c:96:cf:dd:fd:3b
	inet6 fe80::c61:da63:14a3:73af%en0 prefixlen 64 secured scopeid 0x4
	inet 192.168.1.135 netmask 0xffffff00 broadcast 192.168.1.255
	nd6 options=201<PERFORMNUD,DAD>
	media: autoselect
	status: active
```

and use that IP address in the `DATABASE_URL`. But we'll also need to configure PostgreSQL `pg_hba.conf` to accept connection from hosts other than localhost or relax the security options, because the default install with `brew` in macOS is restricted to connections coming from localhost and the security is set to trust.

I am going to do it in other way: using docker containers. I'll start a docker container for PostgreSQL and a docker container for our Elixir/Phoenix app. And I'll connect them using a virtual network. Then I'll run the migrations from the elixir app container.

Let's create a the virtual network:

```bash
docker network create saturn-network
```

And let's get a postgres docker image and boot it up binding it up to the virtual network I just created:

```bash
docker run -d --network saturn-network --network-alias postgres-server -e POSTGRES_PASSWORD=supersecret postgres
```

This command downloads the latest postgres docker image, binds it to the network we created, sets a DNS alias for it, `postgres-server`, sets an environment variable for the default user and, as we didn't say otherwise, will use the default username `postgres`. It also runs in detached mode.

Check that is running:

```bash
docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED          STATUS          PORTS      NAMES
8281f722c845   postgres   "docker-entrypoint.s…"   45 seconds ago   Up 44 seconds   5432/tcp   kind_mahavira
```

Let's connect to it to create the database. You need to use the CONTAINER ID you got from the `docker ps` (change the id to your own container id)

```bash
docker exec -it 8281f722c845 psql -U postgres
```

Create the production database, and exit the container:

```bash
docker exec -it 8281f722c845 psql -U postgres
psql (14.0 (Debian 14.0-1.pgdg110+1))
Type "help" for help.

postgres=# CREATE DATABASE saturn_prod;
CREATE DATABASE
postgres=# \q
```

Now lets set our environment variables to point to this docker postgres database.:

```bash
export DATABASE_URL=ecto://postgres:supersecret@postgres-server/saturn_prod
```

The other environment variables don't require change, but they need to be exported in the shell you're going to use to run the elixir app container. If you have opened a new terminal ensure you have export them all there.

Now we can start the elixir app:

```bash
docker container run -dp $PORT:$PORT -e POOL_SIZE -e PORT -e DATABASE_URL -e SECRET_KEY_BASE --network saturn-network  --name saturn elixir/saturn
```

You can see that we are mapping the port used inside the docker image to start the elixir release to the same port number on the running container. We pass the other environment variables as they are currently exported in the terminal. Notice that this docker container will bind to the `saturn-network` too, otherwise won't be able to reach the `postgres-server` host.

Finally, let's run the migrations. We will send the command to the elixir app container and that will run the migrations over the virtual network in the postgres container.

```bash
docker exec -it saturn bin/saturn eval "Saturn.Release.migrate"
```

You should see something like this:

```bash
12:14:32.135 [info] Migrations already up
```

You can now point to http://localhost:4001 and you'll be accessing your app, deployed as an Elixir Release, inside a docker container that is connected through a virtual network to another container with postgres running in it.

How cool is that?

## Source code

The  [source code](https://github.com/miguelcoba/saturn)  for the saturn project is open source under the MIT license. Use the `main` branch.

## About

I'm [Miguel Cobá](https://miguelcoba.com). I write about Elixir, Elm, Software Development, and eBook writing.

- Follow me on [Twitter](https://twitter.com/MiguelCoba_)
- Subscribe to my [newsletter](https://newsletter.miguelcoba.com)
- Read all my articles on my [blog](https://blog.miguelcoba.com)
- Get my books:
    - [100 Elixir Tips](https://store.miguelcoba.com/l/100elixirtips) [FREE]
    - [Deploying Elixir](https://store.miguelcoba.com/l/deployingelixir) [FREE]
    - [Deploying Elixir: Advanced Topics](https://store.miguelcoba.com/l/advancedtopics)
    - [eBook Writing Workflow for Developers](https://store.miguelcoba.com/l/ebookwriting)