Docker Secrets Management: Handling Sensitive Data in Containers
Every containerized application needs secrets: database passwords, API keys, TLS certificates, OAuth tokens. How you handle these secrets can be the difference between a secure deployment and a data breach. Yet the most common approach — passing secrets via environment variables — is also one of the least secure.
This guide examines every practical method for managing secrets in Docker, from environment variables (and their risks) to Docker Swarm secrets, external vaults, and runtime injection patterns. You will learn which approach fits your infrastructure, and how to avoid the most common mistakes.
The Problem with Environment Variables
Environment variables are the default way to pass configuration to Docker containers. Every tutorial uses them, and Docker Compose makes them easy:
# docker-compose.yml - The "easy" way
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: super_secret_password_123
This works, but it creates several security problems:
Where Environment Variables Leak
- docker inspect — Anyone with Docker access can run
docker inspect container_nameand see all environment variables in plain text - /proc filesystem — Environment variables are visible in
/proc/[pid]/environinside the container and on the host - Docker logs and error messages — Applications may log environment variables during startup or in error traces
- Docker Compose files in Git — If
.envfiles or Compose files with hardcoded secrets get committed, they are in your Git history forever - Image layers — If secrets are set during build with
ENV, they are baked into the image and visible to anyone who pulls it - Orchestrator UIs — Management dashboards may display environment variables to users who should not see them
# Anyone with docker access can see all env vars
$ docker inspect my-database --format '{{json .Config.Env}}' | jq
[
"POSTGRES_PASSWORD=super_secret_password_123",
"POSTGRES_USER=admin",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
]
# From inside the container
$ cat /proc/1/environ | tr '\0' '\n'
POSTGRES_PASSWORD=super_secret_password_123
POSTGRES_USER=admin
Key insight: Environment variables are not secrets management. They are configuration management. The distinction matters because environment variables were never designed to be secure — they are designed to be visible and accessible.
Docker Compose Secrets (File-Based)
Docker Compose supports a secrets directive that mounts secret files into containers at /run/secrets/. This is more secure than environment variables because secrets are stored in files with restrictive permissions rather than in the process environment:
# Create secret files (not tracked in Git)
echo "super_secret_password_123" > ./secrets/db_password.txt
echo "admin" > ./secrets/db_user.txt
chmod 600 ./secrets/*.txt
# docker-compose.yml
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_USER_FILE: /run/secrets/db_user
secrets:
- db_password
- db_user
app:
image: my-app:latest
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
db_user:
file: ./secrets/db_user.txt
api_key:
file: ./secrets/api_key.txt
Inside the container, secrets appear as files:
# Inside the container
$ cat /run/secrets/db_password
super_secret_password_123
$ ls -la /run/secrets/
-r--r--r-- 1 root root 24 Feb 18 10:00 db_password
-r--r--r-- 1 root root 6 Feb 18 10:00 db_user
Many official Docker images support the _FILE suffix convention (PostgreSQL, MySQL, MariaDB, WordPress). For applications that do not support reading secrets from files natively, you can use an entrypoint script:
#!/bin/sh
# entrypoint.sh - Load secrets from files into env vars at runtime
for secret_file in /run/secrets/*; do
var_name=$(basename "$secret_file")
export "$var_name"="$(cat $secret_file)"
done
# Execute the original command
exec "$@"
Docker Swarm Secrets
Docker Swarm provides a built-in secrets management system with encryption at rest and in transit. Secrets are stored in the Swarm's encrypted Raft log and only distributed to nodes running services that need them:
# Create a secret from a file
echo "super_secret_password_123" | docker secret create db_password -
# Create a secret from a file
docker secret create tls_cert ./server.crt
# List secrets (values are never shown)
$ docker secret ls
ID NAME CREATED UPDATED
j3k4l5m6n7o8p9q0r1s2t3u4 db_password 5 minutes ago 5 minutes ago
a1b2c3d4e5f6g7h8i9j0k1l2 tls_cert 2 minutes ago 2 minutes ago
# Use secrets in a Swarm service
docker service create \
--name web \
--secret db_password \
--secret source=tls_cert,target=/etc/ssl/server.crt,mode=0400 \
my-app:latest
Swarm secrets advantages:
- Encrypted at rest — Stored in the encrypted Raft log on manager nodes
- Encrypted in transit — Transmitted over mutual TLS between nodes
- Need-to-know access — Only containers that explicitly request a secret receive it
- In-memory only — Secrets are mounted as tmpfs inside containers, never written to disk on worker nodes
- Rotation support — Secrets can be rotated by creating a new version and updating the service
# Rotate a secret
echo "new_password_456" | docker secret create db_password_v2 -
docker service update \
--secret-rm db_password \
--secret-add source=db_password_v2,target=db_password \
web
In Docker Compose files for Swarm deployment:
version: "3.8"
services:
web:
image: my-app:latest
secrets:
- db_password
- source: api_key
target: /run/secrets/external_api_key
uid: "1000"
gid: "1000"
mode: 0400
secrets:
db_password:
external: true # Must be created with 'docker secret create' first
api_key:
external: true
HashiCorp Vault
For organizations that need centralized secrets management across multiple applications and environments, HashiCorp Vault is the industry standard. Vault provides dynamic secrets, automatic rotation, audit logging, and fine-grained access policies:
# Deploy Vault as a Docker container (dev mode for testing)
docker run -d \
--name vault \
--cap-add IPC_LOCK \
-p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=myroot \
hashicorp/vault:latest
# Store a secret
export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='myroot'
vault kv put secret/myapp/db \
password="super_secret_password_123" \
username="admin"
# Read a secret
vault kv get -field=password secret/myapp/db
Vault Agent Sidecar Pattern
The recommended pattern for Docker workloads is running Vault Agent as a sidecar that fetches secrets and renders them into files your application can read:
# docker-compose.yml with Vault Agent sidecar
version: "3.8"
services:
vault-agent:
image: hashicorp/vault:latest
command: vault agent -config=/etc/vault/agent.hcl
volumes:
- ./vault-agent.hcl:/etc/vault/agent.hcl:ro
- secrets-volume:/secrets
environment:
VAULT_ADDR: https://vault.example.com:8200
app:
image: my-app:latest
volumes:
- secrets-volume:/run/secrets:ro
depends_on:
- vault-agent
volumes:
secrets-volume:
# vault-agent.hcl
auto_auth {
method "approle" {
config = {
role_id_file_path = "/etc/vault/role-id"
secret_id_file_path = "/etc/vault/secret-id"
}
}
sink "file" {
config = {
path = "/secrets/.vault-token"
}
}
}
template {
source = "/etc/vault/templates/db-creds.tpl"
destination = "/secrets/db_password"
}
template {
source = "/etc/vault/templates/api-key.tpl"
destination = "/secrets/api_key"
}
Vault Dynamic Database Credentials
One of Vault's most powerful features is generating short-lived database credentials on demand:
# Configure Vault's database secrets engine
vault secrets enable database
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db:5432/mydb?sslmode=disable" \
allowed_roles="app-role" \
username="vault_admin" \
password="admin_password"
vault write database/roles/app-role \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# Generate dynamic credentials (valid for 1 hour)
$ vault read database/creds/app-role
Key Value
--- -----
lease_id database/creds/app-role/abc123
lease_duration 1h
username v-app-role-abc12345
password A1B2C3-dynamic-password
SOPS (Secrets OPerationS)
Mozilla SOPS encrypts secret files so they can be safely committed to Git. It supports AWS KMS, GCP KMS, Azure Key Vault, and PGP keys for encryption. This approach lets you version-control your secrets alongside your infrastructure code:
# Install SOPS
# brew install sops (macOS)
# apt install sops (Debian/Ubuntu)
# Create a .sops.yaml to configure encryption rules
cat <<EOF > .sops.yaml
creation_rules:
- path_regex: secrets/.*\.env$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
EOF
# Create and encrypt a secrets file
sops secrets/production.env
# This opens your editor - add your secrets:
# DB_PASSWORD=super_secret_password_123
# API_KEY=sk-abc123def456
# The encrypted file is safe to commit
$ cat secrets/production.env
DB_PASSWORD: ENC[AES256_GCM,data:abc123...,iv:xyz...,tag:...]
API_KEY: ENC[AES256_GCM,data:def456...,iv:xyz...,tag:...]
sops:
age:
- recipient: age1ql3z...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
# Decrypt at deploy time
sops -d secrets/production.env > /tmp/decrypted.env
docker compose --env-file /tmp/decrypted.env up -d
rm /tmp/decrypted.env
SOPS works well for small to medium teams that want to keep secrets in version control without a dedicated secrets server.
Build-Time Secrets
Docker BuildKit introduced secure build-time secrets that are never persisted in image layers:
# Dockerfile using BuildKit secrets
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Mount the secret at build time - it's never stored in a layer
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm install
COPY . .
RUN npm run build
# Build with the secret
DOCKER_BUILDKIT=1 docker build \
--secret id=npm_token,src=./.npmrc-token \
-t my-app:latest .
This is critical for build processes that need to access private registries, download licensed software, or clone private repositories.
Runtime Injection Patterns
For maximum security, secrets should be injected at runtime and never stored on disk (even temporarily). Here are patterns for achieving this:
Init Container Pattern
# docker-compose.yml
services:
init-secrets:
image: vault:latest
command: >
sh -c "vault kv get -format=json secret/myapp |
jq -r '.data.data | to_entries[] | .key + \"=\" + .value' >
/secrets/.env && echo 'Secrets loaded'"
volumes:
- secrets:/secrets
environment:
VAULT_ADDR: https://vault.example.com:8200
VAULT_TOKEN: ${VAULT_TOKEN}
app:
image: my-app:latest
env_file:
- /dev/null # Placeholder
volumes:
- secrets:/run/secrets:ro
depends_on:
init-secrets:
condition: service_completed_successfully
volumes:
secrets:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=1m
Environment Variable Substitution at Runtime
#!/bin/sh
# entrypoint.sh - Substitute secrets at runtime
set -e
# Read secrets from files and export as env vars
if [ -d /run/secrets ]; then
for secret in /run/secrets/*; do
var=$(basename "$secret" | tr '[:lower:]' '[:upper:]')
export "$var"="$(cat $secret)"
echo "Loaded secret: $var"
done
fi
# Substitute environment variables in config templates
envsubst < /etc/app/config.template.yml > /etc/app/config.yml
# Drop privileges and run the application
exec su-exec appuser "$@"
Secrets Management Comparison
| Method | Security Level | Complexity | Best For |
|---|---|---|---|
| Environment variables | Low | Minimal | Development only |
| .env files (not in Git) | Low-Medium | Low | Small deployments, single developer |
| Compose file-based secrets | Medium | Low | Single-host production |
| Docker Swarm secrets | High | Medium | Swarm-based production |
| SOPS encrypted files | Medium-High | Medium | GitOps workflows |
| HashiCorp Vault | Very High | High | Enterprise, dynamic secrets |
| Cloud KMS (AWS, GCP, Azure) | Very High | Medium | Cloud-native deployments |
Best Practices Checklist
- Never commit secrets to Git — Add
.env,*.key,*.pem, and secrets directories to.gitignore - Never use ENV in Dockerfiles for secrets — Use BuildKit
--mount=type=secretinstead - Prefer file-based secrets over environment variables — Files can have restrictive permissions and are not exposed through
docker inspect - Rotate secrets regularly — Automate rotation with Vault or cloud KMS
- Audit secret access — Enable logging for who accessed what secret and when
- Use least-privilege access — Each service should only have access to the secrets it needs
- Encrypt secrets at rest — Even on the host filesystem, secrets should be encrypted
- Use tmpfs for secret storage — Secrets mounted as tmpfs are never written to disk
Conclusion
Secrets management in Docker is not a single tool decision — it is an architectural choice that depends on your team size, compliance requirements, and infrastructure complexity. For most self-hosted Docker deployments, start with file-based secrets in Docker Compose and graduate to Docker Swarm secrets or Vault as your needs grow.
The most important takeaway is this: if you are passing database passwords as environment variables in production, you have a security gap. It might never be exploited, but it violates the principle of defense in depth. File-based secrets are trivially easy to implement and eliminate the most common exposure vectors. Start there, and build up to more sophisticated solutions as your infrastructure demands.