jonnifyelixir
F7 · Deploy to Fly / F7.2 · runtime config / F7.2.1 · runtime vs compile

F7.2 · deep dive 1 · the runtime-vs-compile split

F7.2.1

The runtime-vs-compile split

A release decides its configuration at two moments, and the line between them is the whole lesson. config/runtime.exs is evaluated once, at boot, in the running container — so the secret key, the public host, the listen port, and the network bind are read from the environment of the deploy, not the environment of the build. config/prod.exs is evaluated at compile time, when mix release assembles the image — so the values it sets are frozen into the artifact. A value that is secret or varies per deploy belongs at boot; a value fixed at build belongs at compile. And one rule sits above both: no secret lives in compile-time config, so the image that ships carries no baked-in key.

We read echo/config/runtime.exs and echo/config/prod.exs as built — the if config_env() != :test boot guard, the SECRET_KEY_BASE/PHX_HOST/PORT/bind_ip reads, the PHX_SERVER server? gate, and what stays compile-time on the other side.

Two moments, one release

A Mix release runs config/runtime.exs once when the node starts, inside the container that will serve traffic. Everything that file reads — System.get_env(...), the chosen bind_ip, the assembled endpoint config — is resolved against the live deploy. By contrast, config/prod.exs is read while the image is being built, so its values are baked into the artifact and cannot change between one boot and the next. The same configuration system spans both moments; which file a value lives in decides when it is decided.

The whole boot block in runtime.exs is wrapped in if config_env() != :test do. Under the test environment the file returns before that block, so the suite neither binds a port nor demands a real secret — the runtime reads belong to a real deploy, not to a unit run. One detail inside the guard matters: config_env/0 is a macro and cannot be called from inside a guard expression, so it is bound to a plain variable first — env = config_env() — and every later case branches on env, not on a fresh macro call.

Use the inspector below to select a configuration value and read which layer owns it, what it reads at boot or bakes at build, and how it differs between prod and dev.

Config inspector · which layer owns each value

BOOT · config/runtime.exs read once at startup SECRET_KEY_BASE · PHX_HOST PORT · bind_ip · PHX_SERVER + DATABASE_URL BUILD · config/prod.exs baked at compile url host/port:443/scheme:https cache_static_manifest event_store adapter pick

Which file a value lives in is not a style choice: it decides whether the value is read from the running deploy or frozen into the image — boot for the secret and the per-deploy values, build for the fixed shape.

What boot reads

Inside the guard, runtime.exs reads four values from the environment. SECRET_KEY_BASE goes through a case {System.get_env("SECRET_KEY_BASE"), env} that raises on {nil, :prod} — with mix phx.gen.secret guidance — falls back to a fixed 64-byte string (String.duplicate("0", 64)) outside :prod so a local mix phx.server needs no env set, and uses the supplied value otherwise. No insecure default ever leaks to prod, because the only fallback is gated behind “not prod”.

PORT is String.to_integer(System.get_env("PORT", "4000")) — defaulted to 4000, and the fly.toml [env] block sets PORT = "4000". bind_ip is {0, 0, 0, 0} under :prod, so the Fly edge proxy can reach the endpoint on every interface, and {127, 0, 0, 1} under dev, so a local boot stays on loopback and is not exposed. PHX_HOST is read only under :prod into config :portal_web, PortalWeb.Endpoint, url: [host: host]; a missing value raises, joining the SECRET_KEY_BASE and DATABASE_URL raises.

A point worth keeping straight: PHX_HOST is the Portal’s own public host — echo-portal.fly.dev — not :deep_link_base_url, the strangler-fig fallback origin that stays https://jonnify.fly.dev. Two hosts, two keys: one names where the Portal serves, the other where unmigrated links still point.

# echo/config/runtime.exs — the boot block (skeleton), read once at startup
if config_env() != :test do
  env = config_env()                              # macro bound to a var: it cannot be called from a guard

  secret_key_base =
    case {System.get_env("SECRET_KEY_BASE"), env} do
      {nil, :prod} -> raise "SECRET_KEY_BASE missing; run mix phx.gen.secret"
      {nil, _}     -> String.duplicate("0", 64)   # dev/non-prod fallback, never prod
      {value, _}    -> value
    end

  port    = String.to_integer(System.get_env("PORT", "4000"))
  bind_ip = if env == :prod, do: {0, 0, 0, 0}, else: {127, 0, 0, 1}

  config :portal_web, PortalWeb.Endpoint,
    http: [ip: bind_ip, port: port],
    secret_key_base: secret_key_base,
    server: server?

  if env == :prod do
    host = System.get_env("PHX_HOST") || raise "PHX_HOST missing"
    config :portal_web, PortalWeb.Endpoint, url: [host: host]
  end
end

Each boot value carries its own prod behaviour: the secret and the host raise when prod and absent, the port defaults, and the bind opens to all interfaces under prod and stays on loopback under dev — all read from the deploy, none frozen into the image.

What stays at build, and the gate that must not

On the other side of the split, config/prod.exs bakes the values that never vary per deploy. The endpoint carries a fixed url: [host: "echo-portal.fly.dev", port: 443, scheme: "https"] shape and cache_static_manifest: "priv/static/cache_manifest.json". The port: 443, scheme: "https" shape is build-time because it never changes between deploys; the host is supplied at boot by PHX_HOST and merges over this compile-time placeholder under :prod. prod.exs also picks the event-store adapter — config :portal, :event_store, Portal.EventStore.Postgres — flipping the config.exs Portal.EventStore.InMemory default. The adapter is a build decision: which store the release ships with is fixed when the image is assembled.

The server? gate is the value that looks compile-time but must not be. server? is true only when PHX_SERVER in ~w(true 1) under :prod, else true in dev, and it feeds config :portal_web, PortalWeb.Endpoint, http: [ip: bind_ip, port: port], server: server?. It is read at boot for a precise reason: a release runs maintenance tasks with bin/portal eval (a migration, say) with PHX_SERVER unset, and that eval must not bind a port. If the gate were a compile-time System.get_env("PHX_SERVER"), the build host’s env would be frozen into the image, and the eval could come up with a listener it should never start.

The simulator below runs the gate as runtime.exs would: choose MIX_ENV and whether PHX_SERVER is set, and read whether the boot block runs, what server? evaluates to, whether the HTTP listener binds a port, and what bind_ip resolves to.

Boot-gate simulator · does this boot bind a port?

if config_env() != :test do … (the boot block) RUNS server? = PHX_SERVER in ~w(true 1) under :prod, else true true HTTP listener (server: server?) binds a port BINDS bind_ip = {0,0,0,0} (prod) / {127,0,0,1} (dev) {0,0,0,0}

The gate is read at boot, not baked at build, so the same image both serves traffic (prod, PHX_SERVER set) and runs a migration without a listener (prod, PHX_SERVER unset) — and the test environment returns before the block entirely.

decided at build

the HTTPS:443 url shape, the digest manifest path, and the Portal.EventStore.Postgres adapter pick — fixed when mix release assembles the image.

decided at boot

the secret, the host, the port, the bind, and the server? gate — read from the deploy’s environment each time the release starts.

The split is now clear: secret and per-deploy values at boot, fixed shape at build, and no secret in the image. But one boot read still has to reach across a network the BEAM cannot see by default — DATABASE_URL resolves a host on Fly’s IPv6-only private network. That is the next dive, F7.2.2 · Fly’s IPv6-only private network; then F7.2.3 separates the two faults that crash with the same error.

References

The boot-time config a release evaluates, the config_env/0 split that decides runtime versus compile, and where a Mix release reads runtime.exs.

Sources

Related in this course