F7.4 · deep dive 2 · secrets & env provisioning
Set by name, never committed
“Configured for prod” splits cleanly in two. One half is non-secret environment —
the public host, the port, the env name, the IPv6 substrate — baked straight into
echo/fly.toml under [env] and committed in the open, because nothing there is
worth hiding. The other half is the two secrets, SECRET_KEY_BASE and
DATABASE_URL, which never appear in fly.toml at all: a deploy provides them by
name with fly secrets set, and lists them by name with fly secrets list.
The coupling is exact — each secret name a deploy provides must equal a name runtime.exs reads,
or the boot raises by design. A missing required value fails loudly rather than booting on an insecure default.
We read echo/fly.toml [env], the three
config/runtime.exs raises, and echo/.env.production as built — the
baked [env] keys, the two secrets provisioned via fly secrets, the
secret-name↔runtime-read coupling, the three raise-on-missing strings, and where the Operator’s local secret
values live (and why they are committed nowhere).
The two halves of “configured for prod”
A production deploy needs a set of environment values, and not all of them are alike. Most are plain configuration that
carries no risk if read: the Portal’s public host, the listen port, the env name, the pool size, the IPv6 networking
substrate. These live in echo/fly.toml under the [env] stanza, are
committed to the repository in the open, and ship inside the image — there is nothing to protect, so there is no
reason to hide them.
The non-secret [env] keys, as built: PHX_HOST = "echo-portal.fly.dev"
(the Portal’s own host, read in runtime.exs under :prod —
distinct from :deep_link_base_url, which stays https://jonnify.fly.dev),
PHX_SERVER = "true", MIX_ENV = "prod", PORT = "4000",
POOL_SIZE = "10", the IPv6/cluster trio ERL_INETRC = "/app/inetrc" /
ECTO_IPV6 = "true" / ERL_AFLAGS = "-proto_dist inet6_tcp", and
TZ = "UTC" / LANG = "en_US.UTF-8". None of these is a secret.
The two secrets sit on the other side of the line. SECRET_KEY_BASE signs and encrypts the
endpoint’s tokens and cookies; DATABASE_URL carries the database credentials
Portal.Repo connects with. Both are sensitive, so neither belongs in a committed file — not
fly.toml, not a config, not a comment. Select a value below to read which store owns it, whether it is
a secret, and how a deploy provisions it.
Where a value lives · committed env vs never-committed secret
Which store a value lives in is the whole question: a non-secret [env] key is committed
in the open, a secret is provisioned by name and committed nowhere.
The two secrets, by name only
Because SECRET_KEY_BASE and DATABASE_URL stay out of every committed file,
a deploy has to hand them to Fly some other way — by name, with fly secrets set. This is
bring-up step 0, run once against the app before the first fly deploy:
fly secrets set SECRET_KEY_BASE=… DATABASE_URL=… -a echo-portal. Fly stores the values in its
own encrypted secret store, injects them into the machine’s environment at boot, and never writes them back to the
repository. To confirm the names are present without revealing any value, fly secrets list -a echo-portal
prints the names (and a digest), not the contents.
Two names keep recurring — the OTP release and the Fly app — and conflating them is the common slip. The OTP
release is portal (you run it as bin/portal); the Fly app is
echo-portal (the -a echo-portal flag every fly secrets
and fly deploy command targets). They are independent identifiers that happen to live in the same
deploy.
So where do the secret values come from when the Operator runs fly secrets set? From a
local file the Operator keeps and the repository never sees: echo/.env.production. It holds
SECRET_KEY_BASE and DATABASE_URL by name, the Operator feeds those values to
fly secrets, and the values are never written into any committed file — not a spec, not a config,
not a comment (F7.4-INV1). The committed artifact carries the secret names and nothing more.
# bring-up step 0 — provide the two secrets BY NAME (values supplied from echo/.env.production) # no secret value is ever written into a committed file — fly stores them encrypted (F7.4-INV1) $ fly secrets set SECRET_KEY_BASE=… DATABASE_URL=… -a echo-portal # confirm the NAMES are present — list shows names + a digest, never a value $ fly secrets list -a echo-portal NAME DIGEST CREATED AT SECRET_KEY_BASE … 2026-06-07T11:33Z DATABASE_URL … 2026-06-07T11:33Z # the non-secret half is the opposite: committed in echo/fly.toml, in the open [env] PHX_HOST = "echo-portal.fly.dev" # the Portal's own host (NOT :deep_link_base_url) PHX_SERVER = "true" MIX_ENV = "prod" PORT = "4000" POOL_SIZE = "10" ECTO_IPV6 = "true" # + ERL_INETRC, ERL_AFLAGS, TZ, LANG
The two secrets travel as names: provisioned with fly secrets set from the
Operator’s local echo/.env.production, confirmed with fly secrets list, and
written into no committed file.
The raise-on-missing contract
Setting a secret by name only matters because runtime.exs reads it by name — and the two
sides have to agree. Each secret name a deploy provides must equal a name the boot config reads, or the boot fails. That
agreement is not enforced by hope: config/runtime.exs contains three explicit raises under
:prod, one per required value, so a missing value fails the boot loudly instead of booting
with an insecure default.
The three raises, with their exact messages. A missing SECRET_KEY_BASE emits
environment variable SECRET_KEY_BASE is missing; a missing PHX_HOST emits
environment variable PHX_HOST is missing; a missing DATABASE_URL emits
environment variable DATABASE_URL is missing. Worth keeping straight:
PHX_HOST is a non-secret [env] key that also raises when absent —
the raise enforces presence, regardless of whether the value is secret. Secrecy decides which store a value lives
in; the raise decides that it must be there at all.
Run the contract below: pick a required prod value and a state. With the value set, its name
matches a runtime.exs read and the boot proceeds; with it missing,
runtime.exs raises the exact string and the boot fails before it can serve a request.
# echo/config/runtime.exs — the three :prod raises (skeleton), the deploy↔boot contract if config_env() == :prod do System.get_env("SECRET_KEY_BASE") || raise "environment variable SECRET_KEY_BASE is missing" System.get_env("PHX_HOST") || raise "environment variable PHX_HOST is missing" # non-secret [env] key, raises anyway System.get_env("DATABASE_URL") || raise "environment variable DATABASE_URL is missing" # feeds Portal.Repo end
The raise-on-missing contract · does this boot proceed or raise?
The raise is the contract: a name the deploy provides must equal a name
runtime.exs reads — when it does not, the boot raises the exact string and fails loudly rather
than serving on an insecure default.
committed env
the non-secret [env] keys — PHX_HOST, PHX_SERVER, PORT, the
IPv6 trio — baked into fly.toml and committed in the open.
never-committed secrets
SECRET_KEY_BASE and DATABASE_URL provisioned by name with fly secrets set from the
Operator’s local echo/.env.production — values in no committed file.
The two halves are now clear: committed [env] keys, never-committed secrets, and a
raise per required value that fails the boot loudly when a name is absent. What turns this static config into a running node
is the deploy itself — how fly deploy rolls a new image, runs the health check, and what fails
when a secret name is wrong. That is the next dive,
F7.4.3 · the deploy lifecycle & failure modes.
References
Provisioning a value by name without committing it, the runtime.exs raise-on-missing-secret
contract, how a boot read reaches (and raises on) an env var, and the [env] section that carries the non-secret
keys.
Sources
- Fly.io — App secrets —
fly secrets set/fly secrets list, provisioning a value by name without committing it. - Phoenix — Deploying with releases —
runtime.exsand the raise-on-missing-secret contract a prod boot enforces. - Elixir — System —
System.get_env/2andSystem.fetch_env!/1, howruntime.exsreads (and raises on) an env var. - Fly.io — fly.toml configuration reference — the
[env]section that carries the non-secret keys.
Related in this course
- F7.4.1 · the distilled fly.toml — the stanzas this dive’s
[env]half lives in. - F7.4.3 · the deploy lifecycle & failure modes — what
fly deploydoes once the secrets and[env]are in place. - F7.4 · fly.toml & the deploy mechanics — the module hub this dive sits in.
- F7.2 · runtime config & the IPv6 network — the
runtime.exsreads these secret names feed. - F7 · Deploy to Fly — the chapter this module belongs to.