jonnifyelixir
F7 · Deploy to Fly / F7.4 · fly.toml & the deploy mechanics / F7.4.2 · secrets & env provisioning

F7.4 · deep dive 2 · secrets & env provisioning

F7.4.2

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

COMMITTED · fly.toml [env] in the repo, in the image PHX_HOST · PHX_SERVER · PORT MIX_ENV · POOL_SIZE · ECTO_IPV6 ERL_INETRC · ERL_AFLAGS · TZ · LANG NEVER COMMITTED · fly secrets set by name, listed by name SECRET_KEY_BASE DATABASE_URL fly secrets set · fly secrets list

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?

deploy provides NAME (fly secrets set / fly.toml [env]) SET runtime.exs reads System.get_env("NAME") under :prod MATCH boot gate: value present? PASS boot proceeds, or raises and fails loudly PROCEEDS

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

Related in this course