← Docker articles

ACE-U field notes

Keeping secrets out of container images, builds and logs

A practical playbook for build-time tokens, runtime credentials and log redaction in a Java service, using BuildKit secrets and mounted files.

Shubham Anandani··5 min read

Problem

Secrets leak in boring ways. A token passed as a build argument ends up in image history. An exception handler prints the whole configuration. An environment variable set for a quick test is still there six months later. The fix is not one tool. It is a set of small defaults that make the safe path the easy path, covering the build, the runtime and the logs.

Options and trade-offs

  • ARG and ENV for credentials: simple, but both are stored in image metadata and can be read back from the image.
  • BuildKit secret mounts: the secret is available to one build step and is never written to a layer.
  • Runtime secrets as environment variables: convenient, but they show up in diagnostics and process listings.
  • Runtime secrets as mounted files: easier to restrict, audit and rotate without rebuilding.

My recommendation

Use BuildKit secret mounts for anything a build needs, mount credentials as files at runtime, and make the application hard to leak from by redacting secrets in its own types. Plan rotation as a restart, not a rebuild.

Worked example

A build step that reads a private repository token from a secret mount and exposes it only to that command:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN --mount=type=secret,id=repo_token \
    REPO_TOKEN="$(cat /run/secrets/repo_token)" ./gradlew --no-daemon build
docker build --secret id=repo_token,src=$HOME/.tokens/repo -t demo/app:dev .

# Verify nothing leaked into the layers
docker history --no-trunc demo/app:dev | grep -i token || echo "no token in history"

At runtime, mount the secret as a file and tell the app where it is:

services:
  api:
    image: demo/api:1.4.2
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

In the application, read from the file when it is configured, and make the configuration type safe to print:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public final class Secrets {
    private Secrets() { }

    public static String read(String name) throws IOException {
        String path = System.getenv(name + "_FILE");
        if (path != null) {
            return Files.readString(Path.of(path)).strip();
        }
        String value = System.getenv(name);
        if (value == null) {
            throw new IllegalStateException("Missing secret: " + name);
        }
        return value;
    }
}

record DbConfig(String url, String password) {
    @Override
    public String toString() {
        return "DbConfig[url=" + url + ", password=****]";
    }
}

Checklist

  1. Never pass credentials through ARG or ENV in a Dockerfile.
  2. Use RUN --mount=type=secret, and check docker history afterwards.
  3. Mount runtime secrets as files with read-only access.
  4. Redact secrets in your own types so an accidental log line or stack trace cannot print them.
  5. Rotate by replacing the file and restarting the container, not by rebuilding the image.
  6. Run a secret scan on source and images and treat a hit as a release blocker.

Where this doesn't apply

A file-based secret in Compose is a bind mount for local use. In a real deployment, use your platform's secret store rather than a file in your repository, and keep that directory in .gitignore.

One honest limitation

Anyone who can run a shell in the container can read its mounted secrets. A secret mount removes the secret from the image, not from the running process.

Sources

  • Docker Docs: Build secrets
  • Docker Docs: Manage secrets in Compose
  • OWASP Cheat Sheet: Secrets Management