Docker Secrets vs .env Files: Secure Configuration Management

A comprehensive comparison of Docker secrets and traditional .env files for managing sensitive configuration. Learn why Docker secrets provide better security, how to migrate from .env files, and best practices for production deployments.

Managing sensitive configuration — database passwords, API keys, TLS certificates — is one of the most critical aspects of containerized applications. Most developers start with .env files, but Docker provides a built-in secrets mechanism that is fundamentally more secure.

This article explains why Docker secrets exist, how they work, and when you should use them over traditional .env files.

The Problem with .env Files

The .env file approach is simple: define key-value pairs and reference them in your docker-compose.yml:

# .env
POSTGRES_PASSWORD=super_secret_123
API_KEY=sk-abc123def456
REDIS_PASSWORD=redis_pass_789
# docker-compose.yml
services:
  app:
    image: myapp
    env_file:
      - .env

This works, but has serious security issues:

1. Environment variables are visible everywhere

Anyone who can run docker inspect on your container sees every secret in plain text:

docker inspect my_container --format '{{json .Config.Env}}'
# Output: ["POSTGRES_PASSWORD=super_secret_123", "API_KEY=sk-abc123def456", ...]

They also appear in /proc/<pid>/environ inside the container, in docker-compose config output, and in any child process that inherits the environment.

2. Logging and debugging often leak secrets

Crash reports, debugging tools, and monitoring agents frequently dump environment variables. A single env command or stack trace can expose everything.

3. .env files are easy to commit accidentally

Despite .gitignore rules, .env files get committed to version control regularly. Once in git history, the secret is exposed permanently — even if you delete the file later.

4. No access control

Every service in your Docker Compose stack that uses env_file gets all the secrets, even if it only needs one. There is no way to restrict which service sees which secret.

How Docker Secrets Work

Docker secrets provide an encrypted, access-controlled mechanism for distributing sensitive data to containers.

Key principles:

  • Secrets are stored encrypted at rest (in Docker Swarm's Raft log)
  • They are mounted as files inside the container at /run/secrets/<secret_name>
  • They are only available to services that are explicitly granted access
  • They never appear in docker inspect, environment variables, or logs
  • They exist only in memory (tmpfs) inside the container — never written to disk

Docker Secrets in Docker Compose (v3.1+)

You don't need a full Swarm cluster. Docker Compose supports secrets with file-based sources:

# docker-compose.yml
version: "3.8"

services:
  db:
    image: postgres:15
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

  app:
    image: myapp
    secrets:
      - db_password
      - api_key
    # Read secrets in your code from /run/secrets/

  redis:
    image: redis:7
    # redis does NOT have access to db_password or api_key

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

Create the secret files:

mkdir -p secrets
echo "super_secret_123" > secrets/db_password.txt
echo "sk-abc123def456" > secrets/api_key.txt
chmod 600 secrets/*.txt

Reading Secrets in Your Application

Inside the container, secrets appear as plain files:

# Inside the container
cat /run/secrets/db_password
# Output: super_secret_123

Python example:

import os

def read_secret(name, default=None):
    """Read a Docker secret from /run/secrets/ or fall back to env var."""
    secret_path = f"/run/secrets/{name}"
    if os.path.exists(secret_path):
        with open(secret_path, "r") as f:
            return f.read().strip()
    return os.environ.get(name.upper(), default)

# Usage
db_password = read_secret("db_password")
api_key = read_secret("api_key")

Node.js example:

const fs = require('fs');
const path = require('path');

function readSecret(name, fallback) {
    const secretPath = path.join('/run/secrets', name);
    try {
        return fs.readFileSync(secretPath, 'utf8').trim();
    } catch {
        return process.env[name.toUpperCase()] || fallback;
    }
}

const dbPassword = readSecret('db_password');

This pattern lets your code work both with Docker secrets (production) and environment variables (local development).

The _FILE Convention

Many official Docker images (PostgreSQL, MySQL, MariaDB, Redis) support the _FILE suffix convention. Instead of passing the secret as an environment variable, you pass the path to the secret file:

services:
  db:
    image: postgres:15
    secrets:
      - db_password
    environment:
      # Instead of: POSTGRES_PASSWORD=super_secret_123
      # Use:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

The image's entrypoint script reads the file and sets the real environment variable internally — the secret never touches the container's environment.

Comparison: .env Files vs Docker Secrets

Criteria.env FilesDocker Secrets
Storage at restPlain text on diskEncrypted (Swarm) or file-based (Compose)
Runtime exposureVisible in docker inspect, /proc/environ, logsMounted as in-memory file at /run/secrets/ only
Access controlAll services see all variablesPer-service: only granted services can read
Accidental git commitHigh risk (.env in project root)Lower risk (separate secrets/ dir, easier to gitignore)
docker inspect leaksYes - all env vars visibleNo - secrets not in inspect output
Child process inheritanceYes - all child processes inherit envNo - must explicitly read the file
RotationRequires container restartCan update secret file, container reads on demand
Multi-line valuesProblematic (newlines in env vars)Easy (TLS certs, SSH keys as files)
Setup complexitySimple - one fileSlightly more - separate files + compose config
Local developmentVery easyWorks, but slightly more setup
Production suitabilityNot recommended for sensitive dataRecommended

A Practical Migration Example

Let's convert a typical .env-based setup to Docker secrets.

Before (using .env):

# .env
POSTGRES_PASSWORD=prod_db_pass_2024
REDIS_PASSWORD=prod_redis_pass
SECRET_KEY=flask_secret_key_abc123
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG
# docker-compose.yml
services:
  flask:
    build: .
    env_file: .env

  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

  redis:
    image: redis:7
    command: redis-server --requirepass ${REDIS_PASSWORD}

After (using Docker secrets):

# docker-compose.yml
version: "3.8"

services:
  flask:
    build: .
    secrets:
      - db_password
      - redis_password
      - flask_secret_key
      - aws_secret_key
    environment:
      # Non-sensitive config stays as env vars
      FLASK_ENV: production
      POSTGRES_HOST: postgres
      REDIS_HOST: redis

  postgres:
    image: postgres:15
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

  redis:
    image: redis:7
    secrets:
      - redis_password
    command: >
      sh -c "redis-server --requirepass $$(cat /run/secrets/redis_password)"

secrets:
  db_password:
    file: ./secrets/db_password.txt
  redis_password:
    file: ./secrets/redis_password.txt
  flask_secret_key:
    file: ./secrets/flask_secret_key.txt
  aws_secret_key:
    file: ./secrets/aws_secret_key.txt

Notice that:

  • Non-sensitive config (FLASK_ENV, POSTGRES_HOST) stays as regular environment variables
  • Sensitive data (passwords, keys) moves to secrets
  • PostgreSQL uses the built-in _FILE convention
  • Redis reads the secret file in its command
  • Flask reads secrets via the read_secret() helper function

When to Use What

Use .env files for:

  • Local development and quick prototyping
  • Non-sensitive configuration (feature flags, port numbers, hostnames)
  • Variables that are intentionally public (e.g., NODE_ENV=production)

Use Docker secrets for:

  • Database passwords
  • API keys and tokens
  • TLS/SSL certificates and private keys
  • SSH keys
  • Any credential that would cause damage if leaked

The hybrid approach works best in practice:

services:
  app:
    env_file:
      - .env              # Non-sensitive: ports, hosts, feature flags
    secrets:
      - db_password       # Sensitive: passwords, keys, certs
      - api_key

Best Practices

1. Never store secrets in your Docker image

# BAD - secret baked into image layer
ENV API_KEY=sk-abc123
COPY .env /app/.env

# GOOD - secret injected at runtime via mount
# No secrets in Dockerfile at all

2. Use .dockerignore to prevent accidental inclusion

# .dockerignore
.env
secrets/
*.pem
*.key

3. Set proper file permissions on secret files

chmod 600 secrets/*.txt
chown root:root secrets/*.txt

4. Add secrets directory to .gitignore

# .gitignore
.env
secrets/

5. Use a secrets manager for larger deployments

For production at scale, consider:

  • HashiCorp Vault — full-featured secrets management
  • AWS Secrets Manager / Azure Key Vault / GCP Secret Manager — cloud-native
  • SOPS (Secrets OPerationS) — encrypts secrets in git

These integrate with Docker secrets or replace them entirely.

Summary

.env files are convenient but fundamentally insecure for sensitive data. Docker secrets provide a purpose-built mechanism that keeps credentials out of environment variables, docker inspect output, logs, and child processes.

The migration is straightforward: move sensitive values to secret files, update your docker-compose.yml to mount them, and modify your application to read from /run/secrets/. Keep non-sensitive config in environment variables.

For any production deployment handling real user data, Docker secrets (or an external secrets manager) should be the default choice.

Docker Secrets vs .env Files: Secure Configuration Management | Software Engineer Blog