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.
Table of Contents
- 1. Why ARG and ENV are unsuitable for secrets
- 2. How RUN --mount=type=secret works
- 3. Practical example: private Composer packages in the build
- 4. SSH agent forwarding with --ssh
- 5. Practical example: cloning a private Git repo over SSH
- 6. Integrating secrets into Docker Compose builds
- 7. Usage in GitHub Actions and GitLab CI
- 8. Security considerations and common pitfalls
- 9. Best practices and a comparison of methods
- 10. Summary
- 11. FAQ
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=
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.