Guide
Docker Compose Secrets Without Leaking Credentials
Mount credentials as read-only files, keep their source files outside Git, and use an external secret manager when local host access is in scope.
What protection do Compose secrets actually provide?
They prevent casual exposure.
Docker Compose secrets mount sensitive values as files inside selected containers, but they do not encrypt the source file or protect credentials from an administrator controlling the Docker host.
That distinction matters. An environment variable can appear in process configuration, debugging output, crash reports, shell history, and container inspection data. A mounted secret stays out of the container's environment and limits access to services explicitly granted the secret.
Compose normally exposes the value at /run/secrets/<secret_name>. The mount is read-only inside the container, and the application opens it only when needed. Docker documents this service-level grant and default path in its Compose secrets procedure.
The source remains ordinary data on the host. If ./secrets/db_password.txt is readable by another local account, included in a backup, copied into a support archive, or committed to Git, Compose cannot repair that mistake.
Use this threat model:
- Compose secrets reduce accidental disclosure through environment variables and broad service configuration.
- Host filesystem permissions still control who can read local secret files.
- Root, Docker daemon administrators, and sufficiently privileged containers remain trusted.
- Rotation is a management task; changing the file does not guarantee a running application reloads it.
- An external secret store is required when audit logs, central revocation, encryption policy, or short-lived credentials are part of the requirement.
How do you create and mount a local secret?
Create the source file.
Then declare it under the top-level secrets key, grant it only to the service that needs access, and verify that the container receives a read-only file under /run/secrets.
- Create a directory that will never enter the repository.
- Restrict access before adding sensitive data.
- Write the credential without putting it directly on the command line.
- Reference that file from
compose.yaml. - Start the service and verify the mount.
install -d -m 700 "$HOME/.config/example-secrets"
umask 077
printf 'Database password: ' >&2
IFS= read -r DB_PASSWORD
printf '%s' "$DB_PASSWORD" > "$HOME/.config/example-secrets/db_password"
unset DB_PASSWORDThe hidden input behavior varies by shell, so use read -s in Bash if shoulder surfing is a concern. Avoid echo 'password' > file, because the complete command may be retained in shell history.
Create compose.yaml:
services:
api:
image: example/api:1.4
secrets:
- db_password
secrets:
db_password:
file: ${HOME}/.config/example-secrets/db_passwordRun docker compose up -d, then check only the metadata rather than printing the credential:
docker compose exec api sh -c 'test -r /run/secrets/db_password && test ! -w /run/secrets/db_password'A zero exit status means the file is readable and not writable from that container process. If the service reports secret file ... does not exist, check the expanded path with docker compose config, but do not paste that output into a ticket until confirming it contains no interpolated credentials.
How should the application read the secret file?
Read it at runtime.
The application should open the mounted file, remove only the expected trailing newline, avoid logging the resulting value, and fail with a specific configuration error when the file is absent.
Many official container images support the _FILE convention. Instead of setting MYSQL_ROOT_PASSWORD, for example, configuration can point MYSQL_ROOT_PASSWORD_FILE at /run/secrets/mysql_root_password; support is application-specific, not a function automatically supplied by Compose.
For an application you control, keep the file path configurable:
from pathlib import Path
secret_path = Path("/run/secrets/db_password")
try:
db_password = secret_path.read_text(encoding="utf-8").rstrip("\n")
except FileNotFoundError as error:
raise RuntimeError(f"Missing secret file: {secret_path}") from error
if not db_password:
raise RuntimeError("Database password is empty")Do not call .strip() unless spaces are forbidden by the credential format. It removes leading and trailing whitespace that may be part of an API key or generated password.
Also decide how rotation works. A process that reads the file once during startup usually needs a restart after the source changes, while a process that reopens it per connection may pick up a replacement without restarting. Atomic replacement and file-watching behavior differ across applications, so document the specific reload signal or restart command rather than promising live rotation.
Why not pass credentials through environment variables?
Environment variables spread farther.
They remain useful for non-sensitive configuration, but passwords and API keys should move to secret files whenever the image supports file-based input and the deployment does not require environment-only configuration.
A Compose .env file is an interpolation mechanism, not a vault. Likewise, an env_file loads values into a container's environment; renaming a password file or adding it to .gitignore does not change how the value is exposed at runtime.
A practical migration looks like this:
- Replace
DB_PASSWORD=${DB_PASSWORD}with a service secret grant. - Add
DB_PASSWORD_FILE=/run/secrets/db_passwordonly if the application recognizes that variable. - Otherwise, change the application configuration to accept a file path.
- Remove the old value from
.env, CI variables, local shell profiles, and copied example files. - Rotate the credential if it previously entered Git, logs, or an issue tracker.
Check the rendered model with docker compose config, but treat its output as sensitive while old environment substitutions remain. Then inspect the running container's configured environment names:
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' example-api-1The password value should be absent. A _FILE variable containing a path is expected, since the path itself is configuration rather than the credential.
How do you keep build credentials out of an image?
Use BuildKit secret mounts.
Runtime secrets do not protect package tokens needed during docker build, so pass those credentials with a build secret and consume them in a single RUN --mount=type=secret instruction.
Never use ARG NPM_TOKEN or copy a credential file into an intermediate layer. Deleting it in a later instruction does not erase the earlier layer, and build arguments can survive in image metadata or provenance.
Add a build secret to the service:
services:
app:
build:
context: .
secrets:
- npm_token
secrets:
npm_token:
file: ${HOME}/.config/example-secrets/npm_tokenConsume it in a Dockerfile with the current frontend syntax:
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
COPY . .BuildKit mounts the value for that instruction instead of baking the secret into a layer, as described in Docker's build secret documentation. This protects the resulting image from that particular leak; the package manager can still expose a token through verbose logs, generated configuration, caches, or scripts, so inspect its documented behavior and keep CI output restricted.
When should production use an external secret manager?
Use one across hosts.
A production system needs an external manager when credentials require central rotation, access policy, audit records, encryption controls, dynamic issuance, or delivery to multiple machines without copying plaintext files by hand.
Local Compose remains reasonable for a single controlled host, development environments, and small internal applications where the host administrator is already trusted. It is not equivalent to an orchestrator's secret subsystem merely because both expose files under /run/secrets.
Choose by failure mode:
- Use a local file when one host is trusted and operational simplicity matters most.
- Use Docker Swarm secrets when the application already runs as Swarm services and Swarm's management model fits the deployment.
- Use a cloud or independent secret manager when policy, identity-based access, revocation, and audit history cross host boundaries.
- Use short-lived database or cloud credentials when the provider supports them; reducing credential lifetime is stronger than protecting one permanent password more carefully.
Compose can reference provider-backed secrets through platform-specific extensions or a retrieval sidecar, but the security boundary belongs to that provider and its authentication method. Do not store the manager's permanent access token beside compose.yaml, since that merely moves the original problem one file to the left.
For a broader orchestrator decision, compare the operational trade-offs in Docker Swarm versus Kubernetes. The right answer depends less on the mount path than on who can retrieve, rotate, and revoke the underlying credential.
FAQ
- Can a Docker Compose secret be committed to Git safely?
- No. Commit only the secret declaration and an inert example filename, while keeping the real source file outside the repository and excluding any local fallback path with
.gitignore. If a real credential entered Git, remove it from current files, rotate it immediately, and then decide whether repository history must be rewritten. - Are Compose secret files encrypted?
- Not by local Compose itself. The source is a normal host file whose protection depends on filesystem permissions, disk encryption, backup policy, and administrator access; encrypted storage or centrally managed delivery must come from the host or an external secret manager.
- Can every container image use the `_FILE` environment convention?
- No.
_FILEworks only when the image entrypoint or application explicitly implements it, so check the image documentation or configure the application to read/run/secrets/<name>directly. Compose mounts the file but does not translate it into an environment variable.