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:

  1. Why .env must not be in the repository
  2. How to generate .env dynamically in a workflow
  3. The .env.example pattern for documentation
  4. How python-dotenv works and how it interacts with CI variables
  5. Verifying that .gitignore protects your .env files

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_KEYThe system has OPENAI_API_KEYload_dotenv()os.environ
Yes (sk-env...)Nosk-env...
NoYes (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 (.env doesn't exist and load_dotenv does 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
  1. Edit .env and fill in your API keys

  2. 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

  • .env NEVER in the repo: always in .gitignore, no exceptions
  • .env.example DOES go in the repo: it documents which variables the app needs without exposing values
  • Generating .env in CI: use a heredoc or scripts to create .env dynamically from secrets
  • load_dotenv() doesn't overwrite: by default, the system's variables take priority over .env
  • Clean up .env after using it: rm -f .env with if: always() in the workflow
  • Never upload .env as 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 .env files
  • If you commit .env by mistake: rotate every key immediately, then clean the history

Additional resources

  1. python-dotenv Documentation — Official python-dotenv documentation
  2. The Twelve-Factor App — Config — Configuration principles in modern applications
  3. GitHub — Ignoring Files — .gitignore documentation
  4. BFG Repo-Cleaner — A tool for cleaning secrets from Git's history
  5. git-secrets — AWS's pre-commit hook for detecting secrets
  6. pre-commit Framework — A framework for managing pre-commit hooks