Database passwords, API keys and TLS keys have to reach your containers somehow, and some ways are much safer than others. Secrets baked into images, committed to Git, or visible in docker inspect are common sources of leaks. Here’s how to handle them properly with plain Docker and Compose.
General principles: secrets management.
Where secrets leak
| Mistake | Why it’s bad |
|---|---|
ENV PASSWORD=… or COPY .env in a Dockerfile |
Stored in image layers. Anyone with the image can read it, even if a later layer deletes it |
Secrets in compose.yaml committed to Git |
Git history is forever, and repositories get shared |
Build args for secrets (--build-arg TOKEN=…) |
Visible in image history (docker history) |
| Environment variables | Visible in docker inspect, /proc/<pid>/environ, crash reports, and sometimes logs |
| Logging configuration objects | Apps sometimes print their whole config at startup |
Level 1: .env files (simple, common)
services:
app:
image: myapp:1.8.3
env_file: .env
chmod 600 /opt/app/.env
echo ".env" >> .gitignore
Acceptable for many small deployments, if the file is protected and never committed. Variables remain visible to anyone with Docker access on the host (who effectively has root anyway). See Docker environment variables.
Level 2: Compose secrets (files mounted into the container)
services:
db:
image: postgres:16.4
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
app:
image: myapp:1.8.3
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
- Each secret appears as a file in
/run/secrets/<name>inside the container. It’s not an environment variable, so it doesn’t show up indocker inspect. - Many official images support
*_FILEvariables (POSTGRES_PASSWORD_FILE,MYSQL_ROOT_PASSWORD_FILE, …) to read secrets from files. - Your own app reads the file at startup.
mkdir -p secrets && chmod 700 secrets
openssl rand -base64 32 > secrets/db_password.txt
chmod 600 secrets/*
In Docker Swarm, secrets are stored encrypted in the cluster, and only delivered to services that need them (docker secret create). See Docker Swarm.
Build-time secrets (private packages, tokens)
Never pass tokens as build args. Use BuildKit secret mounts. They’re available only during that RUN step, and not stored in layers:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
docker build --secret id=npm_token,src=$HOME/.npm_token -t myapp .
For private Git repositories, use RUN --mount=type=ssh with docker build --ssh default. See Dockerfile tutorial and multi-stage builds.
Check your images
docker history --no-trunc myapp:1.8.3 | grep -iE 'key|token|pass'
docker run --rm -it myapp:1.8.3 env
Scanners such as Trivy can detect embedded secrets. See Trivy image scanning.
Bigger setups: a secrets manager
For many services or servers, use a dedicated secrets manager (HashiCorp Vault/OpenBao, Infisical, Doppler, cloud secret managers, or Bitwarden Secrets Manager), and inject secrets at deploy time. Kubernetes has its own Secret objects (encrypt them at rest). See Kubernetes ConfigMaps and Secrets.
Rotation
- Use different secrets per environment (staging ≠ production).
- Rotate after staff changes or suspected leaks: update the secret file, then recreate the containers (
docker compose up -d --force-recreate app). - If a secret ever reached Git, assume it’s compromised: rotate it. Rewriting history isn’t enough.
Quick reference
| Need | Approach |
|---|---|
| Simple runtime config | .env with chmod 600, in .gitignore |
| Passwords for official images | Compose secrets: + *_FILE variables |
| Tokens during build | RUN --mount=type=secret |
| Swarm | docker secret create |
| Many services | Secrets manager |
Related: Docker security best practices and Docker Compose in production.