After years of shipping services in containers, I stopped arguing about the "perfect" secret strategy and settled on what actually works. Below is my field guide to storing and injecting keys, tokens, and database connections with Docker on both Windows and Linux — split into development vs production, and covering Swarm and standalone setups. Everything here comes straight from real deployments, code reviews, and a few scary postmortems.
- Mental model & why I avoid env var secrets
- Development: fast but safe
- Production (Swarm): native Docker secrets
- Production (Standalone): emulate secrets safely
- External secret managers without Kubernetes
- Windows vs Linux notes that matter
- How I set these (copy/paste)
- Rotation & incident response
- Pitfalls I keep seeing
- What to use when (quick compare)
- References
1. Mental model & why I avoid env var secrets
My default: treat secrets as files at runtime and pass their paths via environment variables (e.g., DB_PASSWORD_FILE=/run/secrets/db.password). Files are easier to rotate, lock down with permissions, and keep out of logs and process listings. I only use plain env vars for secrets in short‑lived experiments, and even then I try not to.
# App convention I implement in every service
# Prefer FOO_FILE over FOO
if [ -f "$DB_PASSWORD_FILE" ]; then DB_PASSWORD=$(cat "$DB_PASSWORD_FILE"); fi
2. Development: fast but safe
In dev I want speed, but I don't want real credentials landing in git, logs, or screenshots. I use two env files and read secrets from files:
# deploy/.env (safe defaults, no real secrets)
APP_NAME=my-app
ASPNETCORE_ENVIRONMENT=Development
API_PORT=5080
# deploy/.env.local (per-dev, git-ignored)
DB_HOST=localhost
DB_USER=dev_user
DB_PASSWORD=something-local-only
JWT_PRIVATE_KEY_FILE=./secrets/jwt.private.pem
# deploy/docker-compose.yml
version: "3.8"
services:
api:
build: ../src/Api
image: my-app-api:dev
env_file:
- ./.env
- ./.env.local
ports:
- "${API_PORT:-5080}:8080"
# deploy/docker-compose.override.yml (dev only)
services:
api:
volumes:
- ../src/Api:/app:delegated
- ./secrets/jwt.private.pem:/run/dev-secrets/jwt.private.pem:ro
environment:
JWT_PRIVATE_KEY_PATH: /run/dev-secrets/jwt.private.pem
Windows tip: normalize line endings. I use .gitattributes with *.env text eol=lf. Linux tip: keep dev secret files chmod 600 (umask 077 first).
3. Production (Swarm): native Docker secrets
Swarm secrets are simple and robust: they're mounted as files under /run/secrets and never show up in docker inspect env output or get baked into images.
# Manager node
docker secret create db_password ./deploy/swarm/secrets/db.password
docker secret create jwt_private_pem ./deploy/swarm/secrets/jwt.private.pem
docker secret ls
docker secret inspect db_password --pretty
# deploy/swarm/stack.yml
version: "3.8"
services:
api:
image: my-app-api:1.0.0
deploy:
replicas: 2
secrets:
- source: db_password
target: db.password
- source: jwt_private_pem
target: jwt.private.pem
environment:
DB_PASSWORD_FILE: /run/secrets/db.password
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt.private.pem
secrets:
db_password:
external: true
jwt_private_pem:
external: true
Rotation I trust: create a new secret name (e.g., db_password_v2), update the stack, verify, then remove the old one. Don't reuse names in place.
4. Production (Standalone): emulate secrets safely
No Swarm? I use either a locked host directory with read‑only bind mounts or an in‑memory tmpfs that's populated at start.
4.1 Locked directory + bind mounts
# On the host (Linux)
sudo mkdir -p /opt/my-app/secrets
sudo chown root:root /opt/my-app/secrets
sudo chmod 700 /opt/my-app/secrets
sudo install -m 600 /dev/stdin /opt/my-app/secrets/db.password <<'EOF'
REDACTED-super-strong-password
EOF
# deploy/standalone/docker-compose.yml
version: "3.8"
services:
api:
image: my-app-api:1.0.0
restart: unless-stopped
volumes:
- /opt/my-app/secrets/db.password:/run/app-secrets/db.password:ro
- /opt/my-app/secrets/jwt.private.pem:/run/app-secrets/jwt.private.pem:ro
environment:
DB_PASSWORD_FILE: /run/app-secrets/db.password
JWT_PRIVATE_KEY_PATH: /run/app-secrets/jwt.private.pem
4.2 tmpfs for highly sensitive keys
# Compose snippet
services:
api:
image: my-app-api:1.0.0
tmpfs:
- /run/app-secrets:rw,noexec,nosuid,nodev,size=64k
entrypoint: ["/bin/sh","-c"]
command: |
set -eu
echo "$JWT_PRIVATE_KEY" > /run/app-secrets/jwt.pem
exec /app/my-api
environment:
JWT_PRIVATE_KEY: <redacted>
Note: I still try to avoid passing the full secret via env. If I must, I prevent echoing and scrub variables in wrappers after use.
5. External secret managers without Kubernetes
On single servers or small fleets, I've had great results letting the app fetch secrets at start using host identity:
- Azure Key Vault — VM managed identity fetches a token via IMDS; a tiny bootstrap pulls secrets and writes files under
/run/app-secrets. - AWS Secrets Manager — EC2 instance role + small fetcher renders JSON values into files. Restrict metadata access to the bootstrap only.
- HashiCorp Vault — AppRole/JWT auth with short‑lived tokens and templates (e.g., consul‑template) to write and signal reload.
6. Windows vs Linux notes that matter
Windows
- Docker Desktop (WSL2) runs Linux containers by default — treat container FS/permissions like Linux; use NTFS ACLs on the host and mount
:ro. - Normalize line endings for
.env. Use.gitattributes. - Avoid global user environment variables for secrets; prefer project‑local files.
Linux
- Secrets: files
600, dirs700. Considernoexec,nosuid,nodevon mounts. - Use
tmpfsfor the really sensitive bits. Keep logs from dumping env/process args. - Harden with simple sysctls like
fs.protected_regularand restrict who can read mounted paths.
7. How I set these (copy/paste)
Windows (PowerShell)
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:API_PORT = "5080"
# For dev-only, not production
$env:DB_PASSWORD = "not-for-prod"
docker compose --env-file ./deploy/.env --env-file ./deploy/.env.local up -d
Linux (bash)
export ASPNETCORE_ENVIRONMENT=Production
export API_PORT=8080
docker compose --env-file ./deploy/.env --env-file ./deploy/.env.local up -d
systemd service (Linux)
[Unit]
Description=My Containerized API
After=network.target
[Service]
Type=simple
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=API_PORT=8080
ExecStart=/usr/bin/docker compose -f /opt/myapp/deploy/standalone/docker-compose.yml up --abort-on-container-exit
ExecStop=/usr/bin/docker compose -f /opt/myapp/deploy/standalone/docker-compose.yml down
Restart=always
RestartSec=10
User=www-data
WorkingDirectory=/opt/myapp
[Install]
WantedBy=multi-user.target
Docker Swarm
docker swarm init
docker secret create db_password ./deploy/swarm/secrets/db.password
docker stack deploy -c ./deploy/swarm/stack.yml myapp
8. Rotation & incident response
- Dual‑run secrets during rotation: add
_v2secret, roll out, then retire_v1. - Don't reuse secret names in Swarm. New name → new material.
- Kill loggers that spill env or args on startup; mask secrets in app logs.
- Scan repos & images periodically (
gitleaks,trufflehog).
9. Pitfalls I keep seeing
- Putting real secrets in
.envand committing them. Use.env.localand.gitignore. - Passing secrets on the CLI (they end up in shell history and process lists).
- Baking secrets into images with
ARG/ENVin Dockerfile. - Relying on "private" repos as a security boundary.
- Assuming dev overrides are used in prod; keep prod files clean and separate.
10. What to use when (quick compare)
| Scenario | Recommended | Why | Avoid |
|---|---|---|---|
| Local development | .env (non‑secrets) + .env.local (dev‑only secrets), mount secret files read‑only |
Fast iteration; keeps secrets out of images | Committing secrets; global user env vars |
| Prod on Swarm | Docker secrets → files in /run/secrets |
Scoped, not in env output, easy rotation | Plain env vars for confidential data |
| Prod standalone | Locked dir + bind mounts or tmpfs; optional external manager |
Simple, auditable, image stays clean | CLI args; embedding in Dockerfile |
| Highly sensitive keys | tmpfs + short‑lived fetch at start |
Nothing persistent on disk | Long‑lived env variables |
11. References
- Docker — Manage sensitive data with secrets
- Docker Compose — Environment files
- Azure Key Vault — Concepts
- AWS Secrets Manager — Developer Guide
- HashiCorp Vault — Documentation
If you spot a gap or have a war story I should learn from, ping me — I'm happy to refine this guide.
❓ Frequently Asked Questions
What's the difference between Docker secrets and environment variables?
Docker secrets are encrypted at rest and in transit, mounted as files in containers, and only visible to services that need them. Environment variables are less secure but simpler. Use secrets for sensitive data, env vars for configuration.
Should I use .env files in production?
No, .env files are for development only. In production, use actual environment variables, external secret stores (Azure Key Vault, AWS Secrets Manager), or Docker secrets for swarm mode.
How do I rotate secrets in running containers?
For Docker Swarm, update the secret and redeploy services. For standalone containers, update external secret stores and restart containers. Consider using init containers or sidecar patterns for automatic secret rotation.
Can I use Windows containers with Docker secrets?
Docker secrets work with Windows containers in swarm mode, but file mounting behavior differs from Linux. Consider using Windows credential stores or Azure Key Vault integration for Windows-specific solutions.
What's the best practice for database connection strings?
Split sensitive parts (passwords) into secrets and non-sensitive parts (server names) into environment variables. Compose the full connection string in your application code, never store complete connection strings in plain text.