Secrets in Docker: Stop Hardcoding Them in Your Compose File
by Arif Ikhsanudin, Backend Developer
The password that's been in your git history for two years
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: correcthorsebatterystaple
This line, committed to your git repository at some point, is now in the git history permanently. Even if you delete it from the current file, git log -p reveals it. If the repository was ever public, or if anyone with access to the repo has read the password from the history, it's been potentially exposed.
This pattern is in the majority of Docker Compose tutorials. It makes sense for learning — environment variable injection is straightforward to demonstrate. For production, any service with real credentials configured this way has a secret management problem.
The threat model
Before choosing a solution, be clear about what you're protecting against:
- Repository access: anyone who can read the repository can read secrets committed to it, including historical commits
- Log access: environment variables are sometimes logged by applications, CI systems, or orchestrators — plaintext values in env vars are at risk
- Container inspection:
docker inspecton a running container shows environment variables in plaintext — anyone with Docker host access can read them - Image inspection: secrets baked into images via
ENVorARGare readable from the image
A .env file that's gitignored addresses threat 1. It doesn't address threats 2–4. A proper secrets manager addresses all of them.
Layer 1: .env files (minimum viable)
At minimum, remove secrets from the Compose file and put them in a .env file that's gitignored:
# .gitignore
.env
.env.*
!.env.example
# .env — gitignored
DB_PASSWORD=actual_production_password
JWT_SECRET=actual_jwt_secret
# docker-compose.yml — committed
services:
db:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
Provide a committed .env.example that documents required variables without values:
# .env.example — committed, copy to .env and fill in
DB_PASSWORD=
JWT_SECRET=
API_KEY=
This stops the secret from being in git history. It doesn't stop someone with host access from reading it via docker inspect, and it doesn't help if .env is accidentally committed.
Layer 2: Docker Compose secrets
Compose has a native secrets mechanism that mounts secrets as files inside the container rather than as environment variables:
services:
app:
secrets:
- db_password
- jwt_secret
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
The secret file is mounted at /run/secrets/db_password inside the container. Your application reads the file rather than an environment variable.
This requires application-side support — your app must be able to read the secret from a file path rather than an environment variable. Many applications support a _FILE suffix convention (PostgreSQL client libraries, some Spring Boot configurations), or you can add file reading at startup:
// Node.js — read secret from file if *_FILE env var is set
function readSecret(key) {
const fileKey = `${key}_FILE`;
if (process.env[fileKey]) {
return require('fs').readFileSync(process.env[fileKey], 'utf8').trim();
}
return process.env[key];
}
const dbPassword = readSecret('DB_PASSWORD');
The advantage: secrets don't appear in docker inspect output as environment variables. The disadvantage: secrets are still on the host filesystem in plaintext files, just in a different location.
Layer 3: runtime secrets manager
For production, secrets should come from a secrets manager, not from files on the host:
HashiCorp Vault: inject secrets at container start via the Vault Agent sidecar or Vault's envconsul, which reads secrets from Vault and writes them to environment variables or files before launching the application.
AWS Secrets Manager / Parameter Store: retrieve secrets at startup using the AWS SDK or aws-env (a small tool that reads SSM parameters and sets them as environment variables):
FROM your-app-base
# aws-env reads SSM parameters and exec's into the app with them set
COPY --from=ghcr.io/remind101/ssm-env:latest /ssm-env /usr/local/bin/ssm-env
ENTRYPOINT ["ssm-env", "-with-decryption"]
CMD ["java", "-jar", "app.jar"]
Kubernetes Secrets + external-secrets-operator: Kubernetes Secrets are base64-encoded (not encrypted) by default but can be encrypted at rest with KMS. The external-secrets-operator synchronizes secrets from AWS Secrets Manager, GCP Secret Manager, or Vault into Kubernetes Secrets, which are mounted into pods:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: app-secrets
data:
- secretKey: db-password
remoteRef:
key: production/app/db-password
The resulting Kubernetes Secret is mounted as a file or environment variable in the pod. The plaintext secret never touches the CI pipeline or the host filesystem.
What NOT to do
Don't use Docker build args for secrets:
ARG DB_PASSWORD # bad — visible in docker history, in the image layers
ENV DB_PASSWORD=$DB_PASSWORD
docker history --no-trunc your-image reveals build args. Even if you delete the ENV line, the ARG value may persist in the build layer.
The correct alternative for secrets needed during build (e.g., to access a private package registry): use BuildKit's --secret flag:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
docker buildx build --secret id=npm_token,src=.npm_token .
The secret is available during the build step but is not stored in any layer.
Don't print environment variables in logs:
// Don't log all environment variables at startup
System.getenv().forEach((k, v) -> log.info("{} = {}", k, v));
Even if secrets are properly managed, logging them defeats the purpose. Log only configuration keys, not values.
The practical path
If you have hardcoded secrets in Compose files right now:
- Move them to a
.envfile, add.envto.gitignore— do this today - Rotate any secrets that were ever committed to git history — assume they're compromised
- Move toward a secrets manager for production environments — do this before the next security audit asks you to
The .env file is not a final solution — it's an improvement over committed secrets. Plan your path to runtime secret injection before it becomes a compliance requirement rather than a voluntary improvement.