Almost every non-trivial container needs a secret somewhere. You need a private registry token to pull a base image, an npm auth token to install private packages during the build, or a database password at runtime. The trap is that Docker gives you several places to put those values, and most of them leak. A credential dropped into an ARG or ENV ends up permanently baked into your image, readable by anyone who runs docker history.
This guide separates the two problems that people constantly conflate: secrets you need during the build and secrets you need while the container runs. They have completely different solutions. We will cover BuildKit's --mount=type=secret, why ARG and ENV are dangerous, and how to wire runtime secrets cleanly, with working code for each.
The short answer
Docker secrets come in two flavors. Build-time secrets (a private repo token, an npm auth token) belong in BuildKit's RUN --mount=type=secret, which exposes the value to a single build step without writing it to any layer. Runtime secrets (database passwords, API keys) belong in environment variables or a secrets manager injected when the container starts. Never put either kind in an ARG or ENV, because both persist in the final image.
Why ARG and ENV leak secrets
To understand the fix, you have to understand why the obvious approaches fail. A Docker image is a stack of read-only layers. Every instruction in your Dockerfile that changes the filesystem or metadata produces a layer, and those layers are permanent. Anyone with the image can unpack them.
ENV writes the value into every layer that follows
An ENV instruction sets an environment variable that persists into the final image and into every running container. That is the whole point of ENV, and it is exactly why it is wrong for secrets.
# NEVER do this
ENV DATABASE_PASSWORD=supersecret123Anyone who pulls this image can read the value instantly:
docker inspect myimage --format '{{json .Config.Env}}'
# ["PATH=/usr/local/bin:...","DATABASE_PASSWORD=supersecret123"]The credential is not hidden, encrypted, or scoped. It is plain metadata attached to the image.
ARG is baked into the image history
ARG feels safer because the variable only exists during the build and is not present as a runtime environment variable. People assume that means it disappears. It does not. Build arguments are recorded in the image's build history.
# NEVER do this either
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm ciThe value passed with --build-arg NPM_TOKEN=... is visible to anyone who inspects the layer history:
docker history --no-trunc myimage
# ...RUN npm config set //registry.npmjs.org/:_authToken=npm_xxxxxxxx && npm ci...Docker's own documentation is explicit on this point: warning readers that build-time variables passed via ARG and --build-arg are not suitable for secrets, because they persist and can be inspected in the final image (Docker build-args documentation). Squashing layers or using a multi-stage build helps a little, but the moment the secret touches a RUN command's recorded history or a written file that gets copied forward, it can survive. The clean answer is to never let the secret enter the layer graph at all.
Build-time secrets with BuildKit
BuildKit, the default build engine in modern Docker, solves this with RUN --mount=type=secret. The secret is mounted into a single RUN instruction as a temporary in-memory file, used, and then discarded. It never becomes part of any layer, never appears in docker history, and never lands in the final image.
Build-time secret: a credential needed only while the image is being built, such as a token to install private dependencies or clone a private repository. It has no reason to exist inside the running container.
Here is the correct pattern for the npm token example above.
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
# Mount the secret only for this RUN; it never hits a layer
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" \
npm config set //registry.npmjs.org/:_authToken="${NPM_TOKEN}" && \
npm ci && \
npm config delete //registry.npmjs.org/:_authToken
COPY . .
RUN npm run build
# Final stage carries only the built app, no token anywhere
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]The first line, # syntax=docker/dockerfile:1, opts into the modern Dockerfile frontend so the --mount flag is understood. You then build by passing the secret from a file or an environment variable:
# From a file
docker build --secret id=npm_token,src=./npm_token.txt -t myapp .
# From an environment variable
export NPM_TOKEN="npm_xxxxxxxx"
docker build --secret id=npm_token,env=NPM_TOKEN -t myapp .Now inspect the result. The token is nowhere:
docker history --no-trunc myapp | grep -i token
# (no output)The secret existed only inside /run/secrets/npm_token for the duration of one RUN step. This is the single most important build-time pattern to internalize, and it replaces every ARG-based approach to build credentials.
Common build-secret targets
By default the secret mounts at /run/secrets/<id>. You can point it wherever a tool expects to find it:
# Place a private key at a known SSH path for a git clone
RUN --mount=type=secret,id=ssh_key,target=/root/.ssh/id_ed25519,mode=0400 \
git clone [email protected]:acme/private-lib.gitFor SSH specifically, BuildKit also offers --mount=type=ssh to forward your agent, which avoids writing the key to disk at all.
Runtime secrets: env vars and secret files
Build-time patterns solve half the problem. The database password your app needs at runtime is a different question, because it must be present while the container runs, not while it builds. Here, environment variables are the correct and standard mechanism, as long as you inject them at start time rather than baking them in.
Runtime secret: a credential the application reads while serving traffic, such as a database URL, a third-party API key, or a JWT signing key. It is provided when the container starts and is never written into the image.
The application reads secrets from the environment exactly as the Twelve-Factor methodology prescribes:
// The image contains this code, never the value
const dbUrl = process.env.DATABASE_URL;
const jwtSecret = process.env.JWT_SECRET;
if (!dbUrl || !jwtSecret) {
throw new Error("Missing required runtime secrets");
}You supply the values when you run the container, so they live in your orchestration layer, not in the image:
docker run \
-e DATABASE_URL="postgres://user:pass@db:5432/app" \
-e JWT_SECRET="$(cat ./jwt_secret)" \
myappThe image is identical across every environment. Development, staging, and production differ only in the values injected at run time. That separation is the entire reason environment variables exist as a configuration channel. For a deeper treatment of managing these values, rotation, and the mistakes teams make, see our environment variables and secrets guide.
Runtime secret files with Compose and Swarm
Passing secrets as environment variables is fine, but environment variables can leak through crash dumps, /proc, or a careless logging library that prints the whole environment. For stricter setups, Docker Compose and Swarm can mount secrets as files instead:
services:
app:
image: myapp
secrets:
- db_password
environment:
# Point the app at the file, not the value
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./db_password.txtThe application reads the file path from DB_PASSWORD_FILE and loads the secret from /run/secrets/db_password. The _FILE suffix convention is widely supported by official images. The trade-off is more moving parts, so many teams stick with plain environment variables and focus their effort on keeping those out of logs and images.
Build-time versus runtime secrets compared
The single most useful mental model is knowing which category a given secret belongs to. This table maps the decision.
| Aspect | Build-time secret | Runtime secret |
|---|---|---|
| Example | npm token, SSH deploy key, private registry login | Database URL, API key, JWT secret |
| Needed during | docker build only | Container execution |
| Correct mechanism | RUN --mount=type=secret | Environment variable or mounted secret file |
| Wrong mechanism | ARG + --build-arg | ENV in the Dockerfile |
| Lands in image? | No, discarded after the RUN step | No, injected at start time |
Visible in docker history? | No | No |
| Where the value lives | Passed to docker build --secret | Orchestrator, secrets manager, or run command |
If a secret is only used to assemble the image, it is build-time. If the application reads it while serving requests, it is runtime. A value should never appear in both columns; a build token has no business inside the running container, and a runtime password should never be needed to build.
A complete example with both kinds
Real applications often need both. Here is a Dockerfile that installs private packages during the build (build-time secret) and reads a database URL at runtime (runtime secret), keeping each in its proper channel.
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
WORKDIR /app
COPY requirements.txt ./
# Build-time secret: index token, mounted and discarded
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL="https://$(cat /run/secrets/pip_token)@pypi.internal/simple" \
pip install --no-cache-dir -r requirements.txt
COPY . .
FROM python:3.13-slim
WORKDIR /app
COPY --from=build /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=build /app /app
# App binds to the standard interface and reads runtime config from env
EXPOSE 8080
CMD ["python", "-m", "app"]Build it and run it, each secret through its own door:
# Build-time secret goes to docker build
docker build --secret id=pip_token,env=PIP_TOKEN -t myapp .
# Runtime secret goes to docker run
docker run -e DATABASE_URL="$(cat ./db_url)" -p 8080:8080 myappNotice the application binds inside the container and reads DATABASE_URL from the environment at startup. Nothing sensitive is ever written into a layer.
How Out Plane handles this for you
If you deploy with a platform instead of managing raw docker run commands, the runtime side of this problem largely disappears. Out Plane is a PaaS that builds your app from a GitHub push or a container image using Buildpacks or your own Dockerfile, and it manages runtime secrets as first-class environment variables that are encrypted at rest, masked in the console, and injected only into your running instances. They never appear in your image, your build logs, or your git history.
Concretely, the workflow is:
- Push your repository. Out Plane builds it, honoring BuildKit build secrets in your Dockerfile so your private-dependency tokens stay out of the image.
- Add runtime secrets like
DATABASE_URLandJWT_SECRETas environment variables in the console or via the API token. - Your app binds to
0.0.0.0on the injectedPORTand reads its secrets from the environment at startup.
For a managed database, the connection string is provided as an environment variable automatically, with backups, point-in-time recovery, and connection pooling handled for you, so you are not managing a database password by hand at all. Instances run across regions including Nuremberg, Helsinki, Ashburn, Hillsboro, and Singapore, with per-second billing, a permanently free Hobby tier with 3 instances, and a $20 credit for 30 days with no card required. For the full container workflow, see our guide to Docker hosting.
Frequently asked questions
What is the difference between a build secret and a runtime secret in Docker?
A build secret is only needed while the image is being built, such as a token to install private packages, and belongs in BuildKit's RUN --mount=type=secret. A runtime secret is read by the application while it runs, such as a database password, and belongs in an environment variable or mounted secret file injected at container start. Keeping them separate prevents build credentials from ever reaching the running container.
Why should I not use ARG for secrets?
Because ARG values are recorded in the image's build history. Anyone who runs docker history --no-trunc on your image can read the value you passed with --build-arg. Docker's documentation explicitly warns that build arguments are not a secure way to pass secrets. Use RUN --mount=type=secret instead, which never records the value in any layer.
Does ENV keep secrets out of my image?
No. An ENV instruction writes the value directly into the image configuration, where it is readable with docker inspect and present in every container started from the image. ENV is for non-sensitive configuration only. Provide real secrets at run time with docker run -e or a mounted secret file.
How do I pass a secret to docker build without it being stored?
Add # syntax=docker/dockerfile:1 to the top of your Dockerfile, use RUN --mount=type=secret,id=mysecret, and build with docker build --secret id=mysecret,src=./file or --secret id=mysecret,env=VAR. BuildKit mounts the secret as a temporary file for that single step and discards it, so it never enters a layer or the build history.
Can I still see build secrets in docker history?
No. That is the whole advantage of RUN --mount=type=secret. Unlike ARG, the secret is mounted as an ephemeral in-memory file and is not recorded in the layer history. Run docker history --no-trunc afterward and the value will not appear.
Are Docker Compose secrets encrypted?
In plain Docker Compose, file-based secrets are mounted from files on the host and are not encrypted at rest by Compose itself; you rely on host permissions. In Docker Swarm mode, secrets are encrypted at rest in the Raft log and transmitted only to nodes that need them. For stronger guarantees, use a dedicated secrets manager or a platform that encrypts secrets at rest for you.
How do managed platforms handle Docker secrets?
Most PaaS providers, including Out Plane, honor BuildKit build secrets during the image build and manage runtime secrets as encrypted environment variables injected into your instances at start time. This means you never write a secret into an ENV line or pass a runtime credential through --build-arg, and the values stay out of your image, logs, and version control.
Key takeaways
The failure mode with Docker secrets is treating one problem as if it were two, or two problems as if they were one. Build secrets and runtime secrets are genuinely different, and the correct tool for each is different.
- Never put a secret in
ENVorARG. Both persist in the image and are trivially recoverable. - Use
RUN --mount=type=secretfor anything needed only during the build. - Use environment variables or mounted secret files, injected at start time, for anything the running app needs.
- Verify with
docker history --no-truncanddocker inspect. If you can read a secret there, so can anyone with your image.
Get these two channels right and your credentials stay out of your layers, your logs, and your git history, which is exactly where they belong.