jonnifyelixir
F6 / F6.08 / releases

F6.08 · part 2 of 3

Releases & config

A release is how an Elixir app ships. mix release assembles your compiled code, every dependency, and the ERTS — the Erlang runtime, the BEAM itself — into one self-contained directory that runs on a server with no Elixir or Erlang installed. It comes with a launcher, bin/portal, that can start the system, open a remote console, or run a one-off command. The subtlety that trips people is configuration timing. config/config.exs and config/prod.exs are read at compile time and baked into the artifact — good for fixed settings, wrong for secrets, which would then live in your build. config/runtime.exs is different: it is evaluated every time the release boots, on the server, so it can read environment variables for the database URL, the secret key base, and the host. That single distinction is most of release config. The last wrinkle is migrations: a release has no mix, so mix ecto.migrate is unavailable. Instead you add a tiny Portal.Release module and invoke it with bin/portal eval, which boots only enough of the app to run the migrator.

Four parts: what a release is, the compile-time-versus-runtime config split, reading the environment in runtime.exs, and running migrations without mix.

Release, runtime, command

Three ideas. A release is the self-contained artifact. runtime.exs is the config evaluated at boot. A release command runs code on the server without mix. Select one.

Releases · select one

mix release app + deps + the BEAM — one artifact, no Elixir to run the artifact runtime.exs reads env vars at boot — System.fetch_env!("...") boot config a release command bin/portal eval — run code without mix (e.g. migrate) on the server

idea: mix release

is: a self-contained artifact

Because the BEAM ships inside the release, the production image needs no language runtime — only the operating system. That is what makes Elixir releases small to deploy and easy to roll back: one directory, one launcher.

Compile time vs boot time

Config comes in two flavours separated by when it runs. config.exs and prod.exs are evaluated while you build and are frozen into the artifact — fine for things that never change, unsafe for secrets. runtime.exs is evaluated when the release boots on the server, so it sees the real environment and can pull secrets from it. Same syntax, different moment.

build (baked in) → artifact → boot (reads env)

BUILD TIME config.exs + prod.exs compiled in · fixed settings only the release artifact, shipped BOOT TIME (server) runtime.exs evaluated reads env for secrets & URLs DATABASE_URL · SECRET_KEY_BASE · PHX_HOST supplied by the environment, never in the build secrets enter at boot, so the same artifact runs in any environment

The rule of thumb is simple: if a value differs between environments or must stay secret, it belongs in runtime.exs read from the environment; if it is the same everywhere and safe to commit, compile-time config is fine.

Reading the environment

runtime.exs guards production settings with config_env() and reads each secret with System.fetch_env!/1, which raises if the variable is missing — so a misconfigured deploy fails loudly at boot rather than limping along. The Repo gets its URL, the Endpoint its host and secret key base.

# config/runtime.exs — evaluated when the release boots, on the server
import Config

if config_env() == :prod do
  database_url = System.fetch_env!("DATABASE_URL")
  config :portal, Portal.Repo, url: database_url, pool_size: 10

  config :portal, PortalWeb.Endpoint,
    url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
    secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
end

Failing hard on a missing variable is the point of fetch_env! over get_env: you would rather the release refuse to start than serve traffic without a database URL or with a blank secret.

Migrating without mix

Migrations are a build-tool task, but a release has no mix. The standard answer is a small Portal.Release module that loads the app and runs the Ecto migrator for each repo. You invoke it with bin/portal eval, which starts only enough of the system to execute the function — the same migrations from F6.03, run on the server.

defmodule Portal.Release do
  @app :portal

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, fn repo -> Ecto.Migrator.run(repo, :up, all: true) end)
    end
  end

  defp repos, do: Application.fetch_env!(@app, :ecto_repos)
  defp load_app, do: Application.load(@app)
end

# on the server, with no mix:  bin/portal eval "Portal.Release.migrate()"

fixed config

Compiled into the artifact at build time from prod.exs.

secrets & migrations

runtime.exs reads env at boot; a release command migrates.

Next: deploying to production — the release is built and configured; now build, migrate, and boot it, clustered across nodes.