Notes to self

Making 12factor Elixir/Phoenix releases

Elixir had a bad reputation for its deployment story due to the complex tooling and compile-time configuration preference. That is history now as we can easily make Elixir v1.11 releases with the runtime configuration to adhere to the 12factor style of deployment.

If you don’t know what 12factor is, it’s a document made at Heroku with recommendations how to design your applications. Although the purpose was most likely about stirring people into making applications that would run smoothly on the Heroku platform, it’s a quite sensible set of recommendations.

I don’t think you have to adhere to 12factor at all costs, but some points make sense. This post is namely about section III., which recommends storing configuration in an environment. Something a bit problematic in Elixir before, but something I always wanted.

Sections on dependencies and logs are also relevant, while sections on stateless processes and concurrency might not apply to us as Beam has its own lightweight stateful processes. However, you can decide to keep Elixir nodes stateless and use something like Redis.

But back to the runtime configuration. Suppose we design our application to depend only on the runtime environment variables. In that case, we can make true release artifacts that can be used across organizations and workloads (well, almost, as we don’t have a streamlined way of cross-compiling Elixir). Now compare it to the compile-time configuration, which bakes in your database credentials.

Let’s imagine a minimal Phoenix application. It will come with some config files in the config/ directory. Namely a common config/config.exs and config/prod.exs for configuring production release. Those files provide the build-time application configuration to use values it has available when building the release.

Your Elixir application might combine the compile and runtime configuration in the end, but the following is about providing all your ENVs at runtime.

The first question I had was, “what’s the minimal amount of work to make a Phoenix release?”

It seems we can just call to Mix with the right MIX_ENV environment:

$ MIX_ENV=prod mix release new_phoenix

The new_phoenix is just a name. You can omit it, and your project name will be used instead.

We’ll get the following output:

Release created at _build/prod/rel/new_phoenix!

    # To start your system
    _build/prod/rel/new_phoenix/bin/new_phoenix start

Once the release is running:

    # To connect to it remotely
    _build/prod/rel/new_phoenix/bin/new_phoenix remote

    # To stop it gracefully (you may also send SIGINT/SIGTERM)
    _build/prod/rel/new_phoenix/bin/new_phoenix stop

To list all commands:

    _build/prod/rel/new_phoenix/bin/new_phoenix

This was easy! Let’s start it:

$ _build/prod/rel/new_phoenix/bin/new_phoenix start

It seems to be working, but how do we access the application? The port 4000 is not working as usual.

Chances are that you are missing server: true in your endpoint configuration:

# config/prod.exs
config :new_phoenix, NewPhoenixWeb.Endpoint,
  http: [:inet6, port: System.get_env("PORT") || 4000],
  ...
  server: true

Reruning mix release will now start the server at port 4000. It’s now a fully self-contained release (usable on the same architecture) and it will also log to STDOUT by default. That’s two things done from the 12factor checklist.

What if we want to change the PORT at runtime? Maybe some servers will already use port 4000 for something else. But we don’t know that beforehand to provide it while building the release!

We have to move this configuration to runtime. Thanks to the recently added config/runtime.exs support (replacing deprecated config/releases.exs), we can do it without a sweat:

# config/runtime.exs
import Config

if config_env() == :prod do
  config :new_phoenix, NewPhoenixWeb.Endpoint,
    http: [:inet6, port: System.fetch_env!("PORT")],
    ...
    server: true
end

The only difference at this point is the name of the file. I also changed System.get_env/ to System.fetch_env!/1, which will fail to start the application if the port is not provided. That’s something you might want to do when it comes to things like databases.

Note that config/runtime.exs is evaluated in all environments, therefore we provide the configuration only for the :prod environment.

Rerunning works as expected:

$ MIX_ENV=prod mix release new_phoenix
$ PORT=4000 build/prod/rel/new_phoenix/bin/new_phoenix start

While I added just one configurable variable, you can move all your configuration to runtime this way. And you can forget about the config/prod.secret.exs file. I think this is simpler in the long run and can match how you deploy your Ruby, Python, and other stacks workloads.

Don’t forget most project will need SECRET_KEY_BASE which you can always generate with:

$ mix phx.gen.secret

Again, before we needed it already for the release, but today we can make it completely a run-time dependency.

And then, full-stack applications will have to handle the front-end concerns as well. If you have cache_static_manifest as part of your configuration, run mix phx.digest first to build the static files in priv/static for the release.

For a standard Phoenix 1.5 application with Webpack:

$ mix deps.get --only prod
$ MIX_ENV=prod mix compile
$ npm install --prefix ./assets
$ npm run deploy --prefix ./assets
$ MIX_ENV=prod mix phx.digest
$ MIX_ENV=prod mix release new_phoenix
$ PORT=4000 build/prod/rel/new_phoenix/bin/new_phoenix start

For a standard Phoenix 1.6 application with esbuild:

$ mix deps.get --only prod
$ MIX_ENV=prod mix compile
$ MIX_ENV=prod mix assets.deploy
$ MIX_ENV=prod mix release new_phoenix
$ PORT=4000 build/prod/rel/new_phoenix/bin/new_phoenix start

The assets.deploy task runs esbuild and phx.digest by default:

# cat mix.exs
...
  defp aliases do
    [
      ...
      "assets.deploy": ["esbuild default --minify", "phx.digest"]
    ]
  end
...

If you want to use this release on a production server, copy it over with something like scp and run it from there.

You can run it in the daemon mode, or ideally with a system process manager like systemd. Here are the list of subcommands for the release executable:

The known commands are:

    start          Starts the system
    start_iex      Starts the system with IEx attached
    daemon         Starts the system as a daemon
    daemon_iex     Starts the system as a daemon with IEx attached
    eval "EXPR"    Executes the given expression on a new, non-booted system
    rpc "EXPR"     Executes the given expression remotely on the running system
    remote         Connects to the running system via a remote shell
    restart        Restarts the running system via a remote command
    stop           Stops the running system via a remote command
    pid            Prints the OS PID of the running system via a remote command
    version        Prints the release name and version to be booted

I will look into proper systemd configuration for the release in one of the upcoming post.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS