There are two distinct Docker secret patterns: Docker Swarm secrets at runtime (the secret is mounted into the running container from the Swarm secret store) and Docker BuildKit secrets at build time (the secret is mounted into the build context from the host or a file). The two are not interchangeable — Swarm secrets are for runtime, BuildKit secrets are for build, and neither is the right answer for the naive ENV SECRET=*** pattern. The naive pattern bakes the secret into the image layer, where docker historyanddocker inspect` reveal it forever, even after the secret is rotated.
This post walks through both patterns, the mistake, the audit pattern, and the deploy platform story that handles secrets without Swarm or BuildKit.
Table of contents
- The mistake: ENV SECRET, baked into every layer
- The runtime pattern: Docker Swarm secrets
- The build-time pattern: Docker BuildKit secrets
- The right pattern for each scenario
- The deploy platform story
- The audit pattern
- The escape hatch when the secret is in the image already
- How this fits the rest of the stack
- FAQ
The mistake: ENV SECRET, baked into every layer
The mistake is the line every Docker tutorial starts with:
FROM node:20
ENV API_KEY=sk_live_abc123
COPY . .
RUN npm install
CMD ["node", "server.js"]
The line ENV API_KEY=sk_live_abc123 looks innocent. The behavior is not. The ENV instruction sets the environment variable for the build and for the runtime. The variable is part of the image’s metadata, which means:
docker history myimage:latestshows the lineENV API_KEY=sk_live_abc123. The secret is in the output.docker inspect myimage:latestshowsEnv: [API_KEY=sk_live_abc123, ...]. The secret is in the JSON.- The image is pushed to a registry, the registry stores the layer, the layer is downloadable by anyone with pull access. The secret is in the registry.
- The team rotates the secret (
API_KEY=sk_live_def456in the next build), the old image is still in the registry, the old image still has the old secret. The team has to delete the old image to truly rotate.
The mistake is the most common Docker security bug. The right answer is to never use ENV for secrets, ever.
The gotcha: even the team’s CI logs may have the secret. The docker build command logs the ENV instruction, the CI captures the log, the log is searchable. The right answer is to use the secret-mount patterns below, which do not log the value.
The runtime pattern: Docker Swarm secrets
Docker Swarm secrets are a Swarm-specific feature. The pattern: the team creates a secret with docker secret create, the secret is stored in the Swarm’s Raft log, the secret is mounted into the running container at a specified path, the application reads the secret from the file.
The standard flow:
# Create the secret from a file or stdin
echo "sk_live_abc123" | docker secret create api_key -
# Deploy a service that mounts the secret
docker service create \
--name api \
--secret api_key \
myimage:latest
The api_key secret is mounted at /run/secrets/api_key inside the running container. The application reads the secret from the file:
const fs = require('fs');
const apiKey = fs.readFileSync('/run/secrets/api_key', 'utf8').trim();
The gotcha: Swarm secrets are only available in Swarm mode. The team that runs docker run (not Swarm) does not have the secret mount. The right answer for non-Swarm is a different runtime secret pattern (see deploy platform story below).
The second gotcha: Swarm secrets are stored in the Swarm’s Raft log, which is encrypted at rest but is replicated to all manager nodes. The team’s security team should know the secret is in the Swarm’s data plane, not just in the running container.
The third gotcha: Swarm secrets are only updated by re-creating the secret and re-deploying the service. The team that needs to rotate a secret without downtime uses docker secret update (Swarm 1.13+) or re-deploys the service.
The build-time pattern: Docker BuildKit secrets
Docker BuildKit secrets are a BuildKit-specific feature. The pattern: the team uses the --secret flag with docker buildx build, the secret is mounted into the build context at a specified path, the build can read the secret from the file, the secret is not in the resulting image.
The standard Dockerfile:
# syntax=docker/dockerfile:1.7
FROM node:20
RUN --mount=type=secret,id=api_key \
cat /run/secrets/api_key > /tmp/api_key && \
npm config set //registry.npmjs.org/:_authToken "$(cat /tmp/api_key)" && \
npm install
RUN rm /tmp/api_key
The build command:
docker buildx build --secret id=api_key,src=./secrets/api_key.txt -t myimage:latest .
The api_key.txt file is read from the host, mounted into the build context at /run/secrets/api_key, used by the build, and discarded. The resulting image does not contain the secret.
The gotcha: BuildKit must be enabled. The team’s Docker daemon must be running with BuildKit (DOCKER_BUILDKIT=1 or docker buildx). The team that uses the legacy builder does not have the secret mount.
The second gotcha: the secret is in the build cache. The team’s first build with the secret writes the secret to the build cache. The cache is on the build host, the cache persists across builds, the cache is in the host’s filesystem. The right answer is to clean the build cache after a build with a secret (docker buildx prune).
The third gotcha: the secret is in the CI logs if the build command fails. The team’s CI captures the build log, the build log includes the secret if the build fails. The right answer is to redirect the build log to a file, not stdout, when the build uses a secret.
The right pattern for each scenario
The right pattern for each scenario:
- The secret is consumed by the running container (an API key, a database password, a JWT signing key). Swarm secret at runtime, or the deploy platform’s runtime secret. The Dockerfile does not see the secret.
- The secret is consumed by the build (a private npm registry token, a code-signing key, a private Git credential). BuildKit secret at build time, with
--secretflag. The running image does not contain the secret. - The secret is consumed by both. BuildKit secret for the build, Swarm secret (or platform secret) for the runtime. The Dockerfile uses
--mount=type=secretfor the build, the deploy configures the runtime secret.
The right answer for any other case is one of the three above, with the appropriate mount.
The deploy platform story
The deploy platform story is the one most teams will use. The pattern: the platform handles the secret store, the platform injects the secret at runtime, the platform’s build system uses BuildKit (or equivalent) for build-time secrets. The team does not configure Swarm.
The standard implementation: the platform’s dashboard has a “Secrets” or “Environment Variables” section, the team adds the secret to the dashboard, the platform injects the secret at runtime via the standard process.env mechanism. The platform’s build system uses --secret for build-time secrets.
The gotcha: the platform’s secret store is the source of truth. The team that hardcodes the secret in the Dockerfile has leaked the secret to the image, regardless of the platform. The right answer is to use the platform’s secret store, not the Dockerfile.
The second gotcha: the platform’s secret store is not visible to the team in the same way as the runtime environment. The team that needs to know which secrets are set uses the platform’s “Secrets” view, not the runtime environment.
The audit pattern
The audit pattern is the same regardless of which secret pattern the team uses. The audit is: every secret in the team’s Dockerfiles is removed, every secret in the team’s images is rotated, every secret in the team’s registries is purged.
The standard audit:
- Grep the Dockerfiles for
ENVinstructions that look like secrets. Common patterns:ENV API_KEY=,ENV DB_PASSWORD=,ENV SECRET=,ENV TOKEN=. The right answer is to remove every match. - Grep the build history for the secrets.
docker history --no-trunc myimage:latest | grep -i secret. The right answer is to find the offending line and rebuild without it. - Inspect the image metadata.
docker inspect myimage:latest | grep -i secret. The right answer is to confirm the inspect output is clean. - Audit the registry. The team’s registry has the old images with the old secrets. The right answer is to delete the old images, force a rebuild, and confirm the new image is the only one in the registry.
The audit takes an hour. The right answer is to run it quarterly, or after every Dockerfile change that involves ENV.
The escape hatch when the secret is in the image already
The escape hatch when the secret is in the image already:
- Rotate the secret at the source. The team that has a leaked API key rotates the key at the provider (Stripe, AWS, etc.). The old key is invalidated, the new key is the only one that works.
- Rebuild the image without the secret. The team removes the
ENVline, rebuilds the image, pushes the new image. The new image does not contain the secret. - Delete the old image from the registry. The old image is still in the registry with the old secret. The right answer is to delete it, force the team’s deploys to use the new image, and confirm the old image is no longer pull-able.
- Audit the team for other places the secret was used. The secret may be in the team’s CI logs, the team’s issue tracker, the team’s Slack history, the team’s commit history. The right answer is to grep all of these and rotate any other instances.
The four-step pattern is the right answer for a leaked secret. The right answer is to do the four steps within hours of discovering the leak, not days.
How this fits the rest of the stack
The secret pattern is also a security pattern. Every secret the team’s app reads is a line item on the platform’s security audit, and the team’s mental model for the security posture is the sum of those secrets. The right answer is to model the secret flow before the project ships, not after. The RunxBuild hosting calculator is the right place to do that exercise — pick the runtime size, the memory tier, the storage, the secret store, and the egress, and the calculator shows what the deploy actually costs at the team’s actual usage.
Useful related references:
FAQ
What is a Docker secret?
A Docker secret is a piece of sensitive data (an API key, a database password, a TLS certificate) that the team does not want in the image or in the registry. There are two patterns: Docker Swarm secrets at runtime and Docker BuildKit secrets at build time. The team uses the appropriate pattern based on when the secret is consumed.
What is the difference between Docker Swarm secrets and BuildKit secrets?
Swarm secrets are for runtime — the secret is mounted into the running container. BuildKit secrets are for build — the secret is mounted into the build context. The two are not interchangeable. The team uses Swarm for runtime secrets, BuildKit for build secrets, and never ENV for either.
Why is ENV SECRET a mistake?
ENV SECRET=*** bakes the secret into the image layer. docker history and docker inspect reveal the secret. The registry stores the layer with the secret. The team cannot rotate the secret without deleting the image and rebuilding. The right answer is to never use ENV for secrets, ever.
How do I add a secret to a Docker Swarm service?
docker secret create <name> <file> creates the secret. docker service create --secret <name> myimage mounts the secret at /run/secrets/<name> in the running container. The application reads the secret from the file.
How do I add a secret to a Docker build?
docker buildx build --secret id=<name>,src=<file> . mounts the secret at /run/secrets/<name> in the build context. The Dockerfile uses RUN --mount=type=secret,id=<name> to access the secret. The resulting image does not contain the secret.
Can I use both Swarm and BuildKit secrets in the same image?
Yes. The Swarm secret is for runtime (mounted at /run/secrets/<name> in the running container). The BuildKit secret is for build (mounted at /run/secrets/<name> in the build context). The two are different mount paths in different stages.
How do I know if a secret is in my image already?
docker history --no-trunc myimage:latest | grep -i secret shows the ENV lines. docker inspect myimage:latest | grep -i secret shows the image metadata. The right answer is to grep both, find the offending line, rotate the secret at the source, and rebuild.
How do I rotate a leaked secret?
Four steps: rotate the secret at the provider, rebuild the image without the secret, delete the old image from the registry, audit the team for other places the secret was used (CI logs, issue tracker, Slack, commit history). The four steps take hours, not days.