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.
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
- Never pass credentials through ARG or ENV in a Dockerfile.
- Use RUN --mount=type=secret, and check docker history afterwards.
- Mount runtime secrets as files with read-only access.
- Redact secrets in your own types so an accidental log line or stack trace cannot print them.
- Rotate by replacing the file and restarting the container, not by rebuilding the image.
- 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