BuildKit’s buildx can securely pass secrets to your build process, but it’s easy to accidentally expose them if you’re not careful.

Here’s how a typical build with secrets looks in practice, and then we’ll dive into the mechanisms.

# syntax=docker/dockerfile:1
FROM alpine:latest
RUN --mount=type=secret,id=mysecret SECRET=$(cat /run/secrets/mysecret) && echo "Secret is $SECRET" >> /app/secret.txt

And the buildx command to run it:

buildx build --secret id=mysecret,src=./mysecret.txt -t myimage .

If mysecret.txt contains verysecretpassword, the output will show Secret is verysecretpassword. Crucially, this secret never appears in the final image layers.

The Problem: Accidental Exposure

The most common way secrets leak is by being written to build cache or, worse, directly into a container filesystem that isn’t ephemeral.

Consider this Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:latest
RUN --mount=type=secret,id=mysecret echo "My secret is $(cat /run/secrets/mysecret)" > /app/secret.txt
RUN cat /app/secret.txt

If you run this with buildx build --secret id=mysecret,src=./mysecret.txt -t myimage ., the output will contain the secret. Even worse, if you were to later inspect the intermediate layers of the image (which are often cached), the RUN cat /app/secret.txt command might be cached with the secret already written to a file within that layer’s filesystem state.

Another common mistake is using --mount=type=cache alongside --mount=type=secret in a way that allows the secret to be written to the cache. BuildKit’s cache is designed to be immutable, but if you’re not careful about how you’re copying or writing data that could be a secret, you might inadvertently persist it.

How BuildKit Handles Secrets

BuildKit’s secret mounting works by providing a temporary, in-memory filesystem accessible only during the RUN instruction that requests it. This filesystem is mounted at /run/secrets/ by default, or wherever you specify with target.

When you use --mount=type=secret,id=mysecret,src=./mysecret.txt, BuildKit reads the content of ./mysecret.txt and makes it available in a temporary, encrypted, in-memory buffer. This buffer is then mounted as a read-only filesystem within the specific RUN instruction. Once the RUN instruction finishes, this mount is unmounted and the data is garbage collected. It is not written to the image layers.

Common Causes and Fixes

  1. Secrets written to a file that persists:

    • Diagnosis: Inspect the Dockerfile. Look for RUN commands that write secret content to files after the --mount=type=secret instruction has finished, or commands that don’t use the secret mount at all but are expected to.
    • Fix: Ensure any RUN command that accesses a secret uses the --mount=type=secret directive. Never directly echo or cat a secret into a file that is intended to be part of the final image layer unless it’s within the scope of the secret mount.
      # INCORRECT (leaks secret to layer)
      FROM alpine:latest
      RUN --mount=type=secret,id=mysecret \
          echo $(cat /run/secrets/mysecret) > /app/leaked_secret.txt
      
      # CORRECT (secret only available during RUN)
      FROM alpine:latest
      RUN --mount=type=secret,id=mysecret \
          echo "Secret processed" > /app/processed.txt
      
      The echo $(cat /run/secrets/mysecret) > /app/leaked_secret.txt line would write the secret to /app/leaked_secret.txt, which then becomes part of the image layer. The corrected version only writes a log message.
  2. Secrets being part of the build cache:

    • Diagnosis: If you’re using --mount=type=cache, and the value being cached could have been derived from a secret, there’s a risk. BuildKit’s cache is keyed by the build step’s content hash. If a RUN command using a secret also produces output that influences the cache key, and that output is problematic, the cache could become a vector.
    • Fix: Avoid caching steps that directly process or output raw secrets. If you must cache data derived from secrets, ensure it’s sanitized or only used in ephemeral contexts.
      # Potentially problematic if 'output.txt' contains sensitive info from MYSECRET
      RUN --mount=type=secret,id=mysecret \
          --mount=type=cache,target=/app/cache \
          SECRET_VAL=$(cat /run/secrets/mysecret) && \
          echo "$SECRET_VAL" > /app/output.txt && \
          echo "$SECRET_VAL" >> /app/cache/data.log
      
      The fix here isn’t a Dockerfile change but a build strategy change: don’t cache steps that operate on secrets, or ensure the cached output is scrubbed.
  3. Using --mount=type=bind with secret files:

    • Diagnosis: Accidentally binding a file containing secrets into the build context using --mount=type=bind instead of --mount=type=secret. Bind mounts are more permanent and can be more easily inspected.
    • Fix: Always use --mount=type=secret for sensitive data. Bind mounts are for general file access.
      # INCORRECT (binds the actual secret file, which might be inspected)
      buildx build --mount type=bind,src=./mysecret.txt,dst=/app/secret.txt -t myimage .
      
      # CORRECT
      buildx build --secret id=mysecret,src=./mysecret.txt -t myimage .
      
      The --mount=type=bind makes ./mysecret.txt available at /app/secret.txt within the container, which is generally not what you want for secrets.
  4. Secrets in docker build arguments (--build-arg):

    • Diagnosis: Using ARG in the Dockerfile and passing values via docker build --build-arg MY_SECRET=.... These are visible in the build history and can be easily inspected.
    • Fix: Use --mount=type=secret exclusively for secrets. ARG is for build-time configuration that isn’t sensitive.
      # INCORRECT (secret exposed via ARG)
      ARG MY_SECRET
      RUN echo "My secret is $MY_SECRET" > /app/secret.txt
      
      # CORRECT (using secret mount)
      RUN --mount=type=secret,id=mysecret \
          echo "Secret is $(cat /run/secrets/mysecret)" > /app/secret.txt
      
  5. Not cleaning up temporary files containing secrets:

    • Diagnosis: Even when using secret mounts correctly, if a RUN command copies the secret from /run/secrets/mysecret to a temporary file and then forgets to rm it, that temporary file might persist if the RUN step is part of a larger multi-stage build or if an error occurs.
    • Fix: Always ensure temporary files are cleaned up within the same RUN instruction that creates them, or use mktemp with careful cleanup.
      # INCORRECT (temp file might persist on error)
      RUN --mount=type=secret,id=mysecret \
          echo $(cat /run/secrets/mysecret) > /tmp/tempsecret.txt && \
          # ... do something with tempsecret.txt ...
      
      # CORRECT (cleanup within the same RUN)
      RUN --mount=type=secret,id=mysecret \
          echo $(cat /run/secrets/mysecret) > /tmp/tempsecret.txt && \
          # ... do something with tempsecret.txt ...
          rm /tmp/tempsecret.txt
      
      Or, more robustly:
      RUN --mount=type=secret,id=mysecret \
          SECRET_VAL=$(cat /run/secrets/mysecret) && \
          TEMP_FILE=$(mktemp) && \
          echo "$SECRET_VAL" > "$TEMP_FILE" && \
          # ... do something with "$TEMP_FILE" ...
          rm -f "$TEMP_FILE"
      
  6. Using docker cp on a running container from a build:

    • Diagnosis: If you run a container from an image that was built with secrets (even if the secrets aren’t supposed to be there), and then accidentally docker cp files from a running container that might have contained transient secret data, you could leak it.
    • Fix: This is more about operational security. Understand what sensitive data could have been transiently present in a running container derived from a build, and avoid docker cp-ing arbitrary files from such containers. Always use --mount=type=secret to ensure secrets are truly ephemeral.

The next hurdle is understanding how BuildKit’s cache itself can be a vector if not managed with secrets in mind.

Want structured learning?

Take the full Buildkit course →