Tutorial#security#configuration#devops#best-practices#evnx#rust

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.

A

Ajit Kumar

Creator, evnx

·11 min read

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=true toggle.
  • Deployment targets change per environment; app behavior often doesn't.

When we mix these concerns, we get:

  • Escaping nightmares in .env files
  • 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

Bash
# .env
DATABASE_URL=postgres://user:pass@host:5432/db
API_KEY=sk_live_abc123
PORT=8080

Characteristics:

  • Flat key-value pairs only
  • All values are strings (no native booleans, numbers, lists)
  • Loaded at process startup via dotenv or similar
  • Injected into process.env / os.environ

Best for: Secrets, deployment targets, anything that changes per environment.

config.yaml: Nested, Structured, Application-Level

YAML
# 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.log

Characteristics:

  • 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

JSON
{
  "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

Python
# 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']
            )
YAML
# 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
Bash
# .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
YAML
# 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.json

Kubernetes 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

Bash
# ❌ DON'T DO THIS
# .env
GOOGLE_CREDENTIALS={"type":"service_account","project_id":"app","private_key":"-----BEGIN PRIVATE KEY-----\nMIIE..."}

Why this fails:

  1. Escaping hell: Newlines (\n), quotes ("), and backslashes (\) must be escaped differently across shells, Docker, Kubernetes, and language runtimes.
  2. Size limits: Some CI/CD systems truncate environment variables >4KB.
  3. Logging exposure: .env values often appear in debug logs; a 2KB JSON blob is harder to redact than ***REDACTED***.
  4. 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

YAML
# ❌ config.yaml with secrets (committed to git)
database:
  url: postgres://user:password123@host/db  # ← SECRET IN VERSION CONTROL
  pool_size: 20

The fix: Split configuration by sensitivity.

YAML
# ✅ config.yaml (safe to commit)
database:
  host: ${DB_HOST}  # Reference env var
  port: 5432
  pool_size: 20
Bash
# ✅ .env (never committed)
DB_HOST=prod-db.internal
DB_PASSWORD=super-secret-value

The 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

Bash
# 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

Bash
# 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/.env

Pre-commit Hook Integration

Bash
# .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
fi

Performance: 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:

VariableTypeStorageWhy
DATABASE_URLSecret + Target.envChanges per env, contains password
STRIPE_API_KEYSecret.envNever commit, injected at runtime
GOOGLE_APPLICATION_CREDENTIALSPath to secret.envPoints to mounted JSON file
SERVER_PORTSimple, non-secretconfig.yamlSame across environments
FEATURE_NEW_CHECKOUTBoolean toggleconfig.yamlVersioned, auditable, non-secret
LOG_LEVELSimple, env-specific.envDebug in dev, info in prod
RATE_LIMIT_CONFIGNested objectconfig.yamlComplex structure, non-secret

Production Patterns: Docker, Kubernetes, CI/CD

Docker: Multi-stage Builds with Config Injection

Dockerfile
# 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"]
Bash
# 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

YAML
# .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:

Python
# 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.yamlAWS 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:

  1. Use .env + config.yaml for local/dev/staging
  2. Use a secret manager for production secrets
  3. Reference secret manager paths in .env.production
  4. Use evnx to enforce the boundary
Bash
# .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 file

Takeaway: Actionable Steps Today

  1. Audit your repository now

    Bash
    git log -p --all | grep -iE "(password|api_key|secret|token)" | head -20
  2. Split your configuration by sensitivity

    • Secrets → .env (never committed)
    • Behavior → config.yaml (safe to commit)
    • Complex credentials → dedicated .json file, referenced by path
  3. Add automated scanning

    Bash
    # 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
  4. Update your CI/CD pipeline

    • Add evnx scan --strict as a required check
    • Fail builds that contain high-entropy strings in config files
    • Validate .env.example stays synchronized
  5. Document the pattern for your team

    • Create a CONFIGURATION.md in your repo
    • Include the decision tree above
    • Add examples of correct/incorrect usage

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

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.

Bash
# 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 commit

Your configuration is your attack surface. Treat it like one.

#security#configuration#devops#best-practices#evnx#rust
A

Ajit Kumar

Creator, evnx

Developer, researcher, security advocate, and accidental AWS key pusher. I built evnx after a production incident I'd rather forget — so you don't have to repeat my mistakes. Currently obsessed with Rust, developer tooling, and zero-trust secret management.