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#
- Never commit secrets — Use
.gitignore, pre-commit hooks - Never log secrets — Scrub logs, use structured logging
- Never build secrets into images — Use runtime injection
- Rotate regularly — Automate rotation, design for it
- Audit access — Log who accessed what secrets when
- Encrypt at rest — Use encrypted secret stores
- Limit scope — Each service gets only the secrets it needs
Development vs Production#
| Aspect | Development | Production |
|---|
| Storage | Local .env files | Vault/KMS/Secrets Manager |
| Injection | Docker Compose env_file | Init containers, sidecars |
| Rotation | Manual | Automated |
| Access | Developer machines | Service accounts only |
| Logging | May appear in debug | Scrubbed, 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.