| |

Docker 24 🐳 Advanced Buildkit Features: Secret Mounts (–mount=type=secret) and SSH Forwarding

A Docker build is a script that runs with whatever privileges the builder has. Historically, that script had no safe way to use credentials. Build arguments (--build-arg) are visible in the image history and docker history output. Environment variables (ENV) end up in the image configuration. Copying a credential file into the build context and deleting it in a later RUN instruction does not remove it—the layer that contained the file persists in the image, and docker save can extract it. The result is a class of security incidents where API keys, database passwords, and SSH private keys end up baked into images pushed to registries.

BuildKit changes this. Secret mounts (--mount=type=secret) and SSH forwarding (--mount=type=ssh) provide temporary, in-memory access to credentials during a specific RUN instruction. The credentials are never written to a layer, never stored in image history, and never present in the final image. This chapter covers both features, the syntax for using them, and the patterns that make them safe in production.

Key point: Secret mounts and SSH forwarding are temporary. A secret mounted with --mount=type=secret exists only for the duration of the RUN instruction that mounts it. It is not stored in any layer, not in the image configuration, and not in the build cache. SSH forwarding exposes the host’s SSH agent socket to the build container for the duration of a RUN instruction, allowing Git to authenticate to private repositories without the key ever entering the image.


Why BuildKit secrets exist

The build argument problem. ARG values are visible in the image history. docker history --no-trunc shows every build argument that was passed to the build. Even if an argument is not promoted to an ENV, it is part of the build’s provenance metadata. A token passed via --build-arg NPM_TOKEN=xyz is recoverable from the image unless the image is scrubbed, and scrubbing is not reliable because the build cache may retain it.

The COPY-then-delete problem. A common workaround is to COPY a credential file into the image, use it in a RUN instruction, and rm it in the same instruction. This does not work. Docker layers are immutable. The layer created by COPY contains the credential. The RUN rm creates a new layer that masks the file but does not remove it from the earlier layer. docker save extracts all layers, including the one containing the credential. The only safe pattern is to never let the credential enter the filesystem in the first place.

The SSH private key problem. Private Git repositories are a common dependency source. The naive approach is to copy an SSH private key into the build context, use it for git clone, and delete it. This has the same layer problem as any other credential. It also exposes the key to anyone who can read the build context, which in CI is often shared. SSH forwarding avoids both issues by exposing the host’s ssh-agent socket to the build container. The key never leaves the host’s agent; the build container can request signatures but cannot read the key.

The CI problem. CI environments need to build images that depend on private registries, private Git repositories, and credentialed APIs. Historically, this meant injecting secrets into the build environment as environment variables, which then leaked into image layers or build logs. BuildKit secrets allow CI systems to pass credentials through a dedicated channel that is designed to be temporary and non-persistent.

The BuildKit dependency. Both features require BuildKit. Docker 18.09 and later include BuildKit, and it became the default builder in Docker 23.0. The Dockerfile syntax directive # syntax=docker/dockerfile:1 enables the --mount flag for RUN instructions. Without it, the --mount syntax is not recognized.


a. Secret mounts: syntax and usage

A secret mount is declared in a RUN instruction with --mount=type=secret. The secret is passed to the build with --secret on the command line.

The Dockerfile:

# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

The id=npmrc is the identifier. The target=/root/.npmrc specifies where the secret file appears inside the build container. If target is omitted, the default is /run/secrets/{id}.

The build command:

docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

The src points to the file on the host. The file is read at build time, mounted into the container at the specified target, and removed when the RUN instruction completes. It is never written to a layer .

The secret can be passed from an environment variable instead of a file:

export NPM_TOKEN="npm_xxx"
echo "$NPM_TOKEN" | docker build --secret id=npmrc,src=- -t myapp .

When src=-, BuildKit reads the secret from standard input. This is useful in CI systems that store secrets in environment variables or secret managers .

The required=true option fails the build if the secret is not provided. Without it, the build proceeds with the secret missing, which can produce confusing errors.

RUN --mount=type=secret,id=aws,target=/root/.aws/credentials,required=true \
    aws s3 cp s3://bucket/file /app/file

The mode option sets file permissions. The default is 0400, readable only by the owner. For secrets that need to be readable by a non-root user, set uid and mode explicitly.

RUN --mount=type=secret,id=key,target=/app/key,mode=0440,uid=1000 \
    cat /app/key

Multiple secrets can be mounted in a single RUN instruction. Each has its own id and target path.

RUN --mount=type=secret,id=npm_token \
    --mount=type=secret,id=github_token \
    echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && \
    git config --global url."https://$(cat /run/secrets/github_token)@github.com/".insteadOf "https://github.com/"

The build command passes each secret separately:

docker build \
  --secret id=npm_token,src=npm_token.txt \
  --secret id=github_token,src=github_token.txt \
  -t myapp .

The secret is available only in the RUN instruction that mounts it. A subsequent RUN instruction cannot access it. This scoping is enforced by the builder and is the feature that makes secrets safe .


b. SSH forwarding: syntax and usage

SSH forwarding exposes the host’s ssh-agent socket to the build container. The build container can use the agent to authenticate SSH connections, but the private key itself never leaves the host.

The Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine AS build
RUN apk add --no-cache git openssh-client

RUN --mount=type=ssh \
    mkdir -p ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    git clone git@github.com:myorg/private-repo.git /app

The --mount=type=ssh instruction makes the SSH agent socket available. The ssh-keyscan adds GitHub’s host key to known_hosts, preventing the “authenticity of host cannot be established” prompt.

The build command:

DOCKER_BUILDKIT=1 docker build --ssh default -t myapp .

The --ssh default flag forwards the default SSH agent socket. If the agent is running and has a key added, the build container can use it. The key is not copied; only the socket is forwarded .

The host must have an SSH agent running with the appropriate key loaded:

eval $(ssh-agent)
ssh-add ~/.ssh/id_rsa

Without an agent, --ssh default has nothing to forward, and Git authentication fails.

Multiple SSH keys can be forwarded by naming them. The --ssh flag accepts a name=path syntax:

docker build --ssh github=$SSH_AUTH_SOCK -t myapp .

The Dockerfile references the named socket:

RUN --mount=type=ssh,id=github \
    git clone git@github.com:myorg/private-repo.git

The id in the mount must match the name in the --ssh flag. This allows different keys for different services .

SSH forwarding is specifically for SSH authentication. For HTTPS authentication to Git providers, a secret mount containing a personal access token is the appropriate mechanism.


c. Patterns and security considerations

The most common pattern for secret mounts is providing credentials to package managers. The secret is mounted at the path the package manager expects, and the command runs as if the credential file were present.

# npm
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

# pip
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
    pip install -r requirements.txt

# Go modules
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    go mod download

# AWS
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
    aws s3 cp s3://bucket/data /app/data

The secret file is not copied into the image. It exists only in the build container’s temporary filesystem, mounted at the target path. When the RUN instruction finishes, the mount is removed and the secret is gone.

A critical security consideration: the build log may contain the secret if the command prints it. A RUN cat /run/secrets/token will print the token to the build output, where it is visible in CI logs. The secret is not in the image, but it is in the log. This is the same exposure as printing a password to a terminal. Avoid cat on secrets in commands that run in CI .

The required=true option is a safety feature. Without it, a build that expects a secret will proceed if the secret is not provided, and the failure may be cryptic. With it, the build fails immediately with a clear error.

RUN --mount=type=secret,id=token,required=true \
    cat /run/secrets/token

The --mount=type=secret directive is only available with BuildKit. The # syntax=docker/dockerfile:1 directive enables it. Without the syntax directive, older Docker versions may not recognize the mount flag. Docker 23.0 and later use BuildKit by default, so the syntax directive is often unnecessary but is recommended for explicitness.

The secret is not stored in the build cache. If a RUN instruction that uses a secret is cached, the secret is not re-read on subsequent builds. This means that rotating a secret does not invalidate the cache. If the build depends on the secret’s value, the cache may serve a stale result. For builds where the secret changes the output, the cache must be invalidated manually, or the secret must be used in a way that does not affect the cached layer .

A subtle issue: the RUN instruction’s cache key includes the mount declaration but not the secret content. Two builds with different secrets but the same Dockerfile will share the cache. If the secret affects the output—for example, if it authenticates to a private registry and downloads a private package—the cached layer may contain the result of the first build’s secret, not the second. This is usually desirable (the package is the same), but it can be surprising if the secret controls access to a mutable resource.

SSH forwarding has a similar cache consideration. The forwarded agent is not part of the cache key. A cached RUN that cloned a repository will not re-clone on subsequent builds, even if the repository has changed. To force a re-clone, the cache must be invalidated or the clone must be part of a layer whose cache key changes.


Complete Example Session

# ============================================
# PART 1: NPM PRIVATE REGISTRY WITH SECRET
# ============================================
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
COPY . .
RUN npm run build
# ============================================
# PART 2: BUILD COMMAND WITH SECRET
# ============================================
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# ============================================
# PART 3: PYTHON PRIVATE INDEX
# ============================================
FROM python:3.12-slim
COPY requirements.txt .
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
    pip install --no-cache-dir -r requirements.txt
# ============================================
# PART 4: GO PRIVATE MODULES
# ============================================
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server
# ============================================
# PART 5: SSH FORWARDING FOR GIT CLONE
# ============================================
FROM alpine AS build
RUN apk add --no-cache git openssh-client
RUN --mount=type=ssh \
    mkdir -p ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    git clone git@github.com:myorg/private-repo.git /app
# ============================================
# PART 6: BUILD WITH SSH FORWARDING
# ============================================
eval $(ssh-agent)
ssh-add ~/.ssh/id_rsa
DOCKER_BUILDKIT=1 docker build --ssh default -t myapp .
# ============================================
# PART 7: MULTIPLE SECRETS
# ============================================
FROM alpine
RUN --mount=type=secret,id=npm_token \
    --mount=type=secret,id=github_token \
    echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && \
    git config --global url."https://$(cat /run/secrets/github_token)@github.com/".insteadOf "https://github.com/"
# ============================================
# PART 8: MULTIPLE SECRETS BUILD COMMAND
# ============================================
docker build \
  --secret id=npm_token,src=npm_token.txt \
  --secret id=github_token,src=github_token.txt \
  -t myapp .
# ============================================
# PART 9: REQUIRED SECRET
# ============================================
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials,required=true \
    aws s3 cp s3://bucket/data /app/data
# ============================================
# PART 10: VERIFY SECRET NOT IN IMAGE
# ============================================
# docker history --no-trunc myapp | grep -i secret
# → no output
# docker run --rm myapp cat /run/secrets/token
# → file not found

The ten parts covered npm private registry, the build command, Python private index, Go private modules, SSH forwarding, the SSH build command, multiple secrets, multiple secret build, required secrets, and verification.


Quick Reference

Secret Mount Syntax

ComponentSyntaxPurpose
Mount type--mount=type=secretDeclares a secret mount
IDid=nameIdentifier matching --secret
Targettarget=/pathWhere secret appears (default /run/secrets/{id})
Requiredrequired=trueFail if secret missing
Modemode=0400File permissions
UIDuid=1000Owner of secret file

Build Command Flags

FlagPurpose
--secret id=name,src=pathPass secret from file
--secret id=name,src=-Read secret from stdin
--ssh defaultForward default SSH agent
--ssh name=pathForward named SSH socket
DOCKER_BUILDKIT=1Enable BuildKit (Docker < 23)

Secret vs Build Argument

Aspect--build-arg--secret
Stored in image historyYesNo
Visible in docker historyYesNo
Available across RUN instructionsYesNo (scoped to one RUN)
Cached in layerYesNo
Use caseNon-sensitive configCredentials

SSH vs Secret for Git

AspectSSH ForwardingSecret (PAT)
ProtocolSSHHTTPS
Key leaves hostNoNo
Agent requiredYesNo
GitHub authSSH keyPersonal access token
Cache considerationAgent not in cache keySecret not in cache key

Best Practices

✅ Do This:

# Use secret mount for credentials
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci         # ✅

# Use required=true for mandatory secrets
RUN --mount=type=secret,id=aws,required=true aws s3 cp ...           # ✅

# Use SSH forwarding for private Git
RUN --mount=type=ssh git clone git@github.com:org/repo.git           # ✅

# Use ssh-keyscan for known_hosts
RUN --mount=type=ssh \
    ssh-keyscan github.com >> ~/.ssh/known_hosts                     # ✅

# Verify secrets are not in the image
docker history --no-trunc myapp | grep -i secret                     # ✅

# Use .dockerignore for credential files
# .env
# *.pem
# .npmrc                                                             # ✅

❌ Don’t Do This:

# Don't use build args for secrets
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc     # ❌

# Don't COPY then delete credentials
COPY .npmrc /root/.npmrc
RUN npm ci && rm /root/.npmrc                                        # ❌

# Don't cat secrets in commands
RUN --mount=type=secret,id=token cat /run/secrets/token              # ❌

# Don't copy SSH keys into the build
COPY id_rsa /root/.ssh/id_rsa                                        # ❌

# Don't rely on cache invalidation for secret rotation
# (secret content not in cache key)                                  # ❌

# Don't forget the syntax directive for older Docker
# syntax=docker/dockerfile:1                                         # ⚠️

Common Pitfalls

PitfallWhy It HappensFix
--mount not recognizedBuildKit not enabledDOCKER_BUILDKIT=1 or Docker 23+
Secret not foundWrong id or not passedCheck --secret id=name matches mount
Build fails with missing secretrequired=true not setAdd required=true for mandatory
SSH clone failsNo agent or key not addedeval $(ssh-agent) && ssh-add
Host key verification failedknown_hosts not populatedssh-keyscan github.com >> ~/.ssh/known_hosts
Secret visible in logscat in commandRemove cat, use in-place
Cache serves stale resultSecret not in cache keyInvalidate cache or force rebuild

Real-World Examples

1. Private npm Registry

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

2. Private Python Index

RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
    pip install -r requirements.txt

3. Private Go Modules

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    go mod download

4. Git Clone with SSH

RUN --mount=type=ssh \
    git clone git@github.com:org/repo.git /app

5. AWS Credentials

RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
    aws s3 cp s3://bucket/data /app/data

6. Multiple Secrets

RUN --mount=type=secret,id=a --mount=type=secret,id=b \
    command

7. Required Secret

RUN --mount=type=secret,id=key,required=true \
    cat /run/secrets/key

8. Secret from Environment

echo "$TOKEN" | docker build --secret id=token,src=- .

9. Named SSH Socket

docker build --ssh github=$SSH_AUTH_SOCK .

10. Verify No Secrets

docker history --no-trunc myapp | grep -i -E 'token|secret|key'

Visual

Secret Mount Lifecycle

┌─────────────────────────────────────────────────────────────┐
│  SECRET MOUNT LIFECYCLE                                     │
│                                                             │
│  Host                       Build Container                 │
│  ┌─────────┐                ┌─────────────────────┐         │
│  │ .npmrc  │                │                     │         │
│  │ (file)  │                │  RUN --mount=type=  │         │
│  │         │                │    secret,id=npmrc  │         │
│  │         │ ──────────────▶│                     │         │
│  │         │  read once     │  /root/.npmrc       │         │
│  └─────────┘                │  (mounted tmpfs)    │         │
│                             │                     │         │
│                             │  npm ci             │         │
│                             │                     │         │
│                             │  RUN completes      │         │
│                             │  mount removed      │         │
│                             └─────────────────────┘         │
│                                                             │
│  After build:                                               │
│  - No secret in any layer                                   │
│  - No secret in image config                                │
│  - No secret in build cache                                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Build Argument vs Secret

┌─────────────────────────────────────────────────────────────┐
│  BUILD ARG (UNSAFE)                                         │
│                                                             │
│  ARG NPM_TOKEN                                              │
│  RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}"   │
│      > .npmrc && npm ci && rm .npmrc                        │
│                                                             │
│  Image history:                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ARG NPM_TOKEN=npm_xxx  ← VISIBLE                   │    │
│  │  RUN echo ...           ← layer contains .npmrc     │    │
│  │  RUN rm .npmrc          ← masks but does not remove │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  docker history --no-trunc shows the token.                 │
│  docker save extracts the layer with .npmrc.                │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  SECRET MOUNT (SAFE)                                        │
│                                                             │
│  RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \     │
│      npm ci                                                 │
│                                                             │
│  Image history:                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  RUN --mount=type=secret ...  ← no secret visible   │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  docker history shows the mount declaration, not the value. │
│  docker save has no layer containing the secret.            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

SSH Forwarding Flow

┌─────────────────────────────────────────────────────────────┐
│  SSH FORWARDING                                             │
│                                                             │
│  Host                                                       │
│  ┌─────────────────────┐                                    │
│  │  ssh-agent          │                                    │
│  │  ┌───────────────┐  │                                    │
│  │  │ private key   │  │  (never leaves agent)              │
│  │  │ (in memory)   │  │                                    │
│  │  └───────┬───────┘  │                                    │
│  │          │ socket   │                                    │
│  └──────────┼──────────┘                                    │
│             │                                               │
│             ▼                                               │
│  Build Container                                            │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  RUN --mount=type=ssh                               │    │
│  │                                                     │    │
│  │  git clone git@github.com:org/repo.git              │    │
│  │      │                                              │    │
│  │      └──▶ uses forwarded socket to sign request     │    │
│  │                                                     │    │
│  │  Key is never present in container filesystem.      │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  docker build --ssh default .                               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Secret Scope

┌─────────────────────────────────────────────────────────────┐
│  SECRET SCOPED TO ONE RUN INSTRUCTION                       │
│                                                             │
│  RUN --mount=type=secret,id=token \                         │
│      cat /run/secrets/token                                 │
│      │                                                      │
│      └── secret available here                              │
│                                                             │
│  RUN cat /run/secrets/token                                 │
│      │                                                      │
│      └── secret NOT available (mount removed)               │
│                                                             │
│  Two RUN instructions cannot share a secret mount.          │
│  Each must declare its own mount.                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Secret mount--mount=type=secret,id=name
Default target/run/secrets/{id}
Custom targettarget=/path
Requiredrequired=true
Build flag--secret id=name,src=path
From stdin--secret id=name,src=-
SSH forwarding--mount=type=ssh
SSH build flag--ssh default
BuildKit requiredYes
Syntax directive# syntax=docker/dockerfile:1
Secret in imageNo
Secret in cacheNo
Secret in logsOnly if printed

Key takeaways:

  • Secret mounts are temporary and scoped. A secret mounted with --mount=type=secret exists only for the duration of the RUN instruction that mounts it. It is not stored in any layer, not in the image configuration, and not in the build cache.
  • Build arguments are not secrets. ARG values are visible in docker history and persist in the image. The only safe way to pass a credential to a build is through a secret mount.
  • COPY-then-delete does not remove the secret. Docker layers are immutable. A credential copied into the image and deleted in a later instruction remains in the earlier layer and can be extracted with docker save.
  • SSH forwarding exposes the agent, not the key. The build container can use the host’s ssh-agent to authenticate SSH connections, but the private key never enters the build. This is the safe pattern for cloning private Git repositories.
  • The required=true option fails the build early. Without it, a missing secret produces a confusing failure later in the build. With it, the build stops immediately with a clear error.
  • Secrets are not in the cache key. Changing a secret does not invalidate cached layers. If the secret affects the build output, the cache must be invalidated manually or the secret must be used in a non-cached context.
  • The syntax directive enables the features. # syntax=docker/dockerfile:1 at the top of the Dockerfile enables the --mount flag for RUN instructions. Docker 23.0 and later use BuildKit by default, but the directive is recommended for explicitness.
  • Secrets can leak through logs. A cat on a secret file prints it to the build output, where it is visible in CI logs. The secret is not in the image, but it is in the log. Avoid printing secrets.

Remember: BuildKit secrets are the correct way to handle credentials in Docker builds. They are temporary, scoped, and never persist in the image. SSH forwarding is the correct way to authenticate to private Git repositories. The private key never leaves the host. Together, they replace the unsafe patterns of build arguments, environment variables, and COPY-then-delete with a mechanism designed specifically for temporary credential access. The features require BuildKit, which is now the default builder in Docker 23.0 and later, and the syntax directive enables the mount syntax in older versions. Use them in every Dockerfile that needs credentials, and verify with docker history that no secret remains.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!