Back to Blog
Guide6 min read2026-07-13

Environment Variables vs Secrets: What Actually Matters

Not every env var is a secret, and not every secret belongs in an env var. Where the line is, where leaks actually happen, and rules that hold up.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Every configuration value in your app sits somewhere on a spectrum from "public and boring" to "leaking this ends your week." The mistake most teams make isn't picking the wrong tool — it's treating everything on that spectrum the same way. LOG_LEVEL=debug and STRIPE_SECRET_KEY=sk_live_... both end up in the same .env file, handled with the same (lack of) care, and the second one eventually escapes.

Here's the distinction that matters, where leaks actually happen in practice, and a set of rules that hold up under real incidents.

The actual difference

An environment variable is a delivery mechanism: a way to get configuration into a process without baking it into code. This is [twelve-factor config](https://12factor.net/config), and it's still the right default — config varies between deploys, code doesn't, so config lives in the environment.

A secret is a property of the *value*, not the mechanism: anything that grants access or capability. API keys, database passwords, signing keys, OAuth client secrets, webhook signing tokens. The test is simple: if this value appeared in a public GitHub repo, would you have to rotate it? If yes, it's a secret. If no — port numbers, feature flags, log levels, public API base URLs — it's plain config.

The confusion exists because secrets are *usually delivered* as environment variables. That's fine. The problem starts when the "it's just an env var" mindset gets applied to values that are actually credentials.

Where secrets actually leak

I've never seen a secret stolen by someone cracking encryption. Every real leak I've seen went through one of these doors:

Committed .env files. Still the number one. Someone creates .env locally, forgets the .gitignore entry, pushes. Critical detail: once a secret has touched a commit, *rewriting history does not un-leak it*. Forks, local clones, CI caches, and scrapers watching the GitHub event stream all saw it. The only fix is rotation. Treat any committed secret as burned, immediately, no exceptions.

Docker image layers. This Dockerfile leaks a token to anyone who can pull the image:

# BAD — the token is stored in the image forever
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm ci

docker history and layer inspection recover build args and ENV values trivially. If a build step needs a credential, use [BuildKit secret mounts](https://docs.docker.com/build/building/secrets/), which expose the value only during that one instruction and never write it to a layer:

# GOOD — the secret exists only for the duration of this RUN
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

Frontend build prefixes. VITE_*, NEXT_PUBLIC_*, REACT_APP_* — these prefixes mean "inline this value into the JavaScript bundle shipped to every browser." They exist precisely so you can't leak a secret by accident, and people defeat them constantly by renaming API_SECRET to VITE_API_SECRET to "make the build work." If a value is in a frontend bundle, it is public. Full stop. Anything secret must stay server-side, behind an endpoint you control.

Logs and error trackers. The app boots, helpfully logs its full config, and now the database password sits in your log pipeline with different (usually weaker) access controls than the secret store had. Same story with error trackers that capture local variables or request environments. Log the *names* of config you loaded, never the values, and turn on your error tracker's scrubbing rules.

CI logs. A debugging env | sort in a CI step prints every secret the job can see into a build log that half the company can read. Most CI systems mask registered secrets in output, but masking is best-effort — it won't catch a base64-encoded or interpolated variant.

Inheritance. Environment variables pass to every child process. That shell command your app runs, the third-party CLI, the crash handler — they all see the full environment. This is the strongest argument for keeping the environment minimal: every process gets your whole env, whether it needs it or not.

Rules that hold up

1. .gitignore the .env, commit a .env.example. The example file documents every variable with placeholder values, so onboarding doesn't require archaeology:

# .env.example — safe to commit
DATABASE_URL=postgres://user:pass@localhost:5432/dev
STRIPE_SECRET_KEY=sk_test_replace_me
LOG_LEVEL=info

Then add a scanner — [gitleaks](https://github.com/gitleaks/gitleaks) or trufflehog — as a pre-commit hook and in CI. Cheap insurance against the number-one leak.

2. Inject at runtime, not build time. Secrets should enter the process when the container starts, from the platform or a secret manager — never be baked into the artifact. A correctly built image contains zero secrets and can be pushed to any registry without thought.

3. One value per environment. Your staging database password should not open production. Your dev Stripe key should be a test-mode key. When environments share secrets, a compromise anywhere is a compromise everywhere, and you can never rotate cleanly because you can't tell who's using what.

4. Scope to least privilege. A CI job that deploys the frontend doesn't need the database password. Most leaks are made worse not by the initial exposure but by how much the exposed credential could do. Narrow tokens, short expiries where the provider supports them.

5. Have a rotation story before you need it. The question isn't whether you'll ever rotate — it's whether rotating takes five minutes or a war room. If changing the database password requires a coordinated multi-service deploy and a prayer, fix that now, calmly, instead of during an incident.

6. Fail loud on missing config. Validate required variables at boot and refuse to start if one is missing. A crash at startup is a deploy problem; a undefined credential at request time is a 3 a.m. problem. (See the [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) for the longer version of all of this.)

Do you need a secret manager?

Vault, AWS Secrets Manager, Doppler and friends earn their complexity when you have many services, many humans, audit requirements, or dynamic short-lived credentials. They are the right call at that scale.

For most application teams, the honest answer is that your platform's environment configuration — set through a dashboard, scoped per app and per environment, kept out of git — *is* the secret manager, and it's the right level of ceremony. The failure mode isn't "we used platform env vars instead of Vault." It's ".env in the repo" and "key in the bundle," and those are process failures, not tooling failures.

On PandaStack, this is the model: environment variables are configured per app in the dashboard and delivered to the container at runtime, so nothing sensitive lives in your repository or your image. The best example is the one you never handle at all — attach a managed PostgreSQL, MySQL, MongoDB, or Redis instance to your app and DATABASE_URL is injected automatically. A credential that never passes through a human's clipboard can't be pasted into Slack, committed to git, or left in a shell history.

The one-paragraph version

Environment variables are how config travels; secrets are the subset of config that grants access. Deliver both through the environment at runtime, keep both out of git and out of images, and treat the secret subset with three extra habits: separate values per environment, least-privilege scope, and a rotation path you've actually tested. Everything else is commentary.

If you'd rather have the platform handle the wiring — including database credentials you never touch — you can try it at https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Guide

Browse all Guide articles →

See also