Environment variables in Docker containers are visible in plain text via docker inspect — anyone with access to the Docker daemon or the container runtime can read every environment variable, including database passwords, API keys, and JWT secrets. A 2023 GitGuardian study found that over 10 million secrets were leaked on GitHub in that year alone, with hardcoded environment variables and Docker configs among the top sources. At Commsult Indonesia, after auditing our early Docker configurations and finding plaintext credentials in docker inspect output and CI/CD logs, we overhauled our secrets management entirely.
Passing secrets as environment variables has multiple exposure vectors: docker inspect shows all environment variables in plaintext, environment variables are visible in /proc/1/environ inside the container (world-readable by default on some systems), docker run commands with secrets appear in shell history, and CI/CD pipeline logs can capture environment variable values on echo or debug commands. The Docker documentation itself notes that environment variables are not appropriate for sensitive data — they are convenient but not secure.
Docker Swarm has a built-in secrets mechanism: docker secret create stores a secret encrypted at rest in the Swarm Raft log, accessible only to services that explicitly request it. Secrets are mounted as files in /run/secrets/ inside the container using a memory-backed filesystem (tmpfs) that disappears when the container stops. The secret is never stored on the container filesystem or visible in docker inspect. If you are running Docker Swarm, use native secrets for all sensitive values.
Docker Compose also supports secrets via the secrets key in docker-compose.yml. You define secret sources (files or external Swarm secrets) and mount them to specific services. For development, use file-based secrets (a local file not in git). For production, use the external keyword to reference Swarm secrets. This approach gives you the same /run/secrets/ mount behavior without the full Swarm orchestration overhead. Applications read secrets by reading the file at /run/secrets/secret_name rather than reading an environment variable.
┌─────────────────────────────────────────────────────┐
│ SECRETS EXPOSURE COMPARISON │
└─────────────────────────────────────────────────────┘
ENV VAR approach (INSECURE):
docker run -e DB_PASS=secret123 ...
docker inspect container → plaintext visible
/proc/1/environ → plaintext visible
CI/CD debug logs → plaintext leaks
Docker Secrets approach (SECURE):
docker secret create db_pass /run/secrets/db_pass
Encrypted in Swarm Raft log
Mounted as tmpfs: /run/secrets/db_pass
docker inspect → NOT visible
Container reads: fs.readFileSync('/run/secrets/db_pass')
SOPS + age (GitOps-friendly):
secrets.enc.yaml ──(age decrypt)──► .env (CI only)
Encrypted in git Never written to diskFrom my experience securing production NestJS deployments at Commsult Indonesia, the most pragmatic approach for a Docker Compose setup without Swarm is Mozilla SOPS with age encryption. Store an encrypted secrets file in your git repository (safe because it is encrypted), decrypt it to a .env file at deployment time in your CI/CD pipeline using a deploy key stored in GitHub Actions secrets or GitLab CI variables. The plaintext .env file exists only in memory during deployment and is never written to disk. This gives you GitOps-friendly secret management without HashiCorp Vault complexity.
The most universally applicable secrets pattern is file-based: store secrets as files with restrictive permissions (600, owned by root or the service user), and have applications read the secret file rather than an environment variable. Most client libraries support this: Node.js can read a file with fs.readFileSync. Store secret files in /run/secrets/ (tmpfs, disappears on reboot) or /etc/myapp/secrets/ (persistent, appropriate for long-lived secrets). Never store secret files in the application directory or any git-tracked location.
Build-time secrets (npm registry tokens, private package credentials) require special handling. Never use ARG or ENV in Dockerfiles for secrets — they are baked into the image layers and visible in docker history. Use Docker BuildKit secret mounts: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install. The secret is available only during that RUN command and is not stored in any image layer. Enable BuildKit with DOCKER_BUILDKIT=1 docker build or configure it as the default in /etc/docker/daemon.json.
# docker-compose.yml with secrets
services:
app:
image: myapp:latest
secrets:
- db_password
- jwt_secret
environment:
NODE_ENV: production
DB_HOST: db
secrets:
db_password:
file: ./secrets/db_password.txt # dev
# external: true # prod (Swarm)
jwt_secret:
file: ./secrets/jwt_secret.txt
# In Node.js, read secret from file
const dbPass = require('fs').readFileSync('/run/secrets/db_password', 'utf8').trim()
# Dockerfile BuildKit secret (build-time)
# RUN --mount=type=secret,id=npmrc,target=/root/.npmrc # npm install
# Audit current exposure
docker inspect $(docker ps -q) | grep -i -E "password|secret|key|token"
# Scan git history for leaked secrets
trufflehog git file://. --only-verifiedFor larger teams or compliance-sensitive environments, use a dedicated secret manager: HashiCorp Vault (self-hosted, open source, feature-rich), AWS Secrets Manager (managed, integrates with ECS and EKS), GCP Secret Manager (managed, integrates with Cloud Run and GKE), or Infisical (open source, Vault-alternative with a better UX). These tools provide: access control (only specific services can read specific secrets), audit logging (who accessed which secret and when), automatic rotation, and versioning.
I have seen teams use docker-compose.override.yml for local secrets, gitignore it, and then accidentally commit it when the gitignore entry is removed or the file is renamed. Even one committed secrets file can be a permanent leak — git history preserves it even after deletion. Use a separate secrets file that is explicitly gitignored, document required variables in a .env.example file with placeholder values, and enforce pre-commit hooks that scan for credential patterns before allowing commits.
At Commsult Indonesia, our production secrets stack: SOPS with age encryption for encrypting secrets in git (key stored in 1Password and CI/CD platform secrets), Docker Compose secrets for runtime secret injection (secret files mounted at /run/secrets/), and separate .env files per environment (generated by SOPS decrypt in CI/CD, never committed). For GCP Cloud Run services, we use GCP Secret Manager directly — Cloud Run has native Secret Manager integration that mounts secrets as environment variables or files without any custom tooling.
Before building a better secrets solution, audit what is already exposed. Run docker inspect against running containers and grep for password, secret, key, and token patterns to check for secrets in environment variables. Review your docker-compose.yml files for hardcoded secrets. Check your git history for accidentally committed .env files. Use tools like Trufflehog or GitLeaks to scan your entire git history for credential patterns. Fix the leaks you find before adding new secret infrastructure.