How to manage secrets (passwords, keys) in Docker properly?
Managing secrets in Docker is a layered problem: build-time secrets, runtime secrets, and "how do I never type a password into a YAML file". Each layer has the right tool; using the wrong tool is how passwords end up on Pastebin.
Theory
TL;DR
Three distinct problems, three answers:
- Build-time secrets (npm tokens, private repo SSH keys, registry credentials): BuildKit secret mounts (
--mount=type=secret). - Runtime secrets (DB passwords, API keys): mount as files via Swarm secrets, K8s secrets, or
docker run --secret. - Single source of truth at scale: external secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault). Containers fetch at startup or via sidecar.
Forbidden patterns: secrets in ENV, secrets in --build-arg, secrets baked into image layers.
Why ENV is wrong
docker run -e DB_PASSWORD=hunter2 myappLeaks via:
docker inspect <container>— full env in plain text.ps auxeon the host — anyone with shell access.- Container's own
/proc/1/environ— readable from inside. - App startup logs — many frameworks log full env.
- Process listings inside the container.
- Crash dumps — env may end up in core files.
Go through any of these and the password is sitting there.
Build-time secrets via BuildKit
# syntax=docker/dockerfile:1.7
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN \
npm ci
COPY . .docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .The .npmrc is mounted into the RUN step but never lands in any layer. docker history shows no trace.
Compare with the bad pattern:
# WRONG: token in image history forever
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc && npm cidocker history --no-trunc shows the literal RUN line, including the token value.
Runtime secrets via Swarm
# Create the secret (one-time)
echo "hunter2" | docker secret create db_password -
# Reference it in a service
docker service create \
--name api \
--secret db_password \
myappInside the container:
/run/secrets/db_password # contents: hunter2
The secret is mounted as a file at /run/secrets/<name> on a tmpfs (RAM-only, never on disk). The app reads the file at startup:
with open('/run/secrets/db_password') as f:
password = f.read().strip()No env var, no inspectable string. Inside the container, only the running process can read the tmpfs file.
Compose with secrets
version: '3.9'
services:
api:
image: myapp
secrets:
- db_password
- api_key
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # postgres image supports _FILE pattern
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
external: trueMany official images (postgres, mysql, mariadb) support the _FILE env-var convention: POSTGRES_PASSWORD_FILE=/run/secrets/db_password instead of POSTGRES_PASSWORD=.... Use this pattern.
Production: external secret managers
At scale, none of "file in repo" or "docker secret create from CLI" survive. The answer is a real secret manager:
HashiCorp Vault
# App fetches at startup using a Vault sidecar or built-in SDK
vault read secret/data/db/password
# OR use the agent-injector pattern in K8sAWS Secrets Manager / Parameter Store
import boto3
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId='prod/db/password')
password = response['SecretString']GCP Secret Manager / Azure Key Vault — similar APIs.
The app authenticates with workload identity (IAM role, service account) — no static credentials anywhere except the cloud's IAM.
Sidecar / init pattern
services:
vault-init:
image: vault
command: ["vault", "agent", "-config=/etc/vault/agent.hcl"]
volumes:
- vault-secrets:/secrets
api:
image: myapp
volumes:
- vault-secrets:/secrets:ro
depends_on:
vault-init:
condition: service_healthyVault Agent fetches secrets, writes to a tmpfs volume. App reads from /secrets/. The app never authenticates to Vault directly.
Common mistakes
Secrets in env
Covered. Most common, most leaked.
Build-args used as secrets
ARG DB_PASSWORD
RUN echo "$DB_PASSWORD" > /etc/myapp/db_passworddocker history shows the build-arg value forever. Layer contents include the file. Two leaks for the price of one.
Secrets committed to git as secrets.yaml
Classic mistake. Even if you remove the file later, git history has it. Use git-secrets, gitleaks, or pre-commit hooks to prevent commits, not just clean up after.
Decrypting at startup with a hardcoded key
encrypted_password = config['DB_PASSWORD']
key = 'this-is-the-key' # hardcoded → not actually a secret
password = decrypt(encrypted_password, key)If the key is in the image, the encryption is theatre. The right pattern: key comes from the runtime environment (KMS, IAM, sidecar), encrypted blob at rest is fine.
Logging the env at startup
log.info(f"Starting with config: {os.environ}")
# Secrets in env now also in logsWhitelist log fields, never blanket-log env. Use a sanitizer that filters keys matching *PASSWORD*, *TOKEN*, *KEY*.
Real-world architectures
Small team / single host
- Build secrets: BuildKit secret mounts.
- Runtime: Compose
secrets:with files on disk, gitignored. - Acceptable for staging; risky for prod (depends on threat model).
Medium team / Swarm
- Build secrets: BuildKit, fed from CI.
- Runtime: Swarm secrets (
docker secret create). - Audit: Docker secret CLI shows what exists; rotate by recreating.
Production / Kubernetes / multi-cluster
- Vault or cloud-native secret manager.
- App fetches at startup via SDK with workload-identity auth.
- Or Vault Agent sidecar that writes to tmpfs.
- Auto-rotation: short-lived dynamic credentials (Vault DB engine).
Follow-up questions
Q: Are Docker Swarm secrets encrypted at rest?
A: Yes, in the raft store on managers. They are decrypted at use time and mounted as tmpfs (in RAM, no disk write inside the container).
Q: Can I rotate a Swarm secret?
A: Not in-place. Create db_password_v2, update the service to use the new one, delete the old. Most apps need to be restarted to pick up new secrets — design for it.
Q: What is the _FILE pattern?
A: Convention popularized by official Docker images: instead of MY_VAR=value, accept MY_VAR_FILE=/path/to/file and read the value from the file. Postgres, MySQL, MariaDB, RabbitMQ all support it. The file can be a Swarm secret mount.
Q: How do I handle secrets in CI?
A: CI's secret store (GitHub Actions secrets, GitLab masked variables, AWS Secrets Manager pulled by an OIDC role). Pass to BuildKit via --secret. Never commit, never log.
Q: (Senior) How would you design secret management for a multi-team, multi-cluster production environment?
A: Vault as the source of truth. Each service's CI pipeline writes its secrets via Vault's API at deploy time. Each cluster has a Vault Agent sidecar pattern (or external-secrets-operator on K8s). Apps authenticate via workload identity (K8s service account, IAM role, AppRole). Secret rotation: dynamic engine (DB credentials regenerated each request, lifespan minutes). Audit: every secret read logged in Vault's audit log. Recovery: Vault unseal procedure documented, key shares distributed via Shamir's Secret Sharing. The point: developers never see a static password; CI/CD never sees one in plaintext outside Vault; nothing in any container's image or env is sensitive.
Examples
BuildKit secret for npmrc
# syntax=docker/dockerfile:1.7
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN \
npm ci
COPY . .# GitHub Actions
- uses: docker/build-push-action@v5
with:
secrets: |
npmrc=${{ secrets.NPMRC }}No .npmrc in the image history; cache mount keeps installs fast.
Compose with _FILE pattern
version: '3.9'
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
api:
image: myapp
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password.txtApp reads the file at startup; postgres image natively understands _FILE. Same secret distributed to two services.
Vault Agent sidecar
# K8s pod with vault-agent sidecar
apiVersion: v1
kind: Pod
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "myapp"
vault.hashicorp.com/agent-inject-secret-db: "secret/data/myapp/db"
spec:
containers:
- name: app
image: myapp
# /vault/secrets/db is created by the injected agent sidecarApp reads /vault/secrets/db. No Vault SDK in the app code. Vault rotates credentials; agent updates the file in place.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet