Why Fly.io

Most people in the Elixir community will be aware of Fly.io, not only because Kurt Mackey (Fly.io CEO) has been doing the rounds but because of two significant hires; Chris McCord, the creator of Phoenix and Mark Eriksen one of the hosts of Thinking Elixir . However, in case you missed it, this is from the Fly.io website.

Fly is a platform for applications that need to run globally. It runs your code close to users and scales compute in cities where your app is busiest. Write your code, package it into a Docker image, deploy it to Fly’s platform and let that do all the work to keep your app snappy.

Kurt Mackey has made it known that he is a big fan of Elixir and, in particular, Phoenix LiveView, which has translated into investment in people and infrastructure to support the overall goals of Phoenix. Below is a short rundown of how I moved my blog from Digital Ocean to Fly.io and some changes I needed to make to incorporate TailwindCSS.

The Application

My blog is an Elixir application that uses TailwindCSS, NimblePublisher and the latest version of Phoenix and LiveView without a database. The latest version of Phoenix has dispensed with Webpack and moved to esBuild. A great decision, but you no longer need to use NPM unless you want to add specific libraries.

Mike Clark from Pragmatic Studio has provided excellent instructions for adding TailwindCSS to a new Phoenix Project. Another change I made was to remove my custom code and use the NimblePublisher library built and maintained by Dashbit. Following these changes and a bit of maintenance, it was time to deploy it to Fly.io.

Deploying to Fly.io

Fly.io has excellent documentation, and you can find the Elixir deployment instructions here. I used the base Docker file maintained by Fly.io in the hello_elixir GitHub Repo. The instructions are clear and following them allowed me to get a site up and running in no time.

Unfortunately, when I visited the supplied URL, none of the assets (css etc) were being aplied and the LiveView code kept crashing and restarting. Because Fly.io is Docker-based, it didn’t take long to find the errors and see that the problem existed because NPM was not available. I went back to my Docker file and found a section I had passed over the first time.

note: if your project uses a tool like https://purgecss.com/, which customizes asset compilation based on what it finds in your Elixir templates, you will need to move the asset compilation step down so that lib is available. COPY assets assets - Moved down

Because TailwindCSS makes use of PurgeCSS to minify the CSS I knew I needed adjust the Docker file to ensure that /lib is available. I started by getting NPM installed on the build image.

# Original script
    RUN apt-get update -y && apt-get install -y build-essential git \
     && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Modified script
RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*   

Secondly and inline with the instructions I re-ordered the copy statements to ensure that the assets were available before calling RUN mix assets.deploy.

# Compile the release
COPY lib lib

COPY priv priv

COPY assets assets

RUN cd assets && npm install

RUN mix assets.deploy

RUN mix compile

With those changes made I re-deployed the application with all CSS applied and no errors. For reference I have included the full Docker file below.

HTTPS

The last thing I wanted to do was only server https traffic. I followed the instructions on Fly.io for setting up SSL and issuing certificates with LetsEncrypt. Everything worked perfectly except Fly.io still served the http traffic. After a bit of searching through the docs it became apparent that I would need to handle it in the application configuration. In my previous setup on Digital Ocean I used NGINX as a proxy and handled it there but that was no longer an option.

I found this post on Fly.io’s blog and then I jumped onto the excellent Phoenix docs and found it was a simple change to add :force_ssl to the endpoint configuration. So in my prod.exs file I made the following change.

...

  config :ab, ABWeb.Endpoint,
    url: [host: "andrewbarr.io", port: 80],
    cache_static_manifest: "priv/static/cache_manifest.json",
    force_ssl: [rewrite_on: [:x_forwarded_proto]] <------ ADDED

...

With these changes I now have my site running on Fly.io and only serving https. Happy days :)

Docker File


ARG BUILDER_IMAGE="hexpm/elixir:1.12.3-erlang-24.1.4-debian-bullseye-20210902-slim"
ARG RUNNER_IMAGE="debian:bullseye-20210902-slim"

FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# 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 ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

# COPY priv priv - Moved down

# note: if your project uses a tool like https://purgecss.com/,
# which customizes asset compilation based on what it finds in
# your Elixir templates, you will need to move the asset compilation
# step down so that `lib` is available.
# COPY assets assets - Moved down

# For Phoenix 1.6 and later, compile assets using esbuild
# RUN mix assets.deploy - Moved down

# For Phoenix versions earlier than 1.6, compile assets npm
# RUN cd assets && yarn install && yarn run webpack --mode production
# RUN mix phx.digest

# Compile the release
COPY lib lib

COPY priv priv

COPY assets assets

RUN cd assets && npm install

RUN mix assets.deploy

RUN mix compile

# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/

COPY rel rel
RUN mix release

# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/prod/rel ./

USER nobody

# Create a symlink to the application directory by extracting the directory name. This is required
# since the release directory will be named after the application, and we don't know that name.
RUN set -eux; \
  ln -nfs /app/$(basename *)/bin/$(basename *) /app/entry

CMD /app/entry start