← Back to DevBytes

Docker Compose Secrets: Best Practices and Common Pitfalls

Introduction to Docker Compose Secrets

Handling sensitive data like API keys, database passwords, and TLS certificates is one of the trickiest aspects of containerized development. Docker Compose offers a dedicated mechanism for managing secrets that keeps them out of your images, environment variables, and version control. This tutorial walks you through what secrets are, why they matter, how to use them effectively, common mistakes to avoid, and battle-tested best practices for both local development and production.

What Are Secrets in Docker Compose?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Secrets in Docker Compose are a secure way to provide sensitive configuration values to containers without embedding them directly in the Compose file, environment variables, or the container image. When you define a secret, Docker makes it available to the service as a file mounted at /run/secrets/<secret_name>. The container can read the secret from that file, but the secret never appears in docker inspect, shell histories, or environment variable dumps.

Built-in Secret Management vs. File-Based Mounts

It's important to distinguish between two modes of operation:

This tutorial covers both modes, with a focus on practical Compose usage and migration paths to production-grade Swarm secrets.

Why Secrets Matter

The primary reason to use secrets is defense in depth. Secrets help you:

Without secrets, developers often resort to environment variables, which can be trivially exposed by a simple env command inside the container or by printing debugging information. Secrets provide a file-based interface that applications can read once at startup and then clear from memory, reducing the attack surface.

How to Use Secrets in Docker Compose

Declaring Secrets in the Compose File

Secrets are defined under a top-level secrets key, and then referenced by services that need them. Here is a basic example for local development:

version: '3.8'

services:
  web:
    image: nginx:latest
    secrets:
      - db_password
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

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

In this setup, the file ./secrets/db_password.txt on the host is mounted into the container at /run/secrets/db_password. The application can read the file at that path instead of relying on an environment variable.

Mounting Secrets into Services

You can control the target path and permissions with more detailed syntax:

version: '3.8'

services:
  api:
    image: myapp:latest
    secrets:
      - source: db_password
        target: /custom/path/password
        mode: 0400
      - api_key

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

The target changes the mount point inside the container, and mode sets file permissions (octal). Always restrict permissions to read-only for the owner (0400) whenever possible.

Using Secrets in Application Code

Your application should read secrets from files. A common pattern in shell scripts or entrypoints is:

#!/bin/sh
# Read secret and export as environment variable for legacy apps
export DB_PASSWORD=$(cat /run/secrets/db_password)

# Then start the main application
exec "$@"

For a Node.js application, you might do:

const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();

The key principle is: never treat secrets as environment variables directly in the Compose file. Use the secret file and read it inside the container.

Environment Variable Fallacy

A common shortcut is to define secrets via environment variables in the Compose file:

services:
  web:
    environment:
      - DB_PASSWORD=supersecret   # DON'T DO THIS

This is insecure because the value is visible in docker inspect, appears in container environment dumps, and can be leaked in log output or crash reports. Always use the file-based secrets mechanism instead.

Common Pitfalls

1. Embedding Secrets Directly in Environment Variables

As noted above, environment variables are not secrets. Even if you reference an .env file for Compose variable substitution, the resolved values end up in the container's environment and are easily exposed. If you must use environment variables for legacy apps, consider an entrypoint script that reads the secret file and sets the variable, then immediately unsets it after the app starts.

2. Leaking Secrets via Build Args

Build arguments (ARG) are part of the image's build history and can be inspected with docker history. Never pass secrets as build args:

# Dangerous: secret ends up in image layers
ARG DB_PASSWORD=supersecret
RUN echo "use $DB_PASSWORD"

Instead, use multi-stage builds or secret mounts (available with BuildKit) during the build phase, and rely on runtime secrets for final execution.

3. Hardcoding Secret Values in Compose Files

Never write the secret value directly in the Compose file:

secrets:
  db_password:
    # No! This is plain text visible to everyone with access to the Compose file.
    file: ./secrets/db_password.txt
    # content: "supersecret"   # hypothetical but dangerous if supported

The file attribute should point to a path, not contain the secret. The secret file itself must be excluded from version control via .gitignore.

4. Not Cleaning Up Secrets After Use

In Swarm mode, secrets persist until explicitly removed. If you decommission a service, its secrets might still exist on nodes. Regularly prune unused secrets with docker secret rm or automated cleanup scripts.

5. Assuming Secrets Are Encrypted at Rest in Local Compose

Local Compose secrets (file-based) are simply bind mounts. They are not encrypted at rest. Anyone with host filesystem access can read them. For local development this is acceptable as long as you don't commit the files, but never treat them as fully secure in the same way as Swarm secrets.

6. Exposing Secrets to Unnecessary Services

Grant secrets only to the services that absolutely need them. Avoid global secret lists that every service inherits. This limits the blast radius if a service is compromised.

Best Practices

Use External Secret Management for Production

For production deployments on Docker Swarm, use the built-in encrypted secrets:

# Create a secret from standard input
echo "my-production-password" | docker secret create db_password -

# In your Compose file, declare it as external
version: '3.8'
services:
  web:
    image: myapp:prod
    secrets:
      - db_password
secrets:
  db_password:
    external: true

For non-Swarm orchestrators (Kubernetes, Nomad) or cloud platforms, integrate with dedicated secret stores like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. Use init containers or entrypoint scripts to fetch secrets at startup and write them to /run/secrets/ so your application sees a consistent interface.

Restrict Secret Access to Specific Services

In your Compose file, only list secrets under the services that need them:

services:
  frontend:
    image: nginx
    # No secrets needed
  backend:
    image: myapp
    secrets:
      - db_password
      - api_key

This principle of least privilege limits accidental exposure.

Rotate Secrets Regularly

Implement secret rotation by updating the secret in the store and then redeploying services. With Swarm, you can update a secret and then trigger a rolling update of the service:

# Update secret value (creates a new version)
echo "new-password" | docker secret create db_password_v2 -
# Remove old secret later
docker secret rm db_password

Plan for downtime or graceful reload by having your application watch the secret file for changes, or simply restart.

Never Commit Secrets to Version Control

Add secret files to .gitignore and provide template files (e.g., db_password.txt.example) instead. Use a secrets manager or a team-wide tool like sops to encrypt secrets before they touch version control.

# .gitignore
secrets/
*.secret
.env

Use Docker Swarm for Production Secret Security

If you're already using Docker Compose for orchestration, consider migrating to Docker Swarm for built-in encrypted secrets, mutual TLS, and automatic secret distribution. Swarm secrets are encrypted in transit and at rest on the manager nodes, and only decrypted on the worker node that runs the service. This dramatically reduces the risk of secret interception compared to bind mounts.

Validate Secrets in Entrypoint Scripts

Always validate that required secrets exist at container startup, and fail fast with a clear error message:

#!/bin/sh
if [ ! -f /run/secrets/db_password ]; then
  echo "FATAL: db_password secret missing" >&2
  exit 1
fi

This prevents a container from running with a missing secret and producing obscure errors later.

Conclusion

Docker Compose secrets provide a clean, file-based abstraction that helps you keep sensitive data out of images, environment variables, and version control. By understanding the difference between local bind mounts and Swarm's encrypted secrets, avoiding common pitfalls like environment variable leakage and build-arg exposure, and following best practices such as least-privilege access, regular rotation, and external secret store integration, you can significantly improve the security posture of your containerized applications. Start using secrets today—even in local development—to build secure habits that translate seamlessly to production.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles