SW engineering, engineering management and the business of software

subscribe for more
stuff like this:

Deploying Elixir Phoenix webapps to the cloud with releases

I love many things about go. One of my favorite things is that cross-compilation is an absolute breeze. It takes just seconds and it’s especially easy with a tool like gox. After that, deployment is even easier. Just rsync or scp your executable binary and maybe a secret file or two. Boom💥 done. It’s essentially the easiest possible deployment story of any langauge or framework I know of.

So as a long time go developer (nearly a decade!) every other ecosystem pales in comparison.

Elixir is among them. Elixir itself is nearly a decade old, so there has been a lot of community work to make this better over time. As someone relatively new to this lovely language, I’m super grateful for all the hard work. Deploying basic Phoenix apps isn’t golang-easy, but’s it’s not painful.

This post details how I deploy a phoenix webapp, developed on a Mac to a single linux cloud instance.

A quick gotcha

Before you even deploy, there is one non-obvious thing to be aware of related to how config files are compiled and executed.

Most of the files in /config are executed at compile-time, meaning things like environment variables are taken from the machine the code is being compiled on. In my case, the Macbook I’m typing on.

The one exception is config/runtime.exs. This file and any configuration that happens within it is executed right before the application starts up on whatever machine your elixir code is running. This means if I run mix.test on my laptop, it will take env variables or system info from that machine. If I run it on a linux cloud instance, it will take the environment from that cloud machine.

Tooling

First off, as you read about deploying elixir apps, you’ll see two primary release tools.

  • Distillery
  • mix release

The first is an older tool that predates mix release. As I understand it, Distillery was essentially a community tool created to help with production deployments. mix release is relatively new, and is bundled with Elixir 1.9. Most of the new development and tooling is built around mix release. At least one long time Elixir dev I talked with seemed to think that mix release is the future.

I didn’t spend much time with Distillery, but mix release is a nice little tool. It essentially is able to package everything you need to run an Elixir app into a single directory. the layout looks like this:

/my_app-1.2.3
    /bin
        my_app
    /erts-1.11.5
        /bin
    /lib
    /releases
        /1.2.3

The /my_app-1.2.3/bin/my_app is a simple shell script that you can use to start, stop, check version, launch in daemon mode, or even eval or call rpc expressions. /erts-x.y.z is the erlang VM. /lib is your app and all it’s dependencies. releases is a bunch of misc helper files, scripts, etc.

These release directories are essentially self-contained and can simply be scp’d or rsync’d to a remote cloud instance. rsync is preferred as most of it doesn’t change between releases. I typically see 100x or more speedups using rsync.

For this post, I added the following definitions to mix.exsproject definition:

def project do
  [
    app: :my_app_,
    version: "0.3.2-a.2",

    <snip>

    default_release: :my_app,
    releases: [
      my_app: [
        include_executables_for: [:unix],
        include_erts: true,
        strip_beams: true,
        quiet: false,
        steps: [:assemble, :tar]
      ]
    ]
  ]
end

Read the Documentation!

Assuming you are deploying a webapp, you will definitely want to start with the Phoenix release documentation:

https://hexdocs.pm/phoenix/releases.html

You are going to want to exit any files the above link tells you to edit. In particular, the stuff about prod.secret.exs and release.ex. The actuall steps are automated with scripts I’m detailing below.

For release.ex I had to add a line to make it work. Specifically, I needed to start :ssl in the load_app function:

defp load_app do
    Application.load(@app)
    Application.ensure_all_started(:ssl)
end

Additionally, the mix release hexdoc page has more details that help explain what is going on under the hood:

https://hexdocs.pm/mix/Mix.Tasks.Release.html

Definitely read the first, and I recommend reading both.

Building:

With Elixir, cross-compiling is theoretically possible, but it’s not tested and not supported in any way.

Update: I’ve since learned that cross-compilation may not be as dangerous as I first assumed. See here: https://github.com/LostKobrakai/criss_cross

For the common case of building on a Mac or Windows machine and deploying to a linux cloud instance, you need a way to build on linux. Your basic strategies come down to:

  • Build in docker, spit out a tarball
  • Build on a cloud instance
  • Build a docker image, run the image
  • Make the CI tool do it

For this post, I’ll walk thru the first.

For the second, just do a git checkout or rsync your project to the cloud instance and mix compile && mix release. You’ll have to make sure that elixir and any dependencies you need are installed on the cloud instance.

For the third, see the Phoenix documentation here.

With CI tools, reference your CI tool of choice to see if existing documentation exists or try to implement one of the above strategies via the CI tool.

Build in docker, spit out a tarball

First credit and thanks to Kai Wern Choong and their post about deploying Phoenix. It was super helpful to as I was learning about how to deploy with docker.

Here is the script I use to build a Phoenix app inside of a linux docker container, then spit out a tarball:

#!/bin/sh

set -o nounset
set -o errexit

#######
# build project in a dockerfile then output a tarball to sync with external server
# primarily using this post as a resource: 
# https://kaiwern.com/posts/2020/06/20/building-elixir/phoenix-release-with-docker/#build-image

export DATABASE_URL=`cat postgres.secret`
export SECRET_KEY_BASE=`mix phx.gen.secret`

docker build --build-arg DATABASE_URL --build-arg SECRET_KEY_BASE -t my_app_server .

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)"
BASE_NAME=${APP_NAME}-${APP_VSN}
TAR_FILENAME=${BASE_NAME}.tar.gz

id=$(docker create ${APP_NAME}_server)
docker cp $id:/app/${TAR_FILENAME} .
docker rm $id

mkdir -p _staging/${BASE_NAME}
mv ${TAR_FILENAME} _staging/${BASE_NAME}/.
cd _staging/${BASE_NAME}
tar -xf ${TAR_FILENAME}
rm ${TAR_FILENAME}

It will simply create a directory called _release and put any release it builds in there.

The SECRET_KEY_BASE env variable is used for things like session tokens. In this script we regenerate everytime, but that will essentially log out all your users every release. SECRET_KEY_BASE should be persisted accross releases if you don’t want that behavior.

Here’s the Dockerfile I use: https://gist.github.com/amattn/037e3ef59c02140efe60b8aa1c00b687

Walking thru the Dockerfile we are essentially doing the following:

  • install system dependencies
  • get our secrets
  • get and build our elixir dependencies
  • copy in some assets
  • build the frontend
  • copy in the rest of the stuff
  • mix compile
  • mix release
  • export tarball

Deploying

Next I have a simple deploy.sh script that deploys a version to an environment (dev|test|staging|prod). It’s not the most elegant script. But it’s my script and I love it:

https://gist.github.com/amattn/ce12a89ceebe402e464ef3c43147f930

This script does assume you have ssh keys or whatever setup.

Here’s what is going on in there:

  • rsync the service file
  • stop the currently running app on the cloud
  • rsync the release and update permissions
  • migrate the DB
    • this assumes you’ve created a release.ex like the docs say you should.
  • restart the app

Lastly, a fairly typical systemd service file (that is rsync’d by the deploy.sh script):

[Unit]
Description=myapp
After=network.target

[Service]
ExecStart=/home/USERNAME/my_app/bin/my_app start
ExecStop=/home/USERNAME/my_app/bin/my_app stop
ExecReload=/home/USERNAME/my_app/bin/my_app restart
WorkingDirectory=/home/USERNAME/my_app
StandardOutput=inherit
StandardError=inherit
Restart=always
User=USERNAME

[Install]
WantedBy=multi-user.target

SSL

I use caddy in front of the Phoenix app to get https working.

A v2 Caddyfile directive will look like this:

# myapp.com reverse proxy
myapp.com {
    reverse_proxy localhost:4000
    encode zstd gzip
}

Caddy is the easiest way to get a Let’s Encrypt cert. It happens automatically. It’s basically a miracle compared to the hoops we used to jump thru before Let’s Encrypt was a thing.

All done

My time to automate deployment for my first Phoenix app was about a day and half.

The final steps would look like this:

  • ./build_docker_release.sh
  • ./deploy.sh 0.3.2 prod

A good chunk of it was just understanding what was going and debugging the Dockerfile (esp those ENV variables). My Docker-fu is a bit rusty these days.

Conceptually, nothing really hard is going on here. Using the above scripts and files as a template, that time to deploy should be much much shorter.

All the hard work was done by the Elixir community members who worked on the mix release functionality. Their work makes it so that I can deploy to a cloud instance without having to worry about what version of what package is installed on that cloud instance. I’m too spoiled to go back to the days where your release artifact has system dependencies. That way lies madness.

All in all, Elixir’s deployment story, like Elixir itself, has a bit of a learning curve, but ends up rather pleasant after you figure it out.

Bonus: GitHub Actions

This was the best thing I found on setting up GitHub Actions to automate some of the steps above: https://github.com/erlef/setup-elixir

Here are two older resources that might help as well:



in lieu of comments, you should follow me on bluesky at @amattn.com and on twitch.tv at twitch.tv/amattn. I'm happy to chat about content here anytime.


the fine print:
〜 about 〜 archive 〜 mastodon 〜 bluesky 〜 twitch 〜 consulting or speaking inquiries 〜
© matt nunogawa 2010 - 2023 / all rights reserved