BuildKit Secrets and SSH Forwarding: Using Private Repos Securely in Builds
AI generated
FROM
RUN
Docker · BuildKit · Security
BuildKit Secrets and SSH Forwarding
Using private repos securely in builds

ARG and ENV almost always end up visible in the final image, even after an apparent overwrite. BuildKit's --secret and --ssh flags let you provide credentials and SSH keys only transiently during a single RUN step, without them ever landing in a layer.

17 min read BuildKit Secrets SSH Forwarding

1. Why ARG and ENV are unsuitable for secrets

A classic mistake when building Docker images is passing credentials for private package repositories or Git hosts into the build via ARG or ENV. The problem is that every ARG and ENV instruction becomes part of the image metadata and can be read out with docker history or docker inspect, even if a later instruction appears to overwrite the value. Anyone who believes a RUN unset VAR at the end of the Dockerfile removes the secret is mistaken, because the original layer with the plaintext value remains in the image history and can be extracted with just a few commands.

This becomes especially critical when images are pushed to a public or even just a team-wide registry, because then the secret is potentially visible to anyone with pull access. BuildKit, the build engine available since Docker 18.09 and now enabled by default, was extended with exactly two mechanisms to address this: --secret for arbitrary credentials such as Composer auth tokens or npm tokens, and --ssh for forwarding an SSH agent to clone private Git repositories over SSH. Both mechanisms make the sensitive data available only during a single RUN command via an in-memory mount and never write it to a layer.

2. How RUN --mount=type=secret works

The syntax RUN --mount=type=secret,id= temporarily mounts a file with the secret's content under /run/secrets/ inside the build container. It exists only for the duration of that single RUN command and is automatically removed afterward, never becoming part of a committed layer. For the Dockerfile to be allowed to use this syntax, its first line must contain the parser directive syntax=docker/dockerfile:1, since secret mounts are a BuildKit extension and not part of the classic Dockerfile specification.

When invoking docker build, the secret is then passed via a flag, either as a file with --secret id=composer_auth,src=./auth.json or directly from an environment variable with --secret id=npm_token,env=NPM_TOKEN. The latter is especially convenient in CI pipelines, because the secret there usually already exists as an environment variable from a vault or the CI's secret store and does not need to be written to disk as a file first. It is important to consume the secret inside the RUN command via cat or a redirect and not accidentally log it as a shell argument, since build logs themselves could otherwise become a leak source.


# syntax=docker/dockerfile:1
# Pass the secret as an environment variable from CI
docker build \
  --secret id=composer_auth,env=COMPOSER_AUTH \
  --secret id=npm_token,env=NPM_TOKEN \
  -t myshop/app:latest .

# Alternatively pass it as a file (local only, never commit it)
docker build --secret id=composer_auth,src=./auth.json -t myshop/app:latest .

3. Practical example: private Composer packages in the build

A typical use case in Magento or Symfony projects is accessing a private Composer repository such as repo.magento.com or an in-house Satis server. Without a secret mount, you would either have to copy an auth.json with plaintext credentials into the image, making it permanently visible, or resort to awkward multi-stage tricks with manual deletion, which as described above still leaves traces. With a secret mount, the auth.json remains visible only during the composer install call and disappears without a trace afterward.

In the example below, the auth.json is mounted at the location Composer expects inside the HOME directory, so composer install finds it automatically without it ending up in the final image layer. This technique works analogously for npm with an .npmrc containing an auth token, or for pip with a pip.conf for private PyPI indexes.


# syntax=docker/dockerfile:1
FROM composer:2 AS vendor

WORKDIR /app
COPY composer.json composer.lock ./

# auth.json only exists inside this RUN step
RUN --mount=type=secret,id=composer_auth,target=/root/.composer/auth.json \
    composer install --no-dev --no-scripts --no-interaction --prefer-dist

FROM php:8.4-fpm-alpine AS runtime
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html

4. SSH agent forwarding with --ssh

When a build needs to clone private Git repositories over SSH, for example an internal Composer package loaded directly from a Git remote instead of a registry, copying a private key into the image is off-limits, even temporarily, because layer remnants can persist here too. BuildKit solves this more elegantly by forwarding the locally running SSH agent into the build container, analogous to the familiar ssh -A principle.

The prerequisite is a running ssh-agent with a loaded key on the host or runner; the invocation is then docker build --ssh default=$SSH_AUTH_SOCK, or simply --ssh default if the SSH_AUTH_SOCK environment variable is already set. In the Dockerfile, the agent socket is then made available via RUN --mount=type=ssh for exactly that RUN step, so that git clone or SSH-based Composer requires work without a private key ever leaving the host or ending up in the image.

5. Practical example: cloning a private Git repo over SSH

In the example below, an internal module repository is cloned over SSH while known host keys are added at the same time so git does not interactively prompt for confirmation. The StrictHostKeyChecking parameter should not be blanket-disabled in production pipelines but replaced with a pre-known known_hosts entry to avoid man-in-the-middle risks.

It is also important that the base image contains at least an SSH client, since Alpine or Debian Slim images usually do not ship openssh-client by default and must install it beforehand via apk or apt. After cloning in the build stage, only the result, not the SSH access itself, is copied into the next stage.


# syntax=docker/dockerfile:1
FROM alpine:3.20 AS git-fetch

RUN apk add --no-cache git openssh-client && \
    mkdir -p -m 0700 /root/.ssh && \
    ssh-keyscan gitlab.mironsoft.internal >> /root/.ssh/known_hosts

RUN --mount=type=ssh \
    git clone git@gitlab.mironsoft.internal:core/shared-module.git /src/shared-module

FROM php:8.4-fpm-alpine AS runtime
COPY --from=git-fetch /src/shared-module /var/www/html/vendor/mironsoft/shared-module

6. Integrating secrets into Docker Compose builds

docker compose build also supports BuildKit secrets via the secrets key inside a service's build section. This means the secret flag no longer needs to be set manually on every docker build call, but is declared instead in compose.yaml, which improves maintainability especially with multiple services that each need different secrets.

The top-level secrets section defines the source, either a file or an environment variable, while the service-local reference just names it. For production use in CI systems the environment variant is usually preferred, because no temporary files remain on the build machine that could accidentally end up in an artifact or cache directory.


services:
  app:
    build:
      context: .
      secrets:
        - composer_auth
        - npm_token

secrets:
  composer_auth:
    environment: COMPOSER_AUTH
  npm_token:
    environment: NPM_TOKEN

7. Usage in GitHub Actions and GitLab CI

In GitHub Actions, BuildKit secrets combine cleanly with docker/build-push-action via its secrets input, which internally forwards the appropriate --secret flags to the build. Repository or organization secrets are referenced via ${{ secrets.NAME }} and never stored in plaintext in the workflow file, which greatly simplifies audits and secret scanning.

In GitLab CI it works analogously via predefined CI/CD variables that are stored as masked or protected variables in the project and then passed to the docker build call in the job script via --secret id=name,env=VARNAME. In both systems it is important that the respective secret is not accidentally declared as a build ARG instead of a BuildKit secret, because ARGs, as described earlier, end up visibly in the image while secrets do not.


# .github/workflows/build.yml (excerpt)
- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/mironsoft/app:${{ github.sha }}
    secrets: |
      composer_auth=${{ secrets.COMPOSER_AUTH }}
      npm_token=${{ secrets.NPM_TOKEN }}

8. Security considerations and common pitfalls

A common mistake is providing a secret via --mount=type=secret but then printing it within the same RUN command via echo or set -x, causing it to end up in build logs, which are often retained longer than the image itself. Build logs should therefore always be treated as a potential leak source, and set -x should be avoided in security-relevant RUN steps. Equally risky is accidentally copying a certificate or config file loaded via a secret mount into a later stage with COPY, since the content then ends up in a persistent layer after all.

It is also worth noting that secret mounts only work with the BuildKit backend and not with the classic, now-deprecated legacy builder. Since Docker Engine 23.0, BuildKit is the default; in older setups, DOCKER_BUILDKIT=1 must be set explicitly. Anyone using multi-platform builds with buildx should also verify that the buildx builder in use is actually BuildKit-based, since some minimal CI runner images ship only a stripped-down Docker client without full BuildKit support.

9. Best practices and a comparison of methods

As a rule of thumb: anything needed at container runtime belongs in ENV or in a runtime secret mechanism such as Docker Secrets in Swarm mode or Kubernetes Secrets, while anything needed only during the build process, such as registry credentials or SSH keys for private dependencies, should be provided exclusively via BuildKit --secret or --ssh. ARG remains useful for non-critical build parameters such as version numbers or build dates, but never for credentials.

Anyone who consistently uses BuildKit secrets not only significantly reduces an image's attack surface but also simplifies security audits, because a docker history can never expose sensitive values that were never written to a layer in the first place. The table below summarizes the different mechanisms and their suitability for various scenarios.

Mechanism Visible in image Suitable for Recommendation
ARG Yes, permanently in layer history Non-critical build parameters Never for secrets
ENV Yes, permanently in the image Runtime configuration Not for secrets
RUN --mount=type=secret No, only during RUN Auth tokens, passwords, certificates Recommended for build secrets
RUN --mount=type=ssh No, agent forwarding Private Git cloning over SSH Recommended for SSH access

Mironsoft

Container infrastructure, CI pipelines and deployment automation

Docker setups that hold up across the team and in production?

We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.

Dockerfile Review

Systematically optimizing multi-stage builds, layer caching and image size.

Security Audit

Hardening container isolation, secrets handling and image scanning against real attack surfaces.

CI/CD Integration

Building build pipelines, registries and deployment strategies for reproducible releases.

10. Summary

BuildKit Secrets: Key Takeaways

Core problem

ARG and ENV permanently write secrets into the image history, even after an apparent overwrite.

Secrets solution

RUN --mount=type=secret provides files only during a single RUN step, leaving no layer trace.

SSH solution

RUN --mount=type=ssh forwards the local SSH agent for private Git cloning without copying a key.

CI integration

GitHub Actions and GitLab CI pass secrets to --secret flags via masked variables.

11. FAQ: BuildKit Secrets: Key Takeaways

1What is the difference between ARG and BuildKit --secret?
ARG values permanently become part of the image metadata and layer history and can be read out at any time with docker history. A BuildKit secret is mounted as a temporary file only for the duration of a single RUN command and leaves no trace in the final image.
2Do I need special Dockerfile syntax for secret mounts?
Yes, the first line of the Dockerfile must contain the parser directive syntax=docker/dockerfile:1, since secret and SSH mounts are BuildKit extensions and not part of the classic Dockerfile specification.
3How do I pass a secret from an environment variable instead of a file?
With the flag --secret id=name,env=VARNAME on the docker build call. BuildKit then reads the value directly from the environment variable without a file having to be created on disk.
4Does --ssh work without a running SSH agent?
No, an active ssh-agent with a loaded key is required. You can either specify --ssh default=$SSH_AUTH_SOCK explicitly, or simply use --ssh default if SSH_AUTH_SOCK is already set.
5Can I also use secrets with docker compose build?
Yes, via the secrets section in compose.yaml, either as a reference to an environment variable or to a local file. The secrets are then automatically forwarded to the build as BuildKit secrets.
6What happens if I echo the secret inside the RUN command?
Then the value ends up in the build log, which is often retained longer than the image. Secrets should only be consumed inside the RUN command, never printed or logged.
7Is BuildKit enabled by default?
Since Docker Engine 23.0, BuildKit is the default build engine. In older versions, the environment variable DOCKER_BUILDKIT=1 must be set for --secret and --ssh to work at all.
8Can I use multiple secrets in one build?
Yes, the --secret flag can be repeated as many times as needed, each with its own id. Each secret is then mounted under its own path under /run/secrets/ inside the build container.
9How do I avoid a secret-loaded file accidentally ending up in the image?
By never running a COPY or explicit copy operation on the mounted secret file. The mount only exists within the RUN command and is automatically inaccessible afterward, as long as you do not copy it yourself.
10Does this also work with multi-platform builds using buildx?
Yes, as long as the buildx builder in use is actually BuildKit-based. On some minimal CI runner images, a full-featured buildx builder must first be set up with docker buildx create.