Privacy Tools Guide

Secure Environment Variable Management

Storing secrets in environment variables is common and often adequate — but the default patterns are riddled with leakage vectors: docker inspect, process listings, crash dumps, log files, and CI/CD artifact exports all expose ENV values to anyone with access. This guide covers every layer from development to production.

Why Environment Variables Are Not Enough Alone


1. Never Commit Secrets to Git

# Install git-secrets to prevent committing credentials
brew install git-secrets   # macOS
pip install detect-secrets  # cross-platform

# git-secrets: register common patterns
git secrets --install   # install hooks in current repo
git secrets --register-aws   # adds AWS key patterns
git secrets --add '[A-Z0-9]{20}'   # custom pattern

# detect-secrets: scan existing repo
detect-secrets scan > .secrets.baseline
detect-secrets audit .secrets.baseline

# Add to pre-commit hook
cat > .pre-commit-config.yaml <<'EOF'
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']
EOF

pre-commit install

2. Use a .env File Correctly

For local development only:

# .env — only for local dev, NEVER commit
DB_PASSWORD=localonly_password
API_KEY=dev_key_not_real

# .gitignore
echo ".env" >> .gitignore
echo ".env.*" >> .gitignore   # catches .env.production etc.

# Load without polluting the shell environment
# Use direnv (auto-loads .env on cd into directory)
brew install direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc

# Create .envrc (direnv reads this, not .env directly)
echo 'dotenv' > .envrc
direnv allow

3. Docker: Avoid ENV in Dockerfile for Secrets

# BAD — secret baked into image and visible in docker inspect / history
ENV DB_PASSWORD=supersecret

# GOOD — accept at runtime, set no default
# (app reads from environment at startup)

# Use build args only for non-secret build-time values
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}

Pass secrets at docker run time from your secrets manager, not hardcoded:

# Read from a file (avoid shell history)
docker run \
  --env-file <(vault kv get -field=db_password secret/myapp | \
               awk '{print "DB_PASSWORD="$0}') \
  myapp:latest

# Or use Docker secrets (Swarm mode)
echo "supersecret" | docker secret create db_password -
docker service create \
  --secret db_password \
  --name myapp myapp:latest
# Secret available as /run/secrets/db_password — not an env var

Read the Docker secret in your app instead of env var:

import os

def get_secret(name: str) -> str:
    # Docker secret
    secret_file = f"/run/secrets/{name}"
    if os.path.exists(secret_file):
        return open(secret_file).read().strip()
    # Fall back to env var (development)
    return os.environ[name]

DB_PASSWORD = get_secret("db_password")

4. Kubernetes: Use Sealed Secrets or External Secrets Operator

Native Kubernetes Secrets are base64-encoded, not encrypted. Anyone with kubectl get secret access can read them.

Option A: Sealed Secrets (offline encryption)

# Install kubeseal CLI
brew install kubeseal

# Install the controller in the cluster
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/latest/download/controller.yaml

# Create a sealed secret
kubectl create secret generic db-creds \
  --from-literal=password=supersecret \
  --dry-run=client -o yaml \
  | kubeseal --format yaml > sealed-db-creds.yaml

# The sealed secret is safe to commit to git
git add sealed-db-creds.yaml

# Deploy — controller decrypts and creates the real Secret
kubectl apply -f sealed-db-creds.yaml

Option B: External Secrets Operator (pulls from Vault/AWS SM)

# ExternalSecret — pulls from HashiCorp Vault
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-creds
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: db-creds
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: secret/myapp
        property: db_password

5. HashiCorp Vault: Dynamic Secrets

Vault goes further than static secrets — it generates short-lived, per-app credentials on demand:

# Install Vault
sudo apt install -y vault

# Enable database secrets engine
vault secrets enable database

# Configure PostgreSQL
vault write database/config/mydb \
  plugin_name=postgresql-database-plugin \
  allowed_roles="myapp" \
  connection_url="postgresql://vault:vaultpass@localhost/mydb" \
  username="vault" password="vaultpass"

# Create a role — Vault generates credentials on request
vault write database/roles/myapp \
  db_name=mydb \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' \
    VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

# App requests credentials at startup
vault read database/creds/myapp
# Key                Value
# username           v-myapp-aBcDeFgH
# password           A1-aBcDeFgHiJk...
# lease_duration     1h

The credentials expire automatically. If the app is compromised, the attacker’s DB access expires within a hour.


6. Inject Secrets at Runtime (No ENV at all)

# Use envsubst + Vault agent to write config files at startup
# Or use envconsul (Consul/Vault-backed env injection)

vault agent -config=/etc/vault-agent.hcl &

# vault-agent.hcl
# template {
#   source      = "/app/config.tmpl"
#   destination = "/app/config.json"
#   command     = "/bin/kill -HUP $(cat /app/app.pid)"
# }

The app reads a config file written by Vault Agent — the secret is never in the environment.


7. Mask Secrets in CI/CD

GitHub Actions, GitLab CI, and CircleCI all support masked variables. Use them consistently:

# GitHub Actions — store DB_PASSWORD in Settings > Secrets
- name: Run tests
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
  run: pytest

# NEVER do this:
- run: echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> $GITHUB_ENV
# Secrets in GITHUB_ENV are visible in subsequent steps' environment dumps

For GitLab CI:

# Mark variables as masked in GitLab Settings > CI/CD > Variables
# Masked values are redacted from logs automatically

test:
  variables:
    DB_PASSWORD: $DB_PASSWORD   # injected from masked variable
  script:
    - python -m pytest

8. Audit Running Processes for Leaked Secrets

# Check what env vars running processes expose
for pid in /proc/[0-9]*/environ; do
  echo "=== $pid ===" && tr '\0' '\n' < $pid 2>/dev/null | grep -iE "pass|key|secret|token"
done

# Scan Docker containers
for cid in $(docker ps -q); do
  echo "=== $cid ===" && docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $cid \
    | grep -iE "pass|key|secret|token"
done

Checklist



Built by theluckystrike — More at zovo.one