Module 4: Secrets and Environment Management
5. Env Files and .env in CI
Overview
Many Python applications use .env files to load configuration. Locally, you create a .env with your OPENAI_API_KEY, your app reads it with python-dotenv, and it works. But in CI you don't have a .env file — the secrets come from GitHub Secrets, not from a file on disk.
The problem arises when your application is designed to read from .env (using load_dotenv()), but in CI those variables come from the workflow's env. How do you make both worlds compatible?
This capsule teaches you the correct pattern: generating .env files dynamically from secrets in CI, keeping .env in .gitignore so it never gets committed, and creating a .env.example that documents which variables your app needs without exposing real values.
What you're going to learn:
- Why
.envmust not be in the repository - How to generate
.envdynamically in a workflow - The
.env.examplepattern for documentation - How
python-dotenvworks and how it interacts with CI variables - Verifying that
.gitignoreprotects your.envfiles
The problem: local .env vs CI
The typical local flow
Local development:
1. You create .env with your API keys
2. Your app uses load_dotenv() to read them
3. Everything works
The .env file (local):
OPENAI_API_KEY=sk-proj-abc123...
APP_ENV=development
MODEL=gpt-4o-mini
LOG_LEVEL=debug
# src/config.py — typical code that uses .env
import os
from dotenv import load_dotenv
load_dotenv()
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
APP_ENV = os.environ.get("APP_ENV", "development")
MODEL = os.environ.get("MODEL", "gpt-4o-mini")
The problem in CI
CI (GitHub Actions):
1. There's no .env file on the runner
2. The variables come from the workflow's secrets and env
3. load_dotenv() finds nothing → it uses the defaults
4. If OPENAI_API_KEY isn't in the step's env → it fails
There are two solutions:
Solution A: load_dotenv() does nothing if it doesn't find a .env, and the workflow's environment variables take precedence. If your code uses os.environ.get() after load_dotenv(), it works in both contexts.
Solution B: You generate a .env dynamically in CI from the secrets. This is useful when your app expects a specific .env file (like some frameworks that require it).
The fundamental rule: .env NEVER in the repo
.gitignore
# .gitignore
# Environment files - NEVER commit these
.env
.env.local
.env.staging
.env.production
.env.*.local
# Except the example file
!.env.example
Why it matters
If you commit .env:
→ The file stays in Git's history FOREVER
→ Even if you delete it afterward, the previous commits still have it
→ Anyone with access to the repo (or a fork) can see your keys
→ Automated bots scan commits looking for API keys
→ To clean it up you need to rewrite Git's history (complex and risky)
Verifying that .env is in .gitignore
# Verify that .env is protected
git check-ignore .env
# Expected output: .env
# If it returns nothing, .env is NOT in .gitignore — add it immediately
echo ".env" >> .gitignore
The .env.example pattern
Create a .env.example file that documents which variables your app needs. This file DOES get committed to the repo — it contains the variable names but NOT the real values.
# .env.example — It gets committed to the repo
# Copy this file as .env and fill in the values
# OpenAI API Key (required)
# Get yours at: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-your-key-here
# Application environment
APP_ENV=development
# AI Model configuration
MODEL=gpt-4o-mini
MAX_TOKENS=500
TEMPERATURE=0.7
# Cost control
COST_THRESHOLD=5.00
DAILY_REQUEST_ESTIMATE=10000
# Logging
LOG_LEVEL=debug
A script for validating .env against .env.example
#!/usr/bin/env python3
"""
Validate that every variable in .env.example exists as an
environment variable (either from .env or from the system).
"""
import os
import sys
from pathlib import Path
def parse_env_file(path: str) -> list[str]:
"""Extract variable names from a .env file."""
variables = []
env_path = Path(path)
if not env_path.exists():
return variables
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
var_name = line.split("=", 1)[0].strip()
variables.append(var_name)
return variables
def validate_env(example_path: str = ".env.example") -> bool:
"""Verify that every variable in the example exists in the environment."""
required_vars = parse_env_file(example_path)
if not required_vars:
print(f"No variables found in {example_path}")
return True
print(f"Validating {len(required_vars)} variables from {example_path}...")
missing = []
for var in required_vars:
value = os.environ.get(var, "")
if value:
print(f" [OK] {var} is set ({len(value)} chars)")
else:
print(f" [MISSING] {var} is not set")
missing.append(var)
if missing:
print(f"\n{len(missing)} variables missing: {', '.join(missing)}")
return False
print(f"\nAll {len(required_vars)} variables are set.")
return True
if __name__ == "__main__":
example = sys.argv[1] if len(sys.argv) > 1 else ".env.example"
success = validate_env(example)
sys.exit(0 if success else 1)
Expected output (locally, with .env configured)
Validating 8 variables from .env.example...
[OK] OPENAI_API_KEY is set (51 chars)
[OK] APP_ENV is set (11 chars)
[OK] MODEL is set (11 chars)
[OK] MAX_TOKENS is set (3 chars)
[OK] TEMPERATURE is set (3 chars)
[OK] COST_THRESHOLD is set (4 chars)
[OK] DAILY_REQUEST_ESTIMATE is set (5 chars)
[OK] LOG_LEVEL is set (5 chars)
All 8 variables are set.
Expected output (CI, without .env but with secrets)
Validating 8 variables from .env.example...
[OK] OPENAI_API_KEY is set (51 chars)
[OK] APP_ENV is set (7 chars)
[MISSING] MODEL is not set
[MISSING] MAX_TOKENS is not set
[MISSING] TEMPERATURE is not set
[MISSING] COST_THRESHOLD is not set
[MISSING] DAILY_REQUEST_ESTIMATE is not set
[OK] LOG_LEVEL is set (7 chars)
5 variables missing: MODEL, MAX_TOKENS, TEMPERATURE, COST_THRESHOLD, DAILY_REQUEST_ESTIMATE
The non-sensitive configuration variables are missing because you didn't define them in the workflow. The solution: add them as env in the workflow, or generate a .env dynamically.
Generating .env dynamically in CI
The basic pattern
- name: Create .env file
run: |
cat > .env << 'ENVFILE'
APP_ENV=staging
MODEL=gpt-4o-mini
MAX_TOKENS=500
TEMPERATURE=0.7
COST_THRESHOLD=5.00
DAILY_REQUEST_ESTIMATE=10000
LOG_LEVEL=warning
ENVFILE
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Verify .env
run: |
echo "Variables in .env:"
grep -c "=" .env
echo "OPENAI_API_KEY is $(grep -c 'OPENAI_API_KEY' .env) line(s)"
# Output:
# Variables in .env: 8
# OPENAI_API_KEY is 1 line(s)
The heredoc writes the non-sensitive variables in plain text. Then, echo adds the secret as an environment variable. The secret's value expands from $OPENAI_API_KEY (which comes from the step's env).
The template pattern
- name: Generate .env from template
run: |
cp .env.example .env
sed -i "s|OPENAI_API_KEY=.*|OPENAI_API_KEY=$OPENAI_API_KEY|" .env
sed -i "s|APP_ENV=.*|APP_ENV=staging|" .env
sed -i "s|LOG_LEVEL=.*|LOG_LEVEL=warning|" .env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
This pattern copies .env.example as a base and replaces the placeholder values with the real ones.
The Python pattern
#!/usr/bin/env python3
"""Generate .env from environment variables and defaults."""
import os
import sys
from pathlib import Path
DEFAULTS = {
"APP_ENV": "development",
"MODEL": "gpt-4o-mini",
"MAX_TOKENS": "500",
"TEMPERATURE": "0.7",
"COST_THRESHOLD": "5.00",
"DAILY_REQUEST_ESTIMATE": "10000",
"LOG_LEVEL": "debug",
}
SECRETS = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
]
def generate_env_file(output_path: str = ".env") -> None:
lines = []
lines.append("# Generated by scripts/generate_env.py")
lines.append("# DO NOT COMMIT THIS FILE\n")
lines.append("# Configuration")
for key, default in DEFAULTS.items():
value = os.environ.get(key, default)
lines.append(f"{key}={value}")
lines.append("\n# Secrets")
for key in SECRETS:
value = os.environ.get(key, "")
if value:
lines.append(f"{key}={value}")
else:
lines.append(f"# {key}= (not set)")
with open(output_path, "w") as f:
f.write("\n".join(lines) + "\n")
print(f"Generated {output_path} with {len(DEFAULTS) + len(SECRETS)} variables")
if __name__ == "__main__":
output = sys.argv[1] if len(sys.argv) > 1 else ".env"
generate_env_file(output)
In the workflow
- name: Generate .env
run: python scripts/generate_env.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: staging
LOG_LEVEL: warning
The script's expected output
Generated .env with 9 variables
The contents of the generated .env:
# Generated by scripts/generate_env.py
# DO NOT COMMIT THIS FILE
# Configuration
APP_ENV=staging
MODEL=gpt-4o-mini
MAX_TOKENS=500
TEMPERATURE=0.7
COST_THRESHOLD=5.00
DAILY_REQUEST_ESTIMATE=10000
LOG_LEVEL=warning
# Secrets
OPENAI_API_KEY=sk-proj-abc123...
# ANTHROPIC_API_KEY= (not set)
python-dotenv: how precedence works
The default behavior
from dotenv import load_dotenv
import os
load_dotenv() # Reads .env if it exists
# What happens if OPENAI_API_KEY exists both in .env and in the system?
key = os.environ.get("OPENAI_API_KEY")
.env has OPENAI_API_KEY | The system has OPENAI_API_KEY | load_dotenv() → os.environ |
|---|---|---|
| Yes (sk-env...) | No | sk-env... |
| No | Yes (sk-sys...) | sk-sys... |
| Yes (sk-env...) | Yes (sk-sys...) | sk-sys... (the system wins) |
By default, load_dotenv() does NOT overwrite variables that already exist in the environment. This is exactly what you need: in CI, the secrets get passed as system environment variables, and load_dotenv() doesn't overwrite them.
Forcing an override (NOT recommended in CI)
load_dotenv(override=True)
With override=True, the .env overwrites the system's variables. This is dangerous in CI because a .env with the wrong values could overwrite the workflow's correct secrets.
The recommended pattern
# src/config.py
import os
from pathlib import Path
from dotenv import load_dotenv
env_path = Path(".env")
if env_path.exists():
load_dotenv(env_path)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
APP_ENV = os.environ.get("APP_ENV", "development")
MODEL = os.environ.get("MODEL", "gpt-4o-mini")
if not OPENAI_API_KEY:
raise ValueError(
"OPENAI_API_KEY is not set. "
"Set it in .env (local) or as a GitHub Secret (CI)."
)
This pattern works in both contexts:
- Local: It reads from
.env - CI: It reads from the workflow's environment variables (
.envdoesn't exist andload_dotenvdoes nothing)
Security: never upload .env as an artifact
# ❌ DANGER: the .env contains secrets
- name: Upload debug info
uses: actions/upload-artifact@v4
with:
name: debug
path: |
.env
logs/
# ✅ CORRECT: exclude .env
- name: Upload debug info
uses: actions/upload-artifact@v4
with:
name: debug
path: |
logs/
results/
Verifying that .env isn't in the artifacts
- name: Safety check before artifact upload
run: |
if [ -f .env ]; then
echo "WARNING: .env file exists. Removing before artifact upload."
rm .env
fi
Cleaning up .env after using it
- name: Generate .env
run: python scripts/generate_env.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run application
run: python src/main.py
- name: Cleanup .env
if: always()
run: rm -f .env
The if: always() guarantees that the cleanup runs even if the previous step fails.
Troubleshooting
"load_dotenv() doesn't load the variables in CI"
Cause: There's no .env file on the CI runner. load_dotenv() does nothing if the file doesn't exist.
Solution: Verify that the variables are available as the workflow's env, or generate .env dynamically:
- name: Generate .env for CI
run: |
echo "OPENAI_API_KEY=$OPENAI_API_KEY" > .env
echo "APP_ENV=staging" >> .env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
"My app fails with 'OPENAI_API_KEY not set' in CI but works locally"
Cause: Locally you read from .env. In CI, the variable is neither in .env (it doesn't exist) nor in the step's env.
Solution: Make sure the step that runs the app has the secret declared:
- name: Run app
run: python src/main.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
"I accidentally committed my .env"
Cause: .env wasn't in .gitignore before you created it.
The immediate solution:
# 1. Remove .env from tracking (but not from disk)
git rm --cached .env
# 2. Add it to .gitignore
echo ".env" >> .gitignore
# 3. Commit
git add .gitignore
git commit -m "Remove .env from tracking, add to .gitignore"
# 4. IMPORTANT: rotate every key that was in .env
# The previous commit still has .env in the history
# Rotating the keys is the only safe way to protect yourself
To clean the history completely you need tools like git filter-branch or BFG Repo-Cleaner — but the most important thing is to rotate the keys immediately.
".env.example gets overwritten when I copy it to .env"
Cause: Confusion between the two files.
Solution: Document it clearly in the README:
## Setup
1. Copy the example env file:
```bash
cp .env.example .env
-
Edit
.envand fill in your API keys -
Never commit
.env— it's in .gitignore
---
## Exercises
### Exercise 1: A complete .env setup
Create `.env.example`, add `.env` to `.gitignore`, and write a script that generates `.env` from `.env.example` with default values.
<details>
<summary>See solution</summary>
```bash
# .env.example
OPENAI_API_KEY=sk-your-key-here
APP_ENV=development
MODEL=gpt-4o-mini
MAX_TOKENS=500
LOG_LEVEL=debug
# Add it to .gitignore
.env
.env.local
.env.*.local
#!/usr/bin/env python3
"""Generate .env from .env.example with default values."""
import sys
from pathlib import Path
def setup_env(
example_path: str = ".env.example",
output_path: str = ".env",
) -> None:
example = Path(example_path)
output = Path(output_path)
if output.exists():
print(f"{output_path} already exists. Skipping.")
return
if not example.exists():
print(f"{example_path} not found.")
sys.exit(1)
content = example.read_text()
output.write_text(content)
print(f"Created {output_path} from {example_path}")
print("Edit .env and fill in your API keys before running the app.")
if __name__ == "__main__":
setup_env()
Expected output:
Created .env from .env.example
Edit .env and fill in your API keys before running the app.
Exercise 2: A workflow that generates .env and runs the app
Create a workflow that generates .env dynamically, runs the app, and cleans up .env afterward.
See solution
# .github/workflows/env-file-demo.yml
name: Env File Demo
on: workflow_dispatch
jobs:
run-with-env:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Generate .env
run: |
cat > .env << ENVFILE
APP_ENV=staging
MODEL=gpt-4o-mini
MAX_TOKENS=500
TEMPERATURE=0.7
COST_THRESHOLD=5.00
DAILY_REQUEST_ESTIMATE=10000
LOG_LEVEL=warning
ENVFILE
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
echo ".env generated with $(grep -c '=' .env) variables"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Validate .env
run: python scripts/validate_env.py .env.example
- name: Run application
run: python scripts/evaluate_prompts.py
- name: Cleanup .env
if: always()
run: |
rm -f .env
echo ".env cleaned up"
Expected output:
# Step: Generate .env
.env generated with 8 variables
# Step: Validate .env
Validating 8 variables from .env.example...
[OK] OPENAI_API_KEY is set (51 chars)
[OK] APP_ENV is set (7 chars)
...
All 8 variables are set.
# Step: Cleanup .env
.env cleaned up
Exercise 3: A pre-commit hook that prevents committing .env
Write a pre-commit hook script that blocks the commit if any .env file (except .env.example) is staged.
See solution
#!/bin/bash
# .git/hooks/pre-commit
# Prevents accidentally committing .env files
STAGED_ENV_FILES=$(git diff --cached --name-only | grep -E '\.env($|\.local$|\.staging$|\.production$)' | grep -v '.env.example')
if [ -n "$STAGED_ENV_FILES" ]; then
echo "ERROR: Attempting to commit .env files:"
echo "$STAGED_ENV_FILES"
echo ""
echo "Remove them from staging with:"
echo " git reset HEAD <file>"
echo ""
echo "If you intentionally want to commit (NOT recommended):"
echo " git commit --no-verify"
exit 1
fi
exit 0
To install it:
cp scripts/pre-commit-hook.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Expected output (if you try to commit .env):
ERROR: Attempting to commit .env files:
.env
Remove them from staging with:
git reset HEAD .env
Exercise 4: Generate .env for multiple environments
Write a script that generates .env.staging and .env.production with different configurations, reading secrets from environment variables.
See solution
#!/usr/bin/env python3
"""Generate .env files for multiple environments."""
import os
import sys
ENVIRONMENTS = {
"staging": {
"APP_ENV": "staging",
"MODEL": "gpt-4o-mini",
"MAX_TOKENS": "500",
"TEMPERATURE": "0.7",
"COST_THRESHOLD": "10.00",
"LOG_LEVEL": "debug",
},
"production": {
"APP_ENV": "production",
"MODEL": "gpt-4o-mini",
"MAX_TOKENS": "500",
"TEMPERATURE": "0.3",
"COST_THRESHOLD": "500.00",
"LOG_LEVEL": "warning",
},
}
SECRET_VARS = ["OPENAI_API_KEY"]
def generate_env_for(env_name: str) -> str:
if env_name not in ENVIRONMENTS:
print(f"Unknown environment: {env_name}")
sys.exit(1)
config = ENVIRONMENTS[env_name]
output_path = f".env.{env_name}"
lines = [f"# Environment: {env_name}", ""]
for key, value in config.items():
lines.append(f"{key}={value}")
lines.append("")
for secret_name in SECRET_VARS:
env_specific = f"{secret_name}_{env_name.upper()}"
value = os.environ.get(env_specific, os.environ.get(secret_name, ""))
if value:
lines.append(f"{secret_name}={value}")
else:
lines.append(f"# {secret_name}= (not set)")
with open(output_path, "w") as f:
f.write("\n".join(lines) + "\n")
print(f"Generated {output_path}")
return output_path
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else "staging"
generate_env_for(target)
Expected output:
python scripts/generate_multi_env.py staging
# Generated .env.staging
python scripts/generate_multi_env.py production
# Generated .env.production
Summary
- ✅
.envNEVER in the repo: always in.gitignore, no exceptions - ✅
.env.exampleDOES go in the repo: it documents which variables the app needs without exposing values - ✅ Generating
.envin CI: use a heredoc or scripts to create.envdynamically from secrets - ✅
load_dotenv()doesn't overwrite: by default, the system's variables take priority over.env - ✅ Clean up
.envafter using it:rm -f .envwithif: always()in the workflow - ✅ Never upload
.envas an artifact: secret masking only works in logs, not in files - ✅ Programmatic validation: scripts that verify every required variable is present
- ✅ A pre-commit hook: prevents accidentally committing
.envfiles - ✅ If you commit
.envby mistake: rotate every key immediately, then clean the history
Additional resources
- python-dotenv Documentation — Official python-dotenv documentation
- The Twelve-Factor App — Config — Configuration principles in modern applications
- GitHub — Ignoring Files — .gitignore documentation
- BFG Repo-Cleaner — A tool for cleaning secrets from Git's history
- git-secrets — AWS's pre-commit hook for detecting secrets
- pre-commit Framework — A framework for managing pre-commit hooks