YouTube Video
By Unknown
Summary
Topics Covered
- Environment variables are an OS primitive
- Shell exports silently override .env files
- Child processes start with copies, not references
- Frontend env vars ship as plain text in the bundle
- Secrets management is a spectrum
Full Transcript
This is a React app referencing an environment variable. And this is the
environment variable. And this is the JavaScript file your users actually download. That's your API key in plain
download. That's your API key in plain text shipped to every browser. Or maybe
you've hit this one. Your app runs fine locally, but the moment it's in Docker or CI, it can't find a variable that's right there in your file. We treat
environment variables as copy paste configuration and don't usually think about what's happening underneath. In
this video, we'll see what environment variables actually are at the OS level, work through how they move between processes, which explains most works on my machine problems, and end with a
buildtime versus runtime split.
Before environment variables were a webdev concept, they were an OS concept.
Every process running on your computer, your browser, your terminal, your node server, has its own block of memory called its environment. It's just a flat
list of key value pairs, strings mapped to strings. No nesting, no types, just
to strings. No nesting, no types, just text. Open a terminal and type print
text. Open a terminal and type print env. You're looking at your shell's
env. You're looking at your shell's environment. There are usually dozens of
environment. There are usually dozens of them, and most were set by the OS long before your code entered the picture.
Like path, when you type node, your shell splits this variable at the colons and searches each directory until it finds a match. That's not a node feature. That's your operating system.
feature. That's your operating system.
Environment variables are an OS primitive. Everything else is built on
primitive. Everything else is built on top of that.
So where do all these values get set?
They come from multiple layers and the layers stack. Layer one, the OS sets
layers stack. Layer one, the OS sets baseline variables like home and user when your machine boots. Layer two, your shell profile files like Zshrc that run
when you open a terminal. This is where we add to our path. Layer three, manual commands, export for the whole session or the inline prefix syntax for a single
command. Layer four is application
command. Layer four is application tooling. This is where files come in.
tooling. This is where files come in.
And a file is just a text file. It does
nothing on its own. Something has to read it. In Node, that's traditionally.
read it. In Node, that's traditionally.
Env--end file flag. Python has Python-N.
file flag. Python has Python-N.
Docker Compose has its own loading. Each
tool has its own rules. Most of these tools, including will not overwrite a variable that already exists. If your
shell profile exports database URL and your NV also sets it, your shell profile wins. The Nend file fills in gaps. It
wins. The Nend file fills in gaps. It
doesn't overwrite. So, this can be one of the most common debugging rabbit holes. You're staring at your N, the
holes. You're staring at your N, the values right there, but your app reads something different. an old export in
something different. an old export in your shell profile is silently taking priority. So don't forget to look there.
priority. So don't forget to look there.
And another thing while we're talking about end files, these often contain secrets, API keys, database credentials, so they should always be in yourit ignore. And if you've ever accidentally
ignore. And if you've ever accidentally committed a secret and then deleted it in the next commit, that doesn't fix it.
Git keeps every snapshot. The key is still in your history. Anyone who clones the repo can find it with a git show. So
the rule is simple. Secrets go in environment variables, never in source code. If it's sensitive, it doesn't get
code. If it's sensitive, it doesn't get committed. Okay. So we know what
committed. Okay. So we know what environment variables are, where they come from, and what goes in them. But
how do they actually get from your shell into your application? That's what
process inheritance explains.
Quick question. If you set a variable in your terminal and then launch your app, can your app see it? Yes. If your app changes that variable, can your terminal
see the change? No. And here's why. When
a process creates a child process, the child gets a copy of the parents environment, not a reference, a copy.
They're independent from that point on.
Let's actually see this. I'll set a variable in my shell. Read it from node.
Works fine. Now I'll change it inside node. Confirmed it changed there. Back
node. Confirmed it changed there. Back
in the shell, still hello. the child's
change didn't propagate back up.
Independent copies. This is exactly what happens with Docker. Your shell has dozens of variables loaded from your profile. But when you run a Docker
profile. But when you run a Docker container, it starts with a clean slate.
It doesn't inherit your shell environment. You have to explicitly pass
environment. You have to explicitly pass in everything it needs with - E flags or-n file. And this is why your app
or-n file. And this is why your app works fine on your machine, but breaks in Docker. The container only knows what
in Docker. The container only knows what you explicitly told it. Same thing in CI. Each run step in GitHub actions gets
CI. Each run step in GitHub actions gets a fresh shell. Variables you export in one step don't carry to the next. You
need GitHub_env for that. Different CI systems,
for that. Different CI systems, different mechanisms, same underlying reason. Environments are copies that
reason. Environments are copies that diverge. Knowing that can save you some
diverge. Knowing that can save you some headache. And that React example from
headache. And that React example from the beginning of the video, why the API key ended up in plain text in the browser. Let's look at buildtime versus
browser. Let's look at buildtime versus runtime. Build time configuration values
runtime. Build time configuration values read when your code is compiled or bundled. They get embedded in the
bundled. They get embedded in the output. Once the build is done, they're
output. Once the build is done, they're frozen. Runtime configuration values
frozen. Runtime configuration values read when your app actually starts running. You can change them between
running. You can change them between deployments without rebuilding. Sounds
like a clear distinction, but it gets confusing. In a React app or any
confusing. In a React app or any front-end app, when you write process.env.react_IR,
process.env.react_IR,
Reactapp API URL. It looks like you're reading an environment variable. You're
not. Watch what actually happens. I'll
build a React app, then search the output files for my variable name. And
there it is. The bundler replaced every reference with a literal string. It's
hardcoded into the JavaScript, which means anyone can see it. Open DevTools
in your browser. Click sources. There's
your value in plain text, shipped to every user. If you put a secret in a
every user. If you put a secret in a React app or next public variable, it's not secret. It's in the bundle. And
not secret. It's in the bundle. And
there's another consequence. If you
build your front end with a staging API URL, you cannot take that same build and deploy it to production. The URL is baked in. You have to rebuild. That
baked in. You have to rebuild. That
means the artifact you tested in staging is not the artifact running in production. You rebuilt. So, it's
production. You rebuilt. So, it's
technically a different artifact.
Frameworks try to make this clear.
Next.js JS uses next public for buildtime client variables. Serverside
code reads real environment variables at runtime. Vit uses vit with
runtime. Vit uses vit with import.meta.env.
import.meta.env.
Each framework has its own prefix, but the rule is the same. Prefixed variables
go to the browser. Everything else stays on the server. Compare that to a backend. When Express reads
backend. When Express reads process.env.database
process.env.database
URL, it reads the actual environment of the running process. Change the
variable. Restart. New value, no rebuild needed. That's true runtime
needed. That's true runtime configuration. Rule of thumb, if a value
configuration. Rule of thumb, if a value can't change after the build, a public API URL per deploy pipeline, a feature flag for tree shaking, build time is
fine. Everything else should be runtime.
fine. Everything else should be runtime.
Secrets should always be runtime. Never
build time, never committed to code.
Same idea in Docker. ARG is build time.
It only exists during the image build.
ENV is runtime baked into the image and available to containers. You can copy an ARG to an ENV, but then the value is in the image layer. And both ARG and ENV
values end up visible in Docker history.
ARG values aren't available inside running containers, but they're recorded in the image metadata. So neither of them is safe for secrets. Pass secrets
at runtime with Docker Run-E or your orchestrator secret management. For
secrets, think of it as a spectrum. At
the bare minimum, usev files in your.git
ignore. Better a secret manager like Vault or AWS Secrets Manager that encrypts at rest, logs access, and can rotate credentials automatically. Best
have your infrastructure inject short-lived credentials at runtime. Your
app never stores a static key. Build
time frozen into your artifact. Runtime
flexible until the moment it's read.
Secrets always runtime, never build time, never committed to code. To bring
it all together, environment variables are an operating system primitive, flat strings on every process, not a framework feature. They come from
framework feature. They come from layers OS shell commands tooling and those layers have a precedence order. Child processes get a copy of the
order. Child processes get a copy of the parents environment. After that, they're
parents environment. After that, they're independent. That's why Docker and CI
independent. That's why Docker and CI behave differently from your local terminal. And buildtime configuration is
terminal. And buildtime configuration is not the same as runtime configuration.
One is frozen in your artifact, the other stays flexible. Secrets should
always be runtime. Never build time, never committed to code. None of this is advanced. It's foundational. If this was
advanced. It's foundational. If this was useful, like and share. Thanks for
watching and see you in the next one.
Loading video analysis...