7 .env Mistakes That Break Your CI Pipeline
You've pushed a commit, CI starts running, and three minutes later you're staring at a red build with an error message that makes absolutely no sense locally. Nine times out of ten, the culprit is hiding in a .env file — or in the gap between how your local machine handles that file and how your CI runner does.
These bugs are infuriating precisely because they're invisible. Your app works perfectly on your MacBook, works fine in staging, and then detonates in production CI like a landmine that's been sitting there for months. Let's go through the seven most common traps, why each one actually breaks things, and the exact fix for each.
1. Unquoted Values with Spaces (or Special Characters)
This is the one that gets everyone at least once. You have a database URL or a description string that contains a space, a hash, or an equals sign, and you write it in your .env like this:
APP_DESCRIPTION=My Cool App v2.0 (beta)
DB_URL=postgres://user:p@ss#word@host:5432/db
Locally, your dotenv library probably parses this fine. But in CI — especially if the values are being exported as shell variables via export $(cat .env | xargs) — the shell splits on spaces and chokes on the hash sign, interpreting it as the start of a comment. Your DB_URL becomes postgres://user:p@ss and nothing after the # makes it through.
The fix: Always quote values that could contain special characters. Double quotes handle the most cases:
APP_DESCRIPTION="My Cool App v2.0 (beta)"
DB_URL="postgres://user:p@ss#word@host:5432/db"
If you're using a proper dotenv loader (like dotenv in Node.js or python-dotenv), it'll handle both quoted and unquoted forms. The problem is the shell-based loading pattern that shows up constantly in CI scripts. Lint your .env.example file with a tool like dotenv-linter to catch these before they land in a pipeline.
2. CRLF Line Endings
Windows. It's always Windows. If anyone on your team edits .env files on Windows without proper Git line-ending configuration, you'll end up with CRLF (\r\n) endings instead of LF (\n). On a Linux CI runner, that invisible \r character gets appended to every variable value.
So API_KEY=abc123 becomes API_KEY=abc123\r in memory. When your app tries to use that key to authenticate with an external service, the request fails with a cryptic 401. You spend two hours debugging the wrong thing.
The fix: Add a .gitattributes rule:
.env* text eol=lf
This tells Git to normalize line endings for all .env files to LF on checkout, regardless of the OS. You can also strip CRLF from an existing file with: sed -i 's/\r//' .env. In CI, add a quick sanity check: file .env should say "ASCII text" not "ASCII text, with CRLF line terminators."
3. Duplicate Keys (Last-Write Wins — Or Does It?)
You've got a .env file that's been accumulated over two years by five different developers. Somewhere near the top, NODE_ENV=development. Then, 80 lines later, someone added NODE_ENV=test during a debugging session and never removed it.
Different dotenv parsers handle duplicates differently. Some take the first value, some take the last, some throw an error. When you switch CI systems or upgrade a library, the behavior changes silently and your tests start passing or failing for no apparent reason.
The fix: Run this one-liner against your .env files in CI as a preflight check:
awk -F= '!/^#/ && NF>1 {print $1}' .env | sort | uniq -d | grep . && echo "Duplicate keys found!" && exit 1
Better yet, add duplicate detection to your pre-commit hook so duplicates never make it to the repository in the first place. dotenv-linter handles this natively with its --check flag.
4. Committing Secrets (The Quiet Catastrophe)
This one isn't a parsing bug — it's a security disaster that also breaks your pipeline conceptually. Someone commits a real .env file, not the .env.example template. Maybe they forgot to add it to .gitignore. Maybe they're new. Maybe they were moving fast.
GitHub's secret scanning will flag it. Your security team will page you at midnight. And even if you delete the file in the next commit, the secret is still in git history and has to be considered permanently compromised.
The fix is layered:
- Make sure
.envis in.gitignore(and.env.local,.env.production, etc.) - Install gitleaks as a pre-commit hook:
gitleaks protect --staged - Enable GitHub's push protection in your repository settings — it blocks pushes containing known secret patterns before they land
- Rotate any secret that has ever touched a commit, immediately, no exceptions
For CI, use your platform's native secret management (GitHub Actions Secrets, GitLab CI Variables, etc.) and inject them as environment variables at runtime. Never put real secrets in files that travel with the repo.
5. Missing Variables That Exist Only in Your Head
You add a new environment variable locally — say, STRIPE_WEBHOOK_SECRET — you wire it into the code, and everything works. You push. CI breaks because you never added it to your CI secrets store, and you never updated .env.example to document it exists.
The error is usually a cryptic undefined crash rather than a helpful "missing env var" message, which sends you on a red herring chase through the actual code logic.
The fix: Write a startup validation step into your application. In Node.js, something like:
const required = ['DATABASE_URL', 'STRIPE_WEBHOOK_SECRET', 'JWT_SECRET'];
const missing = required.filter(key => !process.env[key]);
if (missing.length) {
console.error(`Missing required env vars: ${missing.join(', ')}`);
process.exit(1);
};
This gives you a clear, actionable error at startup rather than a mysterious failure three layers deep. Also, make it a team rule: every new environment variable gets added to .env.example with a comment in the same PR that introduces the code using it.
6. Interpolation Surprises
Some dotenv loaders support variable interpolation: you can write DATABASE_URL=$DB_HOST/$DB_NAME and they'll expand it automatically. Great feature — until it surprises you in CI where DB_HOST is set at the OS level (maybe from a previous step) to something different than what's in your .env file.
The loaded value silently picks up the shell's version of DB_HOST instead of the one in your file, and your database connection points somewhere unexpected.
The fix: Know whether your dotenv library expands variables and whether it gives priority to existing environment variables. In Node's dotenv, you control this with the override option:
require('dotenv').config({ override: false }); // existing env vars win (default)
require('dotenv').config({ override: true }); // .env file wins
In CI, it's usually correct to let the CI platform's injected secrets win (leave override: false). Document this behavior explicitly in your project README so future developers don't flip it casually and introduce subtle bugs.
7. The Multiline Value Trap
Private keys. RSA private keys in particular. They span multiple lines, they have headers and footers, and getting them into a .env file without breaking something is genuinely annoying.
The naive approach is to paste the whole key with literal newlines. Some parsers handle it, many don't. In CI, when the key is stored as a secret and injected as an environment variable, the newlines often get stripped entirely or replaced with spaces, and your JWT signing or SSL verification fails with an error that tells you nothing useful.
The fix has two parts. First, when storing in CI secrets, base64-encode the key:
base64 -w 0 private_key.pem
Store the base64 string as the secret. Then in your application, decode it at runtime:
const privateKey = Buffer.from(process.env.PRIVATE_KEY_BASE64, 'base64').toString('utf8');
Second, if you're storing multiline values directly in a .env file (only for local dev), use the quoted form with explicit \n escapes and make sure your dotenv library supports it — most modern ones do. Test this explicitly; don't assume.
Putting It Together
Most of these mistakes share a common thread: they work locally by accident and fail in CI by design. The CI environment is stricter, runs as a different user, uses different shell behavior, and doesn't have the ambient context your dev machine has accumulated over years of use.
A simple three-step defense works well for most teams:
- Lint on commit: Run
dotenv-linterandgitleaksas pre-commit hooks. Catch formatting issues and secret leaks before they hit the remote. - Validate at startup: Write a required-variables check that runs before your app does anything else. Make it loud and specific.
- Test your env loading in CI explicitly: Add a step early in your pipeline that prints the resolved (redacted) values of key variables, so you can verify they look right before the tests run.
None of this is glamorous infrastructure work. But the hour you spend setting it up once will save you dozens of hours of confused debugging across the life of a project. And your future self, staring at a failing pipeline at 11pm, will be genuinely grateful.