Deploying Phoenix for Elixir on AWS EC2

Aug 17, 2020

A guide to deploying an Elixir app using the Phoenix framework on AWS EC2 running Amazon Linux 2.

Building B2B Saas integrations?

Xkit's embedded integration platform helps you deliver reliable, maintainable integrations without an engineer.

Sign up for free ▶

Background and Overview

Xkit is built on Phoenix, the popular framework for the Elixir language (which in turn runs on the Erlang VM).

When it came time to deploy our application, we were deciding between Gigalixir, Heroku, and AWS. Ultimately, although AWS would be a little bit more complicated to deploy, for some of the things we wanted to do (subdomain for each customer, custom domain support, etc) AWS was the better choice.

We also decided we wanted to deploy directly onto the EC2 instance rather than running the deployment in Docker. In our view, OTP handles a lot of the same jobs that Docker does, so adding Docker into the mix (no pun intended) is adding indirection, maintenance, and potentially performance bottlenecks.

For building the release, we settled on Elixir Releases. These seem to be the "way of the future" when it comes to deploying Elixir applications, and we wanted to futureproof our infrastructure setup.

However, what we assumed to be a pretty common scenario of deploying an Elixir Release to EC2 seemed to be largely undocumented. So we built some tools ourselves, and thought we'd share them.

Steps for Deployment

The following assumes that you have a Phoenix application on Elixir v1.9 or later that you're trying to deploy. It is based on the Phoenix guide to deployment so its recommended that you read that to get an understanding of the steps we're doing here.

Throughout this guide, we'll denote variables that you're intended to replace with double brackets, like {{ replace_me }}. (yes, this comes from our use of Jinja2 within Ansible!)

The Dockerfile

Elixir doesn't support cross-compilation, so to build an Elixir Release that targets Amazon Linux 2 for EC2, we either need to use another EC2 box to build our application, or use a Docker container.

We opted to use a Docker container so that we could keep our production box for use only in serving production traffic. We could have also spun up another EC2 instance to use just for building, but that felt like overkill.

The Dockerfile below targets Amazon Linux using the amazonlinux:2 base image.

# Building an Phoenix (Elixir) Release targeting Amazon Linux for EC2
# By https://github.com/treygriffith
# Original by the Phoenix team: https://hexdocs.pm/phoenix/releases.html#containers
#
# Note: Build context should be the application root
# Build Args:
# OTP_VERSION - the OTP version to target, like 23.0
# ELIXIR_VERSION - the Elixir version to target, like 1.10.4
#
# If you have other environment variables in config/prod.secret.exs, add them as `ARG`s in this file

FROM amazonlinux:2 AS build

# https://gist.github.com/techgaun/335ef6f6abb5a254c66d73ac6b390262
RUN yum -y groupinstall "Development Tools" && \
    yum -y install openssl-devel ncurses-devel

# Install Erlang
ARG OTP_VERSION
WORKDIR /tmp
RUN mkdir -p otp && \
    curl -LS "https://web.archive.org/web/20211116200237/http://erlang.org/download/otp_src_${OTP_VERSION}.tar.gz" --output otp.tar.gz && \
    tar xfz otp.tar.gz -C otp --strip-components=1
WORKDIR otp/
RUN ./configure && make && make install

# Install Elixir
ARG ELIXIR_VERSION
ENV LC_ALL en_US.UTF-8
WORKDIR /tmp
RUN mkdir -p elixir && \
    curl -LS "https://web.archive.org/web/20211116200237/https://github.com/elixir-lang/elixir/archive/v${ELIXIR_VERSION}.tar.gz" --output elixir.tar.gz && \
    tar xfz elixir.tar.gz -C elixir --strip-components=1
WORKDIR elixir/
RUN make install -e PATH="${PATH}:/usr/local/bin"

# Install node
RUN curl -sL https://rpm.nodesource.com/setup_12.x | bash - && \
    yum install nodejs -y

# prepare build dir
WORKDIR /app

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

# set build ENV
ENV MIX_ENV=prod

# install mix dependencies
COPY mix.exs mix.lock ./
COPY config config
RUN mix do deps.get, deps.compile

# build assets
COPY assets/package.json assets/package-lock.json assets/
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

COPY priv priv
COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix phx.digest

# compile and build release
COPY lib lib
# uncomment COPY if rel/ exists
# COPY rel rel
RUN mix do compile, release

If you have any compile-time configuration in environment variables (e.g. in config/prod.exs), you'll need to add them as ARGs to the Dockerfile. (As an example, we use compile-time configuration to specify the HTTP port).

This Dockerfile is designed to live in the root directory of your application. If you decided to put it elsewhere (e.g. in a deploy directory, like we do), make sure you set the build context to the application root.

Building The Release

Now that we have our Dockerfile, we need to build and extract the Elixir Release.

First We need to grab our current OTP version:

We use this version to download Erlang, so we need both the major and minor version. See this StackOverflow answer for more details about why this is the best way to get both.

erl -eval '{ok, Version} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION"])), io:fwrite(Version), halt().' -noshell

and Elixir version:

elixir -e "IO.puts(System.version())"

Then we'll build the Docker image:

docker build -t {{ your_image_name }} \
                --build-arg OTP_VERSION={{ otp_version }} \
                --build-arg ELIXIR_VERSION={{ elixir_version }}

Don't forget, if you have other compile time environment variables, you'll need to pass them in here

With the Docker image built, we can create a temporary container and extract the release:

docker run --name {{ your_build_container }} {{ your_image_name }} sh
docker cp {{ your_build_container }}:/app/_build/prod/rel/{{ your_app_name }} ./temp

Now we've got an Elixir Release that targets Amazon Linux 2!

Move to EC2 and Start

All that's left is actually starting up the release.

First, on your EC2 box, create a location for the release to live.

mkdir -p ~/app

Copy the new release to EC2 (this is from your local machine):

scp ./temp/{{ your_app_name }} ec2-user@{{ your_ec2_host }}:~/app

And start up the new release:

~/app/bin/{{ your_app_name }} daemon_iex

You'll need to run the daemon_iex command with whatever environment variables you're using in releases.exs, like DATABASE_URL.

Wrap-up

With the steps above, you should be able to successfully deploy an Elixir Release for Phoenix to Amazon Linux on AWS EC2.

There are other steps to a successful deployment approach that we'll hopefully cover in future posts, such as:

  • Configuring EC2 and Phoenix to use alternate ports for HTTP/S to avoid running as root
  • Handling RELEASE_COOKIE
  • Automating the release (via Ansible)

Happy deploying!

Learn something?

We're sharing what we've learned from building Xkit, the embedded integration platform for developers. It's the best way to build integrations — using your existing API.