Notes to self

Cross-compiling Elixir releases with ASDF and Docker

Elixir releases are self-contained directories containing your applications together with all dependencies and the Erlang virtual machine. Although convenient, there are unfortunately not platform-independent. Here’s how to build your application release for any Elixir version and operating system with ASDF and Docker.

Releases are almost as good as a single executable. Almost, because there are platform dependent. Personally, I love minimalism and skipping layers whenever I can. I run Fedora for my desktop so I can actually match my environment with a Fedora server and achieve one of the most effortless deployments there can be (like with Go).

I can move the releases’ files to the server with scp and restart a small systemd service. That’s how simple Elixir deployment can be if you remove anything extra.

However, keeping the same environment is not always possible. And Elixir release won’t just depend on your OS flavor. Instead, an Elixir release depends on your processor architecture and C library version (glibc package). This is because there are still system dependencies in place even though Erlang bytecode is platform-independent.

For example, if I want to deploy to a CentOS 8 or Rocky Linux 8 system I would get a similar complain on startup:

$ /srv/my_app/app/bin/my_app start
/srv/my_app/app/erts-11.2.2.5/bin/beam.smp: /lib64/libc.so.6: version `GLIBC_2.33' not found (required by /srv/my_app/app/erts-11.2.2.5/bin/beam.smp)
/srv/my_app/app/erts-11.2.2.5/bin/beam.smp: /lib64/libc.so.6: version `GLIBC_2.32' not found (required by /srv/my_app/app/erts-11.2.2.5/bin/beam.smp)
/srv/my_app/app/erts-11.2.2.5/bin/beam.smp: /lib64/libm.so.6: version `GLIBC_2.29' not found (required by /srv/my_app/app/erts-11.2.2.5/bin/beam.smp)

Despite using a similar system, it’s game over when the C version library doesn’t match. Not to mention that many developers are running macOS, and therefore cannot use releases directly when deploying to Linux.

One way to solve this is to package the whole release in a Docker container. But what if we don’t want to run containers on the server? What if we just want that release for our target platform?

To that end, we can use Docker locally to compile and retrieve the release artifact. And we can use the ASDF version manager to support various Elixir versions since some systems come with only one version, and some don’t package Elixir at all.

I’ll use Rocky Linux base image in the following example, but you can easily edit the Dockerfile for your target system. I assume building a Phoenix application including any static assets (so they can be served directly by cowboy):

FROM rockylinux/rockylinux as build
SHELL ["/bin/bash", "-c"]
ENV MIX_ENV prod
ENV LANG en_US.UTF-8

# Set the right versions
ENV ERLANG_VERSION latest
ENV ELIXIR_VERSION latest

# Install system dependencies
RUN dnf update -y && \
  dnf group install "Development tools" -y && \
  dnf install -y git openssl-devel ncurses-devel && \
  dnf install -y nodejs && \
  dnf clean all

# Install ASDF
RUN git clone https://github.com/asdf-vm/asdf.git /root/.asdf --branch v0.8.1 && \
  echo "source /root/.asdf/asdf.sh" >> /root/.bashrc

# Install Erlang & Elixir
RUN source /root/.bashrc && \
  asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git && \
  asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git && \
  asdf install erlang $ERLANG_VERSION && \
  asdf install elixir $ELIXIR_VERSION && \
  asdf global erlang $ERLANG_VERSION && \
  asdf global elixir $ELIXIR_VERSION

# Install esbuild
RUN npm install --global esbuild

# Install build tools
RUN source /root/.bashrc && \
  mix local.rebar --force && \
  mix local.hex -if-missing --force

RUN mkdir /myapp
COPY ./ /myapp
WORKDIR /myapp

# Elixir release
RUN source /root/.bashrc && \
  mix deps.get --only prod  && \
  mix compile  && \
  mix phx.digest.clean --all && \
  mix assets.deploy  && \
  mix release --overwrite

# Make a release archive
RUN tar -zcf /myapp/release.tar.gz -C /myapp/_build/prod/rel/my_app .

If you are on a recent Docker version, you can also take advantage of custom build outputs, so that the build already finishes with the artifacts in the given directory:

$ sudo docker build --output type=local,dest=releases .

You’ll need to use the BuildKit backend to use this feature.

If not, make a writable container layer from the result, copy out of that, and delete it:

$ sudo docker build .
$ sudo docker create --name release 64f06191daee
$ sudo docker cp release:/myapp/release.tar.gz ./releases
$ sudo docker rm -f release

Sometimes people run the cp utility from the container while mounting ./releases as a volume. While it’s one command, you need to run the container:

$ sudo docker build .
$ sudo docker run -v /home/strzibny/my_app/releases:/releases 64f06191daee /usr/bin/cp -r /myapp/release.tar.gz /releases

You could also move the release compilation to the run call by making it a script inside the container, which would allow you to reuse the built container with a specific Elixir version for longer.

And that’s it really! If you need to go multi-arch, look here.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS