Deploying Elixir Applications with Duct Tape and Bubblegum

There are a lot of examples on the internet (including some I've written) of how to deploy your Elixir applications, and a lot of them are pretty complicated. I want to show you a simple but reliable deployment example that's a simpler place for you to get started if you're new to deploying Elixir.

I do want to point out that this is not what we do at SmartLogic; but some of my side project deployments are fairly similar. This guide will be skipping everything but base Elixir releases and a bash script that runs a few ssh/scp commands for you. No Ansible, no Kubernetes, no automated deployments. The one thing it does reach for is Docker, to generate the release in the same environment as your server.

This guide is geared towards a fairly standard Phoenix application, but can be adapted for use with a plain Elixir app by removing the Phoenix-specific steps. This guide also assumes you're looking to deploy on a cheap VPS on something like Linode or DigitalOcean.

Release Configuration

We will be configuring the generated release with the file config/releases.exs. See below for my minimal runtime configuration. We'll fill in SECRET_KEY_BASE and DATABASE_URL in the systemd service file.

import Config

config :my_app, Web.Endpoint,
  http: [:inet6, port: 4000],
  url: [host: "myapp.example.com", port: 443, scheme: "https"],
  cache_static_manifest: "priv/static/cache_manifest.json"

config :my_app, Web.Endpoint, secret_key_base: System.get_env("SECRET_KEY_BASE")

config :my_app, MyApp.Repo,
  url: System.get_env("DATABASE_URL"),
  pool_size: 15

config :phoenix, :serve_endpoints, true

config :logger, level: :info


We'll also need to set up a migration task. This is mostly copy-pasted from distillery. Include this as lib/my_app/release_tasks.ex and update for your application.

# From https://github.com/bitwalker/distillery/blob/master/docs/guides/running_migrations.md
defmodule MyApp.ReleaseTasks do
  @moduledoc false

  @start_apps [

  @apps [

  @repos [

  def migrate() do

    # Run migrations
    Enum.each(@apps, &run_migrations_for/1)

    # Signal shutdown

  defp startup() do
    IO.puts("Loading my_app...")

    # Load the code for my_app, but don't start it

    IO.puts("Starting dependencies..")
    # Start apps necessary for executing migrations
    Enum.each(@start_apps, &Application.ensure_all_started/1)

    # Start the Repo(s) for my_app
    IO.puts("Starting repos..")
    Enum.each(@repos, & &1.start_link(pool_size: 2))

  def priv_dir(app), do: "#{:code.priv_dir(app)}"

  defp run_migrations_for(app) do
    IO.puts("Running migrations for #{app}")
    Ecto.Migrator.run(MyApp.Repo, migrations_path(app), :up, all: true)

  defp migrations_path(app), do: Path.join([priv_dir(app), "repo", "migrations"])

Setting up your Dockerfile

First let's configure a multi-stage Dockerfile. This will generate in stages your final release. First it compiles only your dependencies, which will cache as long as your mix.exs and mix.lock files don't change.

Next up is your front-end assets. If these package.json and yarn.lock don't change they are cached as well. You can remove this step if you don't have front-end assets. Finally, it generates your release and makes a tarball in the /opt directory for you to copy out later.

Save this file as Dockerfile.releaser since this is specific to generating a release and copying it out. If you eventually move to something like Kubernetes, this will be a good start.

FROM elixir:1.9 as builder
RUN mix local.rebar --force && \
    mix local.hex --force
COPY mix.* /app/
RUN mix deps.get --only prod
RUN mix deps.compile

FROM node:10.9 as frontend
COPY assets/package.json assets/yarn.lock /app/
COPY --from=builder /app/deps/phoenix /deps/phoenix
COPY --from=builder /app/deps/phoenix_html /deps/phoenix_html
RUN npm install -g yarn && yarn install
COPY assets /app
RUN yarn run deploy

FROM builder as releaser
COPY --from=frontend /priv/static /app/priv/static
COPY . /app/
RUN mix phx.digest
RUN mix release && \
  cd _build/prod/rel/my_app/ && \
  tar czf /opt/my_app.tar.gz .

Release Generation

Next we have a simple bash script to generate the release. This builds the docker image with the releaser-specific file. Once the generation is complete, the tarball is copied out into a local tmp folder.

Copy this into release.sh

set -e

mkdir -p tmp/

docker build -f Dockerfile.releaser -t my_app:releaser .

DOCKER_UUID=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)

docker run -ti --name my_app_releaser_${DOCKER_UUID} my_app:releaser /bin/true
docker cp my_app_releaser_${DOCKER_UUID}:/opt/my_app.tar.gz tmp/
docker rm my_app_releaser_${DOCKER_UUID}

And run chmod +x release.sh to make it executable.

Setting up the Server

We're going to set up the server with a fairly straightforward set of commands, directly on the server. This guide assumes you're using Ubuntu 18.04 LTS.

Once you're done with these commands, you should make sure the user you signed into can only be accessed by SSH keys. If you signed in via root with a password you should disable that and sign in with deploy going forward.

# Make sure you're up to date
sudo apt update
sudo apt upgrade
# This should already be installed, but to be safe
sudo apt install unattended-upgrades

# Reboot for the upgrades
sudo systemctl reboot

# Install postgres
sudo bash -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ bionic-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -

sudo apt update
sudo apt install postgresql-12

# Create deploy user and database in PostgreSQL
# save the password for later
sudo -u postgres createuser deploy -P
sudo -u postgres createdb my_app -O deploy

# Create a deploy user and grant sudo access
sudo useradd -m -s /bin/bash deploy
sudo bash -c 'echo "deploy ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/deploy'
sudo -u deploy bash -c 'echo -e "export DATABASE_URL=postgresql://deploy:[email protected]/my_app\n$(cat /home/deploy/.bashrc)" > /home/deploy/.bashrc'

# Create folder to untar into
sudo -u deploy mkdir /home/deploy/my_app

# Setup a systemd file for your application
# Edit and copy in the file below
sudo vim /etc/systemd/system/my_app.service
sudo systemctl daemon-reload
sudo systemctl enable my_app

# Copy in your SSH keys to the deploy user
sudo -u deploy mkdir /home/deploy/.ssh/
sudo -u deploy touch /home/deploy/.ssh/authorized_keys
sudo -u deploy chmod 0600 /home/deploy/.ssh/authorized_keys
# add your ssh keys to this file
sudo -u deploy vim /home/deploy/.ssh/authorized_keys

# Setup nginx and certbot
sudo apt install nginx certbot python-certbot-nginx -y
sudo rm /etc/nginx/sites-enabled/default
# Copy in the nginx config below and edit for your domain/app
sudo vim /etc/nginx/sites-enabled/my_app
# Verify the configuration
sudo nginx -t
sudo systemctl reload nginx
# Follow certbot to configure HTTPS, this assumes your DNS is configured
sudo certbot --nginx
# After certbot, you may want to edit /etc/nginx/sites-enabled/myapp to verify and update any config it generated

# Enable the firewall
sudo ufw allow ssh
sudo ufw allow https
sudo ufw allow http
sudo ufw enable

systemd file

The systemd configuration file mentioned in the script above.

Description=Runner for MyApp

Environment=SECRET_KEY_BASE="run `mix phx.gen.secret` to generate one"
Environment=DATABASE_URL="postgresql://deploy:[email protected]/my_app"
ExecStart=/home/deploy/my_app/bin/my_app start


nginx config

The nginx configuration file mentioned in the script above.

upstream my_app {
  server localhost:4000;

server {
  server_name my-app.example.com;

  gzip on;

  client_max_body_size 2M;

  error_page 502 /site-down.html;
  location = /site-down.html {
    root /usr/share/nginx/html;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_pass http://my_app;
  # CSS and Javascript
  location ~* \.(?:css|js)$ {
    expires 1w;
    access_log off;
    add_header Cache-Control "public";
    proxy_pass http://my_app;

  # Images
  location ~* \.(jpe?g|png|gif|ico)$ {
    expires 1w;
    access_log off;
    add_header Cache-Control "public";
    proxy_pass http://my_app;
  listen 80 default;
  listen [::]:80;

Deploying the Release

Once we have a local release and the server prepped, we can finally do the deploy!

Save the following as deploy.sh and update with your applications information. Run chmod +x deploy.sh after saving so it can execute.

set -e


scp tmp/my_app.tar.gz [email protected]${host}:

ssh [email protected]${host} 'sudo systemctl stop my_app'
ssh [email protected]${host} 'tar xzf my_app.tar.gz  -C my_app'
ssh [email protected]${host} './my_app/bin/my_app eval "MyApp.ReleaseTasks.migrate()"'
ssh [email protected]${host} 'sudo systemctl start my_app'

With that in place, you can now perform a deploy! ./deploy.sh to get your application on the remote server. You should be able to see your application running after this succeeds.

A Simple but Solid Foundation

There are a few drawbacks with this approach. Your application will be down when deploying. This is likely an OK thing depending on where you are in your application's lifecycle. If no one is using the application because you're still creating it, will anyone notice the 3-5 seconds of downtime?

This guide is here to get you going with a solid and simple foundation for deploying your Elixir application. Once you get further along with your application and more comfortable with releases and deploying, you can start looking at more complex options. But a single Linode/DigitalOcean instance will go a long way with Elixir, so that day may be far away.

You've successfully subscribed to SmartLogic Blog
Great! Next, complete checkout for full access to SmartLogic Blog
Welcome back! You've successfully signed in.
Unable to sign you in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.