env vs. config.yaml vs. google-credentials.json: A Security & Structure Guide
Master configuration management by understanding when to use .env, YAML, and JSON secrets. Learn security best practices and how to prevent leaks with evnx.
Ajit Kumar
Creator, evnx
It was 2:14 AM during a production deploy.
Our Kubernetes cluster was rolling out a new microservice. The pods kept crashing. CrashLoopBackOff. I SSH'd into a node, checked the logs, and found it:
Error: failed to parse config: invalid character '\n' looking for beginning of object key string
The developer had copied a Google service account JSON directly into a .env file. The newlines weren't escaped. The config parser choked. The service died. Three regions. Forty-five minutes of downtime.
That incident taught me: not all configuration is created equal.
The Core Problem: One Size Does Not Fit All
We treat configuration like it's a monolith. It's not.
- ›A database password needs different protection than a feature flag.
- ›A 2KB RSA key blob shouldn't live in the same place as a
DEBUG=truetoggle. - ›Deployment targets change per environment; app behavior often doesn't.
When we mix these concerns, we get:
- ›Escaping nightmares in
.envfiles - ›Secrets accidentally committed to git
- ›Configuration that's impossible to audit
- ›Downtime at 2 AM
The cardinal rule
Never store structured data (JSON, YAML) as a string value in a flat .env file. The escaping will betray you.
Format Differences: The Foundation
Before we talk about what to store where, let's clarify how these formats differ.
.env: Flat, Simple, Process-Level
# .env
DATABASE_URL=postgres://user:pass@host:5432/db
API_KEY=sk_live_abc123
PORT=8080Characteristics:
- ›Flat key-value pairs only
- ›All values are strings (no native booleans, numbers, lists)
- ›Loaded at process startup via
dotenvor similar - ›Injected into
process.env/os.environ
Best for: Secrets, deployment targets, anything that changes per environment.
config.yaml: Nested, Structured, Application-Level
# config.yaml
server:
port: 8080
timeout: 30s
debug: false
features:
new_checkout: true
beta_api:
enabled: true
rollout_percentage: 25
logging:
level: info
outputs:
- stdout
- file:/var/log/app.logCharacteristics:
- ›Supports nested objects, arrays, multiple data types
- ›Parsed by your application code (not the OS)
- ›Can be versioned safely (if it contains no secrets)
- ›Human-readable with comments
Best for: App behavior, feature toggles, non-secret settings.
google-credentials.json: Specialized, Sensitive, Referenced
{
"type": "service_account",
"project_id": "my-production-app",
"private_key_id": "abc123...",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7...\n-----END PRIVATE KEY-----\n",
"client_email": "svc@my-production-app.iam.gserviceaccount.com",
"client_id": "123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}Characteristics:
- ›Complex nested structure with binary-like content (private keys)
- ›Should never be edited by hand
- ›Referenced by file path, not embedded
- ›Managed by cloud provider tooling
Best for: Service account keys, OAuth credentials, any provider-specific secret bundle.
Production Deployment: Real Examples
Let's see how these pieces fit together in a real production environment.
Scenario: Python Microservice on Kubernetes
# src/config/loader.py
import os
import yaml
from pathlib import Path
from google.oauth2 import service_account
class Config:
def __init__(self):
# 1. Load environment variables (secrets & targets)
self.db_url = os.environ['DATABASE_URL']
self.api_key = os.environ['STRIPE_API_KEY']
# 2. Load app behavior from versioned YAML
with open('/app/config/config.yaml', 'r') as f:
app_config = yaml.safe_load(f)
self.debug = app_config['server']['debug']
self.feature_flags = app_config['features']
# 3. Load Google credentials by PATH (not content)
cred_path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')
if cred_path and Path(cred_path).exists():
self.gcp_creds = service_account.Credentials.from_service_account_file(
cred_path,
scopes=['https://www.googleapis.com/auth/cloud-platform']
)# config/config.yaml (committed to git, no secrets)
server:
port: 8080
timeout: 30s
debug: false # Never true in production
features:
new_checkout: true
beta_api:
enabled: true
rollout_percentage: 25 # Gradual rollout
database:
pool_size: 20
connection_timeout: 10# .env.production (NEVER committed, injected via Kubernetes Secret)
DATABASE_URL=postgres://prod-user:REDACTED@prod-db:5432/prod
STRIPE_API_KEY=sk_live_REDACTED
GOOGLE_APPLICATION_CREDENTIALS=/secrets/google-sa-key.json# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
template:
spec:
containers:
- name: app
image: myregistry/payment-service:v1.2.3
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secrets
key: url
- name: STRIPE_API_KEY
valueFrom:
secretKeyRef:
name: stripe-secrets
key: live-key
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /secrets/google-sa-key.json
volumeMounts:
- name: google-creds
mountPath: /secrets
readOnly: true
volumes:
- name: google-creds
secret:
secretName: google-sa-key
items:
- key: key.json
path: google-sa-key.jsonKubernetes best practice
Mount sensitive JSON credential files as secret volumes, not environment variables. Environment variables can leak in logs, error messages, and process listings.
The Security Risks: What Goes Wrong
Anti-Pattern #1: JSON Blobs in .env
# ❌ DON'T DO THIS
# .env
GOOGLE_CREDENTIALS={"type":"service_account","project_id":"app","private_key":"-----BEGIN PRIVATE KEY-----\nMIIE..."}Why this fails:
- ›Escaping hell: Newlines (
\n), quotes ("), and backslashes (\) must be escaped differently across shells, Docker, Kubernetes, and language runtimes. - ›Size limits: Some CI/CD systems truncate environment variables >4KB.
- ›Logging exposure:
.envvalues often appear in debug logs; a 2KB JSON blob is harder to redact than***REDACTED***. - ›Parsing complexity: Your app now needs to
json.loads(os.environ['GOOGLE_CREDENTIALS'])before it can use the credentials.
Anti-Pattern #2: Mixing Secrets with Behavior
# ❌ config.yaml with secrets (committed to git)
database:
url: postgres://user:password123@host/db # ← SECRET IN VERSION CONTROL
pool_size: 20The fix: Split configuration by sensitivity.
# ✅ config.yaml (safe to commit)
database:
host: ${DB_HOST} # Reference env var
port: 5432
pool_size: 20# ✅ .env (never committed)
DB_HOST=prod-db.internal
DB_PASSWORD=super-secret-valueThe git history trap
Removing a secret from .env and committing the change doesn't erase it from git history. Use git filter-branch or BFG Repo-Cleaner to scrub history, then rotate the secret immediately.
The evnx Solution: Catch Mistakes Before Production
Manual reviews miss edge cases. That's why I built scanning into evnx.
Scan for Dangerous Patterns
# Run before commit
$ evnx scan ./project
[SCAN] Analyzing .env files...
[ERROR] .env:17 - GOOGLE_CREDENTIALS contains JSON-like structure
→ Suggestion: Store file path instead: GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
[WARN] .env:23 - API_KEY has high entropy (possible secret)
→ Confidence: 94% | Pattern: ^sk_live_[a-zA-Z0-9]{24,}
[INFO] .env.example is missing 3 variables present in .env
→ Run: evnx sync --example to update
[RESULT] 1 error, 1 warning. Commit blocked.Validate Git Configuration
# Catch .gitignore misconfigurations
$ evnx doctor
[DOCTOR] Environment health check...
[WARNING] packages/api/.env is tracked by git
[WARNING] Root .gitignore does not cover packages/api/.env
[OK] .env.example is present and synchronized
[OK] No high-entropy values detected in committed files
[FIX] Run: evnx ignore --add packages/api/.envPre-commit Hook Integration
# .git/hooks/pre-commit
#!/bin/bash
if ! command -v evnx &> /dev/null; then
echo "⚠️ evnx not installed. Install with: curl -fsSL https://dotenv.space/install.sh | bash"
exit 0 # Don't block, but warn
fi
if ! evnx scan --quiet; then
echo "❌ Security scan failed. Fix issues before committing."
exit 1
fiPerformance: The hook runs in ~180ms on cold start, ~45ms cached. Fast enough that developers don't disable it.
CI/CD gate
Add evnx scan --strict to your GitHub Actions or GitLab CI pipeline. Fail the build if secrets are detected in configuration files.
Decision Tree: Where Does This Setting Belong?
When adding a new configuration variable, follow this logic:
Is it a secret? (password, API key, private key)
├─ YES → Store in .env (injected at runtime)
│ └─ For complex credentials (JSON): store FILE PATH in .env
│
├─ NO → Is it a complex/nested structure? (object, array, multi-line)
│ ├─ YES → Store in config.yaml or dedicated .json file
│ │ └─ Reference the file path in .env if it contains secrets
│ │
│ └─ NO → Does it change per deployment? (DB host, feature flag per env)
│ ├─ YES → Store in .env
│ │
│ └─ NO → Store in config.yaml (versioned with code)
Real-world examples:
| Variable | Type | Storage | Why |
|---|---|---|---|
DATABASE_URL | Secret + Target | .env | Changes per env, contains password |
STRIPE_API_KEY | Secret | .env | Never commit, injected at runtime |
GOOGLE_APPLICATION_CREDENTIALS | Path to secret | .env | Points to mounted JSON file |
SERVER_PORT | Simple, non-secret | config.yaml | Same across environments |
FEATURE_NEW_CHECKOUT | Boolean toggle | config.yaml | Versioned, auditable, non-secret |
LOG_LEVEL | Simple, env-specific | .env | Debug in dev, info in prod |
RATE_LIMIT_CONFIG | Nested object | config.yaml | Complex structure, non-secret |
Production Patterns: Docker, Kubernetes, CI/CD
Docker: Multi-stage Builds with Config Injection
# Dockerfile
FROM python:3.11-slim as builder
# Install dependencies
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
# Create non-root user
RUN useradd -m appuser && mkdir /app && chown appuser:appuser /app
USER appuser
WORKDIR /app
# Copy application code (excluding .env)
COPY --chown=appuser:appuser src/ ./src/
COPY --chown=appuser:appuser config/config.yaml ./config/
# Copy entrypoint that validates config at startup
COPY --chown=appuser:appuser scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python", "-m", "src.main"]# scripts/entrypoint.sh
#!/bin/bash
set -e
# Validate environment before starting app
if ! command -v evnx &> /dev/null; then
echo "⚠️ evnx not available in container. Skipping config validation."
else
echo "🔒 Validating configuration..."
evnx scan --env-only --quiet || exit 1
fi
# Start the application
exec "$@"GitHub Actions: Secure Deployment Pipeline
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install evnx
run: curl -fsSL https://dotenv.space/install.sh | bash
- name: Scan for configuration issues
run: evnx scan --strict
# Fails build if secrets detected in .env or config files
- name: Validate .env.example sync
run: evnx sync --check
# Ensures example file stays up to date
deploy:
needs: security-scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
uses: azure/k8s-set-context@v1
with:
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- name: Deploy with secrets injection
run: |
kubectl create secret generic app-secrets \
--from-literal=DATABASE_URL=${{ secrets.DB_URL }} \
--from-literal=STRIPE_API_KEY=${{ secrets.STRIPE_KEY }} \
--from-literal=GOOGLE_APPLICATION_CREDENTIALS=/secrets/google-sa-key.json \
--dry-run=client -o yaml | kubectl apply -f -Never log environment variables
Add this to your application's logging configuration:
# logging_config.py
SENSITIVE_KEYS = {'password', 'api_key', 'secret', 'token', 'credential'}
class SecretFilter(logging.Filter):
def filter(self, record):
if isinstance(record.msg, str):
for key in SENSITIVE_KEYS:
if key in record.msg.lower():
record.msg = "***REDACTED***"
return True
logger.addFilter(SecretFilter())Comparison: When to Reach for a Secret Manager
| Use Case | .env + config.yaml | AWS Secrets Manager / HashiCorp Vault |
|---|---|---|
| Local development | ✅ Simple, fast | ❌ Overkill |
| Small team / startup | ✅ Low cost, easy setup | ⚠️ Possible, but complex |
| Compliance requirements (SOC2, HIPAA) | ❌ Manual rotation, limited audit | ✅ Automated rotation, full audit logs |
| Dynamic secrets (short-lived DB creds) | ❌ Not supported | ✅ Native support |
| Cross-service secret sharing | ❌ Manual sync | ✅ Centralized access control |
| Cost sensitivity | ✅ Free | ❌ $0.40/secret/month + API calls |
Hybrid approach for growing teams:
- ›Use
.env+config.yamlfor local/dev/staging - ›Use a secret manager for production secrets
- ›Reference secret manager paths in
.env.production - ›Use
evnxto enforce the boundary
# .env.production (production deployment)
# Instead of the actual secret, reference the secret manager path
DATABASE_URL=aws-secrets-manager://prod/db/url
STRIPE_API_KEY=vault://kv/prod/stripe/live-key
GOOGLE_APPLICATION_CREDENTIALS=/secrets/google-sa-key.json # Still mounted as fileTakeaway: Actionable Steps Today
- ›
Audit your repository now
git log -p --all | grep -iE "(password|api_key|secret|token)" | head -20 - ›
Split your configuration by sensitivity
- ›Secrets →
.env(never committed) - ›Behavior →
config.yaml(safe to commit) - ›Complex credentials → dedicated
.jsonfile, referenced by path
- ›Secrets →
- ›
Add automated scanning
# Install evnx curl -fsSL https://dotenv.space/install.sh | bash # Add pre-commit hook evnx hook install --pre-commit # Run initial scan evnx scan --fix - ›
Update your CI/CD pipeline
- ›Add
evnx scan --strictas a required check - ›Fail builds that contain high-entropy strings in config files
- ›Validate
.env.examplestays synchronized
- ›Add
- ›
Document the pattern for your team
- ›Create a
CONFIGURATION.mdin your repo - ›Include the decision tree above
- ›Add examples of correct/incorrect usage
- ›Create a
The 5-minute security win
Run evnx doctor on your project right now. It takes 3 seconds and will catch common misconfigurations before they become incidents.
Next Steps
- ›Read the evnx documentation for advanced scanning rules
- ›Download the Kubernetes config templates for secure secret mounting
- ›Join the discussion to share your configuration patterns
Final warning
Your next incident won't happen at 2 AM when you're paying attention. It'll happen during a rushed deploy, with a tired mind, on a Friday afternoon. Automate the safety net now.
# Get started in 60 seconds
curl -fsSL https://dotenv.space/install.sh | bash
evnx doctor # Find issues in your current setup
evnx scan # Block dangerous patterns before commitYour configuration is your attack surface. Treat it like one.