Skip to main content

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:

  1. Build-time secrets (npm tokens, private repo SSH keys, registry credentials): BuildKit secret mounts (--mount=type=secret).
  2. Runtime secrets (DB passwords, API keys): mount as files via Swarm secrets, K8s secrets, or docker run --secret.
  3. 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

bash
docker run -e DB_PASSWORD=hunter2 myapp

Leaks via:

  • docker inspect <container> — full env in plain text.
  • ps auxe on 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

dockerfile
# syntax=docker/dockerfile:1.7 FROM node:22-alpine WORKDIR /app COPY package*.json ./ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci COPY . .
bash
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:

dockerfile
# WRONG: token in image history forever ARG NPM_TOKEN RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc && npm ci

docker history --no-trunc shows the literal RUN line, including the token value.

Runtime secrets via Swarm

bash
# 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 \ myapp

Inside 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:

python
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

yaml
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: true

Many 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

bash
# 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 K8s

AWS Secrets Manager / Parameter Store

python
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

yaml
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_healthy

Vault 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

dockerfile
ARG DB_PASSWORD RUN echo "$DB_PASSWORD" > /etc/myapp/db_password

docker 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

python
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

python
log.info(f"Starting with config: {os.environ}") # Secrets in env now also in logs

Whitelist 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

dockerfile
# syntax=docker/dockerfile:1.7 FROM node:22-alpine WORKDIR /app COPY package*.json ./ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ --mount=type=cache,target=/root/.npm \ npm ci COPY . .
yaml
# 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

yaml
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.txt

App reads the file at startup; postgres image natively understands _FILE. Same secret distributed to two services.

Vault Agent sidecar

yaml
# 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 sidecar

App reads /vault/secrets/db. No Vault SDK in the app code. Vault rotates credentials; agent updates the file in place.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet