Single File Elixir Scripts

Man fitting an entire script into his head as he runs.
Image by Annie Ruygt

This article’s about running single file Elixir scripts. We even show a Phoenix LiveView Example! Fly.io is a great place to run your Phoenix applications. Check out how to get started!

Elixir has powerful built in scripting functionality, allowing us to write Elixir to a file—say my_script.exs— and execute it directly by elixir my_script.exs.

The vast majority of production Elixir projects will be directly compiled via mix with all available optimizations and performance enhancements enabled. But let’s explore what we can accomplish when we go on script and throw out compilation!

Mix.install/2

The first command to know is Mix.install/2. If you are familiar with Livebook this will be a review, but this command enables installation of any hex package. Let’s jump in:

Mix.install([ 
  :req, 
  {:jason, "~> 1.0"} 
])

Req.get!("https://api.github.com/repos/elixir-lang/elixir").body["description"]
|> dbg()

Here we install the latest version of the wonderful req HTTP client and version 1 for the perfectly named JSON library jason. Once installed, you can immediately use them. Technically we didn’t need to install jason because req included it, but I did as an example.

Application.put_env/4

The second function we will need is Application.put_env/4. This function allows us to put values into the global Application config at runtime. Here is the base environment configuration we need if we want to configure a Phoenix Endpoint:

Application.put_env(:sample, SamplePhoenix.Endpoint,
    http: [ip: {127, 0, 0, 1}, port: 5001],
    server: true,
    live_view: [signing_salt: "aaaaaaaa"],
    secret_key_base: String.duplicate("a", 64)
)

This isn’t the only way to configure something. We could have included an option to Mix.install like so:

Mix.install([ 
      :bandit,
      :phoenix, 
      {:jason, "~> 1.0"} 
    ],
    config: [
        sample: [
            SamplePhoenix.Endpoint: [
                http: [ip: {127, 0, 0, 1}, port: 5001],
                server: true,
                live_view: [signing_salt: "aaaaaaaa"],
                secret_key_base: String.duplicate("a", 64)
            ]
        ]
    ]
)

Now what?

With those two functions we have the basic foundation to do anything Elixir can do but in a single, portable file!

We can do…

System administration

retirement = Path.join([System.user_home!(), "retirement"])
File.mkrp!(retirement)

# Get rid of those old .ex files who needs em!
Path.wildcard("**/*.ex")
|> Enum.filter(fn f -> 
      {{year, _, _,}, _} = File.stat!(f).mtime 
      year < 2023
   end)
|> Enum.each(fn compiled_file -> 
    File.mv!(compiled_file, retirement) 
    # we only need .exs files now
end)

Data processing

Mix.install([ 
  :req, 
  :nimble_csv
])
# Req will parse CSVs for us!
Req.get!("https://api.covidtracking.com/v1/us/daily.csv").body
|> Enum.reduce(0, fn row, count -> 
    death_increase = String.to_integer(Enum.at(row, 19))
    count + death_increase
end)
|> IO.puts()

Report Phoenix LiveView Bugs

Let’s say you’ve discovered a bug in LiveView and want to report it. You can increase the odds of it getting fixed quickly by providing a bare-bones example. You could mix phx.new a project and push it up to GitHub, or you could make a single file example and put it in a gist! In fact, Phoenix core contributor Gary Rennie does this so often that I affectionately call these files Garyfiles.

Application.put_env(:sample, SamplePhoenix.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7.0-rc.2", override: true},
  {:phoenix_live_view, "~> 0.18.2"}
])

defmodule SamplePhoenix.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule SamplePhoenix.SampleLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    {:oops, assign(socket, :count, 0)}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="https://cdn.jsdelivr.net/npm/phoenix@1.7.0-rc.2/priv/static/phoenix.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@0.18.2/priv/static/phoenix_live_view.min.js"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>

    """
  end

  def render(assigns) do
    ~H"""

    <button phx-click="inc">+</button>
    <button phx-click="dec">-</button>
    """
  end

  def handle_event("inc", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count + 1)}
  end

  def handle_event("dec", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count - 1)}
  end
end

defmodule Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", SamplePhoenix do
    pipe_through(:browser)

    live("/", SampleLive, :index)
  end
end

defmodule SamplePhoenix.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)
  plug(Router)
end

{:ok, _} = Supervisor.start_link([SamplePhoenix.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

Turns out the bug wasn’t in Phoenix at all and was an oopsie on my part. Can you spot it?

This one is slightly more involved and is based on the wojtekmach/mix_install_examples project. With this file you have a fully functional Phoenix LiveView application in a single file running on port 5001!

And you can see all of the stuff you need to make Phoenix Work, and frankly it’s not that much. When people say we need a “lightweight web framework” ask them what’s unnecessary in this file!

One word of warning, if you plan on putting this up on a small Fly.io machine you will need to use Bandit instead of Cowboy. Building the deps for Cowboy will use a ton of memory to build when using Mix.install.

Report Fly.io issues

Here at Fly.io we try to be super responsive on the questions on our community forum. Let’s say we have an issue with using mnesia and fly volumes, like some users recently posted. If we wanted to post an isolated bug report, we could set up a minimal project to help really get the attention of the support team.

First, we’d want a Dockerfile that can run Elixir scripts

# syntax = docker/dockerfile:1
FROM "hexpm/elixir:1.14.2-erlang-25.2-debian-bullseye-20221004-slim"

# install dependencies
RUN apt-get update -y && apt-get install -y build-essential git 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 variables we might want
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
ENV ECTO_IPV6 true
ENV ERL_AFLAGS "-proto_dist inet6_tcp"

WORKDIR "/app"

# Copy our files over
COPY bug.exs /app

# install hex + rebar if you plan on using Mix.install
RUN mix local.hex --force && \
    mix local.rebar --force

CMD elixir /app/bug.exs

Finally add our bug.exs

vol_dir = System.get_env("VOL_DIR") || "/data"

# Setup mnesiua
Application.put_env(:mnesia, :dir, to_charlist(vol_dir))
:ok = Application.start(:mnesia)

# Check that mnesia is working
dbg(:mnesia.change_table_copy_type(:schema, node(), :disc_copies))

# Maybe try writing a file to see whatsup
path = Path.join([vol_dir, "hello.txt"])
File.write!(path, "Hello from elixir!")
IO.puts(File.read!(path))

Process.sleep(:infinity) # Keep it running so fly knows its okay

And our fly.toml

app = "APP NAME"

[mounts]
source = "data"
destination = "/data"

Now we can fly create APP_NAME, fly volumes create data, fly deploy and then check the logs fly logs to see what failed.

In this case, I couldn’t reproduce the error they were seeing. But it is helpful to have some code that’s isolated to only the problem you are having. We could also see starting up a Phoenix server this way and deploying a weekend tiny app. I wouldn’t recommend it, but you could!

In Conclusion

If you take nothing else away from this post, I hope you click around Wojtek Mach’s FANTASTIC mix_install_examples repository for Elixir script inspiration. You can do just about anything from Machine Learning to low level Systems Programming, all from a single file and the Elixir runtime.

And finally, please don’t be afraid to use them as a development tools. If you encounter a nasty bug in a library or your code, it can really help to isolate it to JUST the failing code and build out a simple repeatable test case like this.

Or maybe instead of asking ChatGPT to write you a shell script, write it in Elixir, so a human can read it.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!