Phoenix Secrets: runtime.exs, Fly.io, and LLM Keys
The compile-time trap in config.exs, why System.fetch_env! should crash your boot, and how to actually wire Fly secrets and LLM keys through a Phoenix release.
TL;DR:
config.exscompiles into your release;runtime.exsruns when the release boots. Put a secret in the wrong one and it either bakes an old value into your Docker image forever, or vanishes the moment you deploy. The fix is mechanical once you see it: read every secret inconfig/runtime.exswithSystem.fetch_env!/1, notSystem.get_env/2with a fallback — a missing LLM key should crash your boot at 2pm during a deploy, not surface as a silent 500 to a customer at 2am.fly secrets setrestarts your machines to apply it;--stagelets you land a secret before the code that reads it ships. Dotenvy gives dev the same.env-file convenience without touchingSystem.get_envat all, which is exactly where the existing guides on this stop short. And an Anthropic or OpenAI key is not “just another env var” — it’s a bearer credential against your billing account, and it deserves narrower handling than your database URL.
The trap: config.exs bakes, runtime.exs boots
Every Phoenix app since 1.6 ships two configuration entry points, and
the difference between them is the single most common way a new engineer
breaks a deploy. config/config.exs
(and its environment siblings,
dev.exs/prod.exs) runs at compile
time, when mix release builds the artifact.
Whatever value System.get_env("ANTHROPIC_API_KEY") returns
on the build machine, at build time gets frozen into the
release’s compiled config. If you build on CI where that variable
happens to be unset, you get nil baked in — permanently,
until the next rebuild — no matter what you later set in the deployed
environment.
config/runtime.exs is different by design: Phoenix executes it
after compilation, right before the application starts, on every
boot, in every environment including a running release on a production
machine. That’s the only place System.get_env reads the
actual environment the process is running in. This is why the
generated runtime.exs already handles
SECRET_KEY_BASE this way — it’s not a style choice, it’s
the only correct place to read a value that has to differ between your
laptop and the fly machine that’s actually serving traffic.
The trap is subtle because it doesn’t fail loudly. You put an LLM key
in config.exs because that’s the file you had open,
mix phx.server works fine locally (your shell has the env
var, and dev doesn’t build a release), CI passes, the deploy succeeds —
and then the first production request that calls the model gets a 401
from a key that was nil at build time. Nothing in that
chain complains until a real user hits it.
Fail at boot,
not at 2am: fetch_env! over get_env
Once a secret is correctly in runtime.exs, there’s a
second decision that matters just as much: what happens when it’s
missing.
# config/runtime.exs — wrong instinct: silently proceed
anthropic_key = System.get_env("ANTHROPIC_API_KEY", "")This “works.” The app boots. Every request to your agent endpoint returns a vague provider error, or worse, silently degrades to a stub response, and you find out from a support ticket instead of a deploy log.
# config/runtime.exs — crash the boot instead
anthropic_key = System.fetch_env!("ANTHROPIC_API_KEY")
config :sublime_coding, :anthropic, api_key: anthropic_keySystem.fetch_env!/1 raises ArgumentError
immediately if the variable isn’t set, which means the release refuses
to start at all. On Fly, a release that crashes on boot fails the
deploy’s health check and the rollout stops — you see it in
fly logs and fly status before a single
customer request lands on the bad machine, because Fly won’t route traffic to a
machine that never comes up healthy. That is the failure mode you
want: a loud, deploy-time crash you catch in your terminal, not a quiet,
request-time failure someone else catches for you.
This generalizes past LLM keys. Any secret without which the app is
meaningfully broken — the database URL,
SECRET_KEY_BASE, an LLM key that gates a core feature —
should use fetch_env!. Reserve
System.get_env/2 with a real default for genuinely optional
config: an analytics ID, a feature flag, a port number with a sane
fallback. This repo’s own runtime.exs draws exactly that
line — PORT gets
System.get_env("PORT", "4000") because a default port is
harmless, while SECRET_KEY_BASE gets a raise
with a message telling you how to generate one, because a missing
signing key is not something you want silently defaulted in
production.
A missing secret should be a deploy-time crash you catch in your terminal, not a request-time failure your customer catches for you.
fly secrets set,
staged secrets, and what actually restarts
Fly.io’s secrets are the thing runtime.exs reads at boot
— they’re injected as process environment variables on the machine,
nothing more exotic. The workflow:
fly secrets set ANTHROPIC_API_KEY=sk-ant-... \
OPENAI_API_KEY=sk-proj-...This does two things:
it writes the secret into the app’s encrypted vault, then immediately
restarts every machine in the app to pick it up — which also resets each
machine’s ephemeral filesystem, worth knowing if you keep any scratch
state outside a volume. That restart-on-set behavior is why you don’t
want to fly secrets set casually against a busy production
app mid-incident; it’s a real deploy event, not a config tweak.
Two situations where the default behavior is wrong for you:
Setting a secret before the code that reads it
ships. If you’re adding a new ANTHROPIC_API_KEY
read in runtime.exs in the same release that needs the
secret, setting it normally works fine — the restart-and-the-deploy can
happen in either order. But if you want to stage the secret
ahead of a deploy without triggering an extra restart cycle
right now, --stage sets the value in the vault without
touching running machines:
fly secrets set ANTHROPIC_API_KEY=sk-... --stage. The staged value applies the
next time a machine starts or updates — your next
fly deploy picks it up naturally, or you can force it early
with fly secrets deploy, which redeploys the current
release with the staged secrets without rebuilding the image.
Rotating a leaked or expiring key. Same command, no
--stage — you want the restart immediately:
fly secrets set ANTHROPIC_API_KEY=sk-new-.... The old key
stops being read the moment the new machines come up. This is also the
moment fetch_env! earns its keep on the other end: if you
typo the new key name, the next deploy crashes at boot instead of
quietly running every LLM call against a key that no longer exists.
fly secrets list shows names, digests, and set-times —
never plaintext values, by design. If you need to confirm what
a secret’s value is, you kept it somewhere else (a password manager, not
Fly) or you’re regenerating it, not reading it back.
Dev:
Dotenvy gives you .env files without touching
System.get_env
Everything above covers the release. Locally, you don’t want to
export five LLM keys into your shell every session, and you
don’t want a .env file that quietly leaks into
System.get_env and gets read the same way production
secrets are — because then a stray git add .env is a
production-shaped incident. This is precisely the gap the existing
Phoenix-secrets writeups leave: they cover the compile/runtime split,
then wave at “use a .env file in dev” without saying how to
keep that convenience from bleeding into your runtime read path.
Dotenvy
closes it cleanly because it’s read-only by design — it never calls
System.put_env/2, so .env-sourced values are
invisible to plain System.get_env or
System.fetch_env! calls elsewhere in your app. You read
them only through Dotenvy’s own env!/2, which forces you to
declare the type and the requirement inline:
# config/runtime.exs
import Config
import Dotenvy
env_dir = System.get_env("RELEASE_ROOT") || Path.expand(".")
source!([
Path.absname(".env", env_dir),
Path.absname(".#{config_env()}.env", env_dir),
System.get_env()
])
config :sublime_coding, :anthropic,
api_key: env!("ANTHROPIC_API_KEY", :string!)source!/1 layers files in order — a shared
.env, then an env-specific .dev.env or
.test.env overriding it, then the real
System.get_env() last so production’s actual environment
always wins over anything a stray .env file might contain.
:string! (the bang type, not the bang function) enforces
non-empty, same intent as fetch_env! — missing or blank
crashes the boot instead of handing you nil. In prod
there’s typically no .env file on disk at all, so
source!/1 layers in nothing but
System.get_env(), and every value still flows through the
exact same env! call — one code path for both environments,
which is the actual point of doing it this way instead of branching on
config_env() yourself.
.env and .dev.env still belong in
.gitignore, same as they always did — Dotenvy changes how
the value gets read, not whether the file belongs in git.
LLM keys specifically: narrower rules than a database URL
Everything so far applies to any secret. LLM provider keys earn extra caution for two reasons that don’t apply to, say, your Postgres password.
They’re bearer credentials against a metered bill, not a
fixed resource. A leaked database URL gets you rows; a leaked
Anthropic or OpenAI key gets an attacker your billing account. When a
corporate-tier key leaks, threat
actors run high-volume inference workloads against it and drain the
account before anyone notices — there’s no row count to bound the
damage, only your provider’s rate limit and however long detection
takes. GitGuardian’s most recent scan of public GitHub found AI-service
secrets growing 81% year over year, faster than almost every other
credential category they track, which tells you the scanning bots are
tuned for exactly this key shape now — sk-ant-
and sk-proj- prefixes are as fingerprintable as AWS’s
AKIA.
Never in config.exs, full stop — including for
local convenience. The compile-time trap above is annoying when
it’s SECRET_KEY_BASE; it’s actively dangerous for an LLM
key, because a key baked into a compiled release artifact ships with
every copy of that artifact — any Docker image, any build cache, any
layer you push to a registry. Read it in runtime.exs — a
direct fetch_env! in prod or env! behind
Dotenvy in dev.
Scope the key to what actually needs it, and rotate on a schedule you keep. Most providers let you name and scope API keys per project or per use case — use that, so a leaked key from your staging agent isn’t also your production billing key. And rotate proactively rather than reactively; the providers’ own detection catches a lot of leaks fast, OpenAI’s partner program with GitHub revokes recognized key patterns within minutes of detection, but that safety net only covers keys that match a known pattern in a place GitHub scans — it does nothing for a key sitting in your Fly secrets vault that an ex-contractor still has a local copy of.
This post is about the boring, load-bearing layer: getting the key into the process safely. It doesn’t cover what happens after your application holds the key — whether the model itself ever sees it, whether a tool call can leak it back out through a crafted prompt. I’ve written that half separately: secrets management for AI agents covers the broker pattern that keeps a credential scoped to the tool that needs it and out of the model’s context entirely. Different layer, same root discipline — this post gets the key from Fly into your BEAM process correctly; that one governs what your code does with it once it’s there.
The full picture, in order
Put together, the sequence for adding a new LLM key to a Phoenix app on Fly looks like this:
- Add
System.fetch_env!("ANTHROPIC_API_KEY")(orenv!via Dotenvy in dev) toconfig/runtime.exs— neverconfig.exs. fly secrets set ANTHROPIC_API_KEY=sk-ant-...before or alongside the deploy that reads it; use--stageif you want to land the secret ahead of the code without an extra restart.- Deploy. If the key is missing or mistyped, the release fails to boot
and the rollout stops — check
fly logsbefore assuming it’s a code problem. - Scope the key narrowly at the provider, note it in whatever runbook tracks what’s set where, and put its rotation on the same calendar as your other production credentials.
None of this is exotic Elixir. It’s the same discipline that governs
SECRET_KEY_BASE in every mix phx.new app,
applied on purpose to the credential that’s most likely to be the one
you added under deadline pressure without thinking about where it goes.
If you’re standing up an AI feature on Phoenix and want a second pair of
eyes on the security posture around it — this layer and the ones above
it, like the AI-native
security stack or the 50-line agent loop
this key usually feeds — that’s a conversation
worth having before the first deploy, not after the first leak.