F7.2 · deep dive 1 · the runtime-vs-compile split
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
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?
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
- Phoenix — Deploying with releases —
runtime.exsas the boot-time config, and thePHX_SERVERgate that starts the endpoint. - Elixir — Config —
config_env/0and the line between runtime config and compile-time config. - Mix — mix release — a release evaluates
runtime.exsat boot, after the image is built.
Related in this course
- F7.2 · the module hub — the runtime-config module this dive opens.
- F7.2.2 · Fly’s IPv6-only private network — the trio of settings a v6-only boot must agree on, and the set-versus-consume trap.
- F7.2.3 · .internal vs .flycast & the discriminator — the two private-name schemes and the test that isolates the two
:nxdomainfaults. - F6.08 · Auth, deployment & going live — where the config split originates.