Compile Elixir applications into single executable binaries, with Bakeware

Bakeware is a new fantastic tool, built (by Frank Hunleth, Jon Carstens and Connor Rigby) over a weekend for the SpawnFest 2020, which compiles an Elixir, a Scenic or a Phoenix application into single executable binary (yes, like go-lang!). It can be extremely useful to distribute our apps, especially when they are command-line tools or Scenic applications.

For most of the Phoenix app deployments, this tool maybe is not crucial, but I think that sometime it can be really useful to have a Phoenix/Phoenix LiveView app (along with all the needed assets!) in a single, and easily sharable, binary.

Let’s see how to make it work with a Phoenix LiveView application. What we are going to see applies to CLIs and Scenic apps as well.

Bakeware library and Counter LiveView example

At the moment there isn’t an official library on hex.pm, so let’s start by downloading the Bakeware’s code from its GitHub repo, spawnfest/bakeware.

$ git clone https://github.com/spawnfest/bakeware.git
Cloning into 'bakeware'...
...
$ ls bakeware/bakeware
Makefile  README.md assets    lib       mix.exs   mix.lock  src       test

In the bakeware/examples folder, you can also find other useful examples. The library code we need is inside the bakeware/bakeware directory.

To see Bakeware in action, let’s create a new Phoenix project called counter, with LiveView support and without Ecto.

$ mix phx.new counter --live --no-ecto
...
$ cd counter

In the counter Phoenix project’s directory, we create a lib/counter_web/live/counter_live.ex file where we define a CounterWeb.CounterLive live view. You can can simply copy/paste the module’s code below.

#lib/counter_web/live/counter_live.ex
defmodule CounterWeb.CounterLive do
  use CounterWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, counter: 0)}
  end

  @impl true
  def render(assigns) do
    ~L"""
    <h1>Counter: <%= @counter %></h1>
    <button phx-click="dec">-</button>
    <button phx-click="inc">+</button>
    """
  end

  @impl true
  def handle_event("inc", _, socket) do
    {:noreply, update(socket, :counter, & &1 + 1)}
  end

  @impl true
  def handle_event("dec", _, socket) do
    {:noreply, update(socket, :counter, & &1 - 1)}
  end

end

and update the "/" live route in lib/counter_web/router.ex.

#lib/counter_web/router.ex
defmodule CounterWeb.Router do
  use CounterWeb, :router

  ...

  scope "/", CounterWeb do
    pipe_through :browser

    live "/", CounterLive, :index
  end
end

Ok, we now have a simple LiveView counter application that we can compile with Bakeware.

Phoenix LiveView counter example
Phoenix LiveView counter example

To use Bakeware we, just need to change the mix.exs file of our counter Phoenix project.

#mix.exs
defmodule Counter.MixProject do
  def project do
    [
      app: :counter,
      ...
      releases: [
        baked_counter: [
          steps: [:assemble, &Bakeware.assemble/1],
          strip_beams: Mix.env() == :prod,
          overwrite: true
        ]
      ]
    ]
  end

  defp aliases do
    [
      assets: ["cmd npm run deploy --prefix assets"],
      release: ["assets", "phx.digest", "release"],
      setup: ["deps.get", "cmd npm install --prefix assets"]
    ]
  end

  defp deps do
   [
    ...
    {:bakeware, path: "../bakeware/bakeware", runtime: false}
   ]
  end
end

We first add the :bakeware dependency, pointing the :path to the local bakerware subfolder and setting the :runtime option to false.

We then add the :release and :assets aliases, which shorten the list of command we need to type to build a release. For example, the :release alias runs mix assets, mix phx.digest and mix release.

Most importantly, we add a :releases option in the keyword list returned by project/0. The only release we build is called baked_counter, and we set [:assemble, &Bakeware.assemble/1] as a list of :steps to execute when assembling the release. We set two other options: overwrite: true (if there is an existing release version, overwrite it) and :strip_beams which is true only when the environment is prod (controls if BEAM files should have their debug information, documentation chunks, and other non-essential metadata removed).

Remember to add the server: true option to the CounterWeb.Endpoint production config, in config/prod.exs

#config/prod.exs

config :counter, CounterWeb.Endpoint,
  ...
  server: true,
  check_origin: false

When Setting server: true the web server is started when the endpoint supervision tree starts. We can also add check_origin: false, which disables the check of the origin header.

We are ready to build the release. Before running the mix tasks, we need to SECRET_KEY_BASE env variables.

$ mix phx.gen.secret
y0RanKUeQ641fq8Mzh/sa0WwuVoBxwqVrgsXM+aGPrtpYRZwwuyoRRZpomw8ALqJ
$ export SECRET_KEY_BASE="y0RanKUeQ641fq8Mzh/sa0WwuVoBxwqVrgsXM+aGPrtpYRZwwuyoRRZpomw8ALqJ"

And then we run the setup and release mix tasks, setting the MIX_ENV env variable to prod.

$ MIX_ENV=prod mix setup
...
$ MIX_ENV=prod mix release
...
* assembling bakeware baked_counter
Bakeware successfully assembled executable \
at _build/prod/rel/bakeware/baked_counter

The baked_counter executable binary is ready, we find it in the _build/prod/rel/bakeware folder.

Bakeware builds a single executable binary

When we run it we see that our server starts correctly, serving the web app assets (in this case just the javascript, css and Phoenix Framework logo).

$ ./_build/prod/rel/bakeware/baked_counter

bakeware: starting '/Users/alvise/Documents/poeticoding/bakeware/code/counter/./_build/prod/rel/bakeware/baked_counter' (cachedir=/Users/alvise//Library/Caches/Bakeware)...
bakeware: Cache invalid. Extracting...
bakeware: Running /Users/alvise//Library/Caches/Bakeware/6ea5f818ebb68300b8d953d54e082779006802152906cd90dc43caeaefc9a19f/start...
16:49:25.455 [info] Running CounterWeb.Endpoint with cowboy 2.8.0 at :::4000 (http)
16:49:25.456 [info] Access CounterWeb.Endpoint at http://example.com

How does Bakeware work?

The executable binary, built by Bakeware, has the whole release compressed in it. When we run it, it extracts everything in a cache folder, and starts the application.

To optimize the start-time, Bakeware maintains a cache of extracted binaries and assets. In this way, the second time we run it, it’s faster since it doesn’t need to uncompress anything.

The cache directory location is system-specific:

  • on macOS is "~/Library/Caches/Bakeware"
  • on Linux and other Unixes is "~/.cache/bakeware"
  • on Windows is "C:/Users/<USER>/AppData/Local/Bakeware/cache"

In this case, I’m on a mac. If we go in the ~/Library/Caches/Bakeware/6ea5f... folder, we find the extracted release.

Extracted release in cache folder

And under lib/counter-1.0/priv/static we the app static assets.

Share the app with another computer

Let’s try the baked_counter on another mac. If you built your app on Linux, it should work on other similar Linux environments. Windows support is coming (take a look at the end of the article).

I’ve compiled it with my MacBook Pro with Catalina (10.15.7), so it would be nice to see if it works on a slightly older macOS version, like Mojave (10.14). I don’t have another Mac, so I use a remote macmini with macOS 10.14, hosted by MacinCloud, with a pay-as-you-go plan.

Running the app on another computer

And it works!! Is it compatible across many OS versions? Well… it depends by different things. When we build a release this is supposed to run on a target machine with the same operating system with a similar environment, like C runtime and other libraries referenced by the Erlang runtime and any NIFs and ports in the application.

Wrap Up

This is a project started just a month ago, and the team has planned to work on many other things, like for example:

Do you think Bakeware is going to be helpful for you? Maybe for a CLI written in Elixir? Or a Scenic, or Phoenix app with LiveView? Let me know what you think!