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_name and see all environment variables in plain text
  • /proc filesystem — Environment variables are visible in /proc/[pid]/environ inside 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 .env files 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

  1. Never commit secrets to Git — Add .env, *.key, *.pem, and secrets directories to .gitignore
  2. Never use ENV in Dockerfiles for secrets — Use BuildKit --mount=type=secret instead
  3. Prefer file-based secrets over environment variables — Files can have restrictive permissions and are not exposed through docker inspect
  4. Rotate secrets regularly — Automate rotation with Vault or cloud KMS
  5. Audit secret access — Enable logging for who accessed what secret and when
  6. Use least-privilege access — Each service should only have access to the secrets it needs
  7. Encrypt secrets at rest — Even on the host filesystem, secrets should be encrypted
  8. Use tmpfs for secret storage — Secrets mounted as tmpfs are never written to disk
Tip: When managing secrets through usulnet, you can configure environment variables and file-based secrets for containers and stacks directly in the UI. For production deployments, combine usulnet's container management with an external secrets provider like Vault for the strongest security posture.

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.