Deploying a Phoenix 1.6 app with Docker and Elixir Releases

Deploying a Phoenix 1.6 app with Docker and Elixir Releases

Deploy a Phoenix 1.6 app with Docker and Elixir Releases

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.

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:

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 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.

# 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.

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.

# 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.

# 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.

# compile project
COPY lib lib
RUN mix compile

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

# 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.

# 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.

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:

# 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:

# 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.

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:

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:

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:

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:

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:

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:

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:

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:

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)

docker exec -it 8281f722c845 psql -U postgres

Create the production database, and exit the container:

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.:

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:

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.

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

You should see something like this:

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 for the saturn project is open source under the MIT license. Use the main branch.

About

I'm Miguel Cobá. I write about Elixir, Elm, Software Development, and eBook writing.

Photo by Ian Taylor on Unsplash