n4nAI

How environment variables protect your LLM API keys

A practical guide to securing LLM API keys with environment variables, covering local development, CI/CD, container deployments, and common pitfalls.

n4n Team4 min read908 words

Audio narration

Coming soon — every post will get a voice note here.

Environment variables API keys remain the baseline defense for keeping secrets out of source control, yet teams still leak credentials through debug logs, Docker layers, and misconfigured CI pipelines. This guide walks through a layered approach that works from laptop to production, with concrete patterns you can adopt today.

Why environment variables beat the alternatives

Hardcoding keys in source files is the obvious anti-pattern — git history never forgets. Config files checked into version control share the same problem. Secrets managers (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) solve the problem at scale but add operational overhead that slows down early-stage development. Environment variables sit in the sweet spot: universally supported, zero dependencies, and compatible with every deployment target from a developer’s shell to Kubernetes.

The protection model is straightforward. The application reads process.env.OPENAI_API_KEY (Node) or os.getenv("OPENAI_API_KEY") (Python) at runtime. The value never touches disk in the repository. Your shell, CI system, or orchestrator injects it at startup. If an attacker gains read access to your codebase, they find references to the variable name — not the secret itself.

Local development: keep secrets off the filesystem

Use a .env file — but never commit it

Create a .env file in your project root:

# .env
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

Add .env to .gitignore immediately:

# .gitignore
.env
.env.local
.env.*.local

Load it in your application entry point. In Node with dotenv:

// index.js — first line before any other imports
require('dotenv').config();

// Now process.env.OPENAI_API_KEY is available
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

In Python with python-dotenv:

# main.py
from dotenv import load_dotenv
load_dotenv()  # reads .env into os.environ

import os
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

Pitfall: .env files in Docker build context

A common mistake: copying .env into the image via COPY . . in the Dockerfile. The secret becomes baked into a layer. Instead, use --env-file at runtime:

# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
# Build without secrets
docker build -t my-llm-app .

# Run with secrets injected at container start
docker run --env-file .env my-llm-app

For docker-compose, the env_file directive handles this cleanly:

# docker-compose.yml
services:
  app:
    build: .
    env_file:
      - .env

Pitfall: shell history leakage

Exporting keys directly in your terminal writes them to ~/.bash_history or ~/.zsh_history:

# Bad — ends up in history
export OPENAI_API_KEY=sk-...

Prefer one of these approaches:

# Option 1: Read from a file (not in history)
export OPENAI_API_KEY=$(cat ~/.secrets/openai_key)

# Option 2: Use a tool like direnv — loads .envrc automatically on cd
# .envrc (add to .gitignore)
export OPENAI_API_KEY=sk-...
# Install direnv, then allow the project
direnv allow

Direnv also supports dotenv natively via dotenv .env in .envrc, keeping the same file format across tools.

CI/CD: inject secrets at pipeline runtime

GitHub Actions

Store secrets in the repository settings (Settings → Secrets and variables → Actions), then reference them in workflow files:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - run: pytest

The secrets are masked in logs automatically. Never echo them for debugging.

GitLab CI/CD

Similar pattern in .gitlab-ci.yml:

variables:
  OPENAI_API_KEY: $OPENAI_API_KEY  # defined in Settings → CI/CD → Variables

test:
  script:
    - pip install -r requirements.txt
    - pytest

Mark variables as “Protected” and “Masked” in the UI.

CircleCI

Use contexts or project environment variables. In .circleci/config.yml:

version: 2.1
jobs:
  test:
    docker:
      - image: cimg/python:3.11
    environment:
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    steps:
      - checkout
      - run: pip install -r requirements.txt
      - run: pytest
workflows:
  test:
    jobs:
      - test:
          context: llm-secrets  # defined in Organization Settings → Contexts

Container orchestration: Kubernetes, ECS, and beyond

Kubernetes secrets

Create a secret from literal values or a file:

# Imperative (quick)
kubectl create secret generic llm-keys \
  --from-literal=OPENAI_API_KEY=sk-... \
  --from-literal=ANTHROPIC_API_KEY=sk-ant-...

# Declarative (GitOps-friendly) — use sealed-secrets or external-secrets in practice
kubectl create secret generic llm-keys \
  --from-file=.env \
  --dry-run=client -o yaml > secret.yaml

Reference in your deployment:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: llm-app
  template:
    metadata:
      labels:
        app: llm-app
    spec:
      containers:
        - name: app
          image: my-registry/llm-app:latest
          envFrom:
            - secretRef:
                name: llm-keys
          # Or individual env vars:
          env:
            - name: OPENAI_API_KEY
              valueFrom:
                secretKeyRef:
                  name: llm-keys
                  key: OPENAI_API_KEY

AWS ECS task definitions

In the task definition JSON or via the console, define environment variables that pull from Secrets Manager or Parameter Store:

{
  "family": "llm-app",
  "containerDefinitions": [
    {
      "name": "app",
      "image": "123456789.dkr.ecr.us-east-1.amazonaws.com/llm-app:latest",
      "secrets": [
        {
          "name": "OPENAI_API_KEY",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:llm/openai-key:API_KEY::"
        },
        {
          "name": "ANTHROPIC_API_KEY",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:llm/anthropic-key:API_KEY::"
        }
      ]
    }
  ]
}

The ECS agent injects these as environment variables at container startup. The application code remains unchanged.

Runtime hygiene: prevent accidental exposure

Never log the key

A surprising number of leaks happen through debug logging. Guard against it:

# Python — sanitize before logging
import os
import logging

logger = logging.getLogger(__name__)

def log_config():
    # Safe: log presence, not value
    logger.info("OpenAI configured: %s", bool(os.getenv("OPENAI_API_KEY")))
    # Dangerous: never do this
    # logger.debug("OpenAI key: %s", os.getenv("OPENAI_API_KEY"))
// Node — structured logging with redaction
const pino = require('pino');
const logger = pino({
  redact: {
    paths: ['*.apiKey', '*.authorization', '*.OPENAI_API_KEY'],
    censor: '**REDACTED**'
  }
});

logger.info({ apiKey: process.env.OPENAI_API_KEY }, 'config loaded');
// Output: {"level":30,"time":...,"apiKey":"**REDACTED**","msg":"config loaded"}

Redact in error reporting

Sentry, Datadog, and similar tools capture context automatically. Configure scrubbing:

# sentry_sdk.init(
#     before_send=lambda event, hint: scrub_secrets(event, hint)
# )

def scrub_secrets(event, hint):
    if 'request' in event and 'env' in event['request']:
        env = event['request']['env']
        for key in list(env.keys()):
            if 'KEY' in key.upper() or 'SECRET' in key.upper() or 'TOKEN' in key.upper():
                env[key] = '[REDACTED]'
    return event

Validate presence at startup

Fail fast if a required key is missing — don’t wait for the first API call:

# config.py
import os
import sys

REQUIRED_KEYS = [
    "OPENAI_API_KEY",
    "ANTHROPIC_API_KEY",
]

def validate_env():
    missing = [k for k in REQUIRED_KEYS if not os.getenv(k)]
    if missing:
        sys.stderr.write(f"Missing required environment variables: {', '.join(missing)}\n")
        sys.exit(1)

validate_env()
// config.js
const REQUIRED_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'];

function validateEnv() {
  const missing = REQUIRED_KEYS.filter(k => !process.env[k]);
  if (missing.length > 0) {
    console.error(`Missing required environment variables: ${missing.join(', ')}`);
    process.exit(1);
  }
}

validateEnv();

Rotating keys without downtime

Environment variables make rotation straightforward: update the value in your secret store or CI settings, then restart or reload the application. For zero-downtime rotation:

  1. Add the new key alongside the old one (e.g., OPENAI_API_KEY_V2)
  2. Deploy a version that reads from both, preferring the new key
  3. Verify traffic uses the new key via provider usage dashboards
  4. Revoke the old key
  5. Clean up the old variable name in a follow-up deploy
# Graceful rotation pattern
def get_openai_key():
    # Prefer new key, fall back to old
    return os.getenv("OPENAI_API_KEY_V2") or os.getenv("OPENAI_API_KEY")

Kubernetes rolling restarts handle this naturally when you update the Secret:

# Update the secret
kubectl create secret generic llm-keys \
  --from-literal=OPENAI_API_KEY=sk-new... \
  --dry-run=client -o yaml | kubectl apply -f -

# Trigger rolling restart
kubectl rollout restart deployment/llm-app

Multi-environment strategy

Use distinct keys per environment (dev, staging, prod) with clear naming:

# .env.development
OPENAI_API_KEY=sk-dev-...

# .env.staging
OPENAI_API_KEY=sk-staging-...

# .env.production
OPENAI_API_KEY=sk-prod-...

Load the appropriate file based on NODE_ENV or ENVIRONMENT:

// config.js
const env = process.env.NODE_ENV || 'development';
require('dotenv').config({ path: `.env.${env}` });

// Fallback to base .env for shared values
require('dotenv').config();
# config.py
import os
from dotenv import load_dotenv

env = os.getenv("ENVIRONMENT", "development")
load_dotenv(f".env.{env}")  # environment-specific
load_dotenv(".env")         # shared fallback

In CI/CD, set the environment variable at the job level:

# .github/workflows/staging.yml
jobs:
  deploy-staging:
    environment: staging
    env:
      ENVIRONMENT: staging
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY_STAGING }}

When environment variables aren’t enough

Environment variables API keys work well for most teams, but they have limits:

  • No built-in audit trail — you don’t know who accessed the value or when
  • No automatic rotation — you must orchestrate it yourself
  • Process memory exposure — any code in the process can read process.env
  • No fine-grained access control — every container in the pod sees the same secrets

For regulated environments or larger teams, layer a secrets manager on top. The application still reads environment variables; an init container or sidecar (like the AWS Secrets Manager CSI driver, HashiCorp Vault Agent Injector, or External Secrets Operator) populates them from the central store at startup. This gives you audit logs, rotation policies, and RBAC without changing application code.

# Kubernetes with External Secrets Operator
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: llm-keys
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: llm-keys
    creationPolicy: Owner
  data:
    - secretKey: OPENAI_API_KEY
      remoteRef:
        key: prod/llm/openai
        property: API_KEY

The deployment spec stays identical — it still references secretRef: llm-keys.

Checklist before you ship

  • No API keys in repository history (run git log --all --full-history -- "**/.env" to verify)
  • .env in .gitignore at repository root
  • All required keys validated at application startup
  • Logging redacts keys automatically (test with a debug log line)
  • Error reporting scrubs environment variables
  • Distinct keys per environment (dev/staging/prod)
  • CI/CD secrets stored in platform secret store, not workflow files
  • Container images build without secrets (verify with docker history --no-trunc <image>)
  • Rotation procedure documented and tested
  • Incident response plan for key compromise (revoke, rotate, audit usage)

TL;DR

Environment variables API keys are the pragmatic default for LLM credential management. They keep secrets out of git, work everywhere code runs, and require zero infrastructure to start. Pair them with .env files for local development, platform secret stores for CI/CD, and Kubernetes secrets or cloud provider equivalents for production. Add runtime guards — startup validation, log redaction, error scrubbing — and you have a defensible baseline that scales until you need a full secrets manager.

Tagsapi-keyenvironment-variablessecurity

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All api keys & authentication for llm apis posts →