Secrets management in Docker is where most teams get bitten. Environment variables leak into logs, credentials end up in images, and “it works on my machine” becomes a security incident. Here’s how to handle secrets properly at every stage.

The Problem with Environment Variables

The most common approach—and the most dangerous:

1
2
3
4
5
6
# docker-compose.yml - DON'T DO THIS
services:
  app:
    environment:
      - DATABASE_PASSWORD=super_secret_password
      - API_KEY=sk-live-1234567890

Why this fails:

  • Secrets visible in docker inspect
  • Logged in container orchestration systems
  • Committed to version control
  • Visible in process listings

Level 1: Environment Files (Development)

Slightly better—keep secrets out of compose files:

1
2
3
4
5
# docker-compose.yml
services:
  app:
    env_file:
      - .env.local
1
2
3
# .env.local (gitignored)
DATABASE_PASSWORD=dev_password
API_KEY=sk-test-development
1
2
3
4
# .gitignore
.env.local
.env.*.local
*.env

Pros: Secrets not in version control Cons: Still environment variables, still inspectable

Level 2: Docker Secrets (Swarm/Compose)

Docker has built-in secrets management:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# docker-compose.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt

Secrets appear as files in /run/secrets/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# In your application
from pathlib import Path

def get_secret(name: str) -> str:
    """Read secret from Docker secrets or environment."""
    secret_file = Path(f"/run/secrets/{name}")
    if secret_file.exists():
        return secret_file.read_text().strip()
    # Fallback for local development
    return os.environ.get(name.upper(), "")

db_password = get_secret("db_password")

Pros: Secrets are files, not env vars; better isolation Cons: Requires Swarm mode for full features; secrets still on disk

Level 3: Mount from External Secret Store

For production, pull secrets from a dedicated store:

1
2
3
4
5
6
7
# docker-compose.yml
services:
  app:
    volumes:
      - /secure/secrets:/run/secrets:ro
    environment:
      - VAULT_ADDR=https://vault.internal:8200

Or fetch at runtime:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import hvac
import os

def get_vault_secret(path: str) -> dict:
    """Fetch secret from HashiCorp Vault."""
    client = hvac.Client(
        url=os.environ['VAULT_ADDR'],
        token=os.environ['VAULT_TOKEN']
    )
    
    secret = client.secrets.kv.v2.read_secret_version(path=path)
    return secret['data']['data']

# Usage
db_creds = get_vault_secret('database/prod')
connection_string = f"postgres://{db_creds['username']}:{db_creds['password']}@db:5432"

Level 4: Sidecar Pattern (Kubernetes)

In Kubernetes, use init containers or sidecars to inject secrets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  initContainers:
    - name: secrets-init
      image: vault-agent:latest
      volumeMounts:
        - name: secrets
          mountPath: /secrets
      env:
        - name: VAULT_ROLE
          value: "app-role"
  
  containers:
    - name: app
      image: myapp:latest
      volumeMounts:
        - name: secrets
          mountPath: /run/secrets
          readOnly: true
  
  volumes:
    - name: secrets
      emptyDir:
        medium: Memory  # tmpfs - never hits disk

The init container authenticates and fetches secrets before the app starts. Secrets live in memory-backed volumes.

Practical Pattern: Multi-Stage Secret Loading

A flexible approach that works from dev to prod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# config/secrets.py
import os
import json
from pathlib import Path
from functools import lru_cache

@lru_cache
def load_secrets() -> dict:
    """Load secrets from multiple sources with priority."""
    secrets = {}
    
    # Priority 1: Docker secrets (files in /run/secrets)
    secrets_dir = Path("/run/secrets")
    if secrets_dir.exists():
        for secret_file in secrets_dir.iterdir():
            if secret_file.is_file():
                secrets[secret_file.name] = secret_file.read_text().strip()
    
    # Priority 2: JSON secrets file (for local dev)
    local_secrets = Path("secrets.json")
    if local_secrets.exists():
        with open(local_secrets) as f:
            secrets.update(json.load(f))
    
    # Priority 3: Environment variables (fallback)
    for key in ['DATABASE_URL', 'API_KEY', 'SECRET_KEY']:
        if key in os.environ and key not in secrets:
            secrets[key.lower()] = os.environ[key]
    
    return secrets

def get_secret(name: str, default: str = None) -> str:
    """Get a secret by name."""
    secrets = load_secrets()
    value = secrets.get(name, default)
    if value is None:
        raise ValueError(f"Secret '{name}' not found")
    return value

Usage:

1
2
3
4
from config.secrets import get_secret

DATABASE_URL = get_secret('database_url')
API_KEY = get_secret('api_key')

Secret Rotation Without Downtime

Design for rotation from the start:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import time
from threading import Thread

class RotatingSecret:
    def __init__(self, fetch_func, refresh_interval=300):
        self._fetch = fetch_func
        self._value = fetch_func()
        self._interval = refresh_interval
        self._start_refresh_thread()
    
    def _start_refresh_thread(self):
        def refresh():
            while True:
                time.sleep(self._interval)
                try:
                    self._value = self._fetch()
                except Exception as e:
                    print(f"Secret refresh failed: {e}")
        
        thread = Thread(target=refresh, daemon=True)
        thread.start()
    
    @property
    def value(self):
        return self._value

# Usage
db_password = RotatingSecret(
    lambda: get_vault_secret('db/password')['password'],
    refresh_interval=300  # 5 minutes
)

# Always use .value to get current secret
connection = connect(password=db_password.value)

Quick Security Checklist

  1. Never commit secrets — Use .gitignore, pre-commit hooks
  2. Never log secrets — Scrub logs, use structured logging
  3. Never build secrets into images — Use runtime injection
  4. Rotate regularly — Automate rotation, design for it
  5. Audit access — Log who accessed what secrets when
  6. Encrypt at rest — Use encrypted secret stores
  7. Limit scope — Each service gets only the secrets it needs

Development vs Production

AspectDevelopmentProduction
StorageLocal .env filesVault/KMS/Secrets Manager
InjectionDocker Compose env_fileInit containers, sidecars
RotationManualAutomated
AccessDeveloper machinesService accounts only
LoggingMay appear in debugScrubbed, audited

Secrets management is one of those things that’s easy to get wrong and painful to fix later. Start with the right patterns—even if you’re using simple file-based secrets in development, structure your code to support proper secret stores in production.

The goal: your application code shouldn’t know or care where secrets come from. It just calls get_secret() and trusts the infrastructure to provide them securely.