Skip to main content
RunBook Academy

Docker & ContainersXIV Β· SecretsLeaked secrets

Preventing the leak β€” build context hygiene and CI secret scanning

Intermediate⏱ ~18 min

What you'll learn

  • Explain what the build context is and what it carries
  • Write a .dockerignore that denies by default
  • Verify what an image actually contains rather than what you intended
  • Place secret scanning where it blocks rather than where it reports

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

The previous lesson dealt with a secret that is already in an image. Almost every one of those arrived through the same door: COPY . . in a Dockerfile, and a working directory containing rather more than the author had in mind.

What the build context is

docker build . does not read your directory the way a compiler does. It packages the directory β€” the context β€” and hands it to the builder, which is a separate process and possibly a separate machine.

The consequence people miss: the contents of your working directory are the raw material for the build, and the Dockerfile decides what survives into the image. If the Dockerfile says COPY . /app, the answer is β€œall of it”.

Read-only / Safewhat is actually in the directory you are about to build
$ du -sh .[!.]* * 2>/dev/null | sort -h | tail -8
8.0K    .env.production
12K     Dockerfile
1.1M    src
14M     .git
196M    node_modules

Illustrative output

.env.production and .git are the two that matter. The first is a file of credentials by definition. The second is worse than it looks: a git repository contains every version of every file that was ever committed, so a secret committed in March and removed in April is still in .git in August. Copy .git into an image and you have shipped the entire history, including the removals somebody thought had fixed things.

.dockerignore, written the safe way

Most .dockerignore files are a denylist that grew by incident:

# The pattern that keeps failing
.git
.env
node_modules

This is wrong in the way all denylists are wrong. It protects against the three things somebody has already been burnt by, and says nothing about credentials.json, .env.local, id_rsa, terraform.tfstate or kubeconfig β€” none of which existed in the repository when the list was written.

Deny everything and admit what you need:

# Ignore the entire context...
*

# ...then admit exactly what the build requires.
!src/
!public/
!package.json
!package-lock.json
!tsconfig.json

Now a new file in the repository root is excluded by default, and including it is a deliberate, reviewable change to this file. That inversion is the whole point: the failure mode becomes β€œthe build breaks because I forgot to allow a file”, which you find in thirty seconds, instead of β€œa credential shipped”, which you find in six weeks.

Verify, do not assume

A .dockerignore you have not tested is a hypothesis. Two commands settle it.

What the build actually received:

Read-only / Safethe context as the builder sees it
$ docker build --no-cache --progress=plain -t example.com/api:test . 2>&1 | grep -E 'transferring context'
#1 [internal] load build context
#1 transferring context: 1.18MB 0.1s done

Illustrative output

1.18 MB against a directory containing 210 MB is the .dockerignore doing its job. A number close to the full directory size means it is not being applied β€” usually because it is in the wrong place relative to the context root.

What the image actually contains:

Read-only / Safelook for the things that should not be there
$ docker run --rm --entrypoint sh example.com/api:test -c 'ls -A /app; find / -xdev -name .git -maxdepth 4 2>/dev/null'
node_modules
package.json
package-lock.json
src

Illustrative output

No .git, no .env. Run this once per image, not once per project β€” a .dockerignore that was correct last year describes last year’s repository.

Making the pipeline refuse

Hygiene depends on attention, and attention is not a control. The control is a scanner that fails a build.

There are three useful places to put one, and they catch different things:

StageCatchesCost of a catch
Pre-commit hookThe credential before it is committedSeconds. Nothing to clean up.
CI, on the repositoryA credential in the branch or its historyMinutes. Rewrite or rotate.
CI, on the built imageAnything that reached a layer, from any sourceAn hour. Rotate and rebuild.

You want all three, and you want them in that order, because the cost column doubles at every step.

On the repository

gitleaks and trufflehog are the two common choices. Both scan working trees and git history.

#!/usr/bin/env bash
# ci-secret-scan.sh - fail the pipeline on a detected credential.
set -euo pipefail

# --redact keeps the finding out of the CI log, which is itself a
# place secrets leak. A non-zero exit fails the job.
gitleaks detect --source . --redact --exit-code 1

On the image

Repository scanning cannot see a credential that arrived from a CI variable, a cached layer, or a base image. Scan the artefact you are about to publish:

#!/usr/bin/env bash
set -euo pipefail

IMAGE="example.com/api:${CI_COMMIT_SHA}"

docker build -t "$IMAGE" .
trivy image --scanners secret --exit-code 1 "$IMAGE"
docker push "$IMAGE"

The ordering matters: scan between build and push. A scanner that runs after docker push is a reporting tool, not a gate β€” by the time it fires, the layer is in the registry and the previous lesson’s remediation applies.

The leaks a scanner will not catch

Two more that are worth an explicit check:

  • Image labels. LABEL values are stored in the image config exactly like ENV. A build that stamps a label with a CI variable can stamp a token into every image it produces.
  • Compose .env interpolation. Compose reads .env from the project directory to substitute ${VAR} in the Compose file. That file is not sent to any container, but it is a credential file in the repository root, and it is the single most commonly committed secret in Docker projects. It belongs in .gitignore and .dockerignore.
  1. Invert .dockerignore to deny by default and admit explicitly.
  2. Confirm the context size the builder reports is what you expect.
  3. Inspect a built image for .git, .env and stray key files.
  4. Add a pre-commit secret scanner so the cheap catch happens first.
  5. Add an image scanner between build and push, with a non-zero exit failing the job.
  6. Ban set -x and build args for credentials; use secret mounts.

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. Why is copying `.git` into an image particularly dangerous?

  2. Q2. What makes a `*` plus `!` allowlist in `.dockerignore` safer than listing files to exclude?

  3. Q3. Which leaks would a repository secret scanner fail to catch? Select all that apply.

  4. Q4. An image secret scanner that runs after `docker push` is a reporting tool rather than a gate.

Passing score: 75%. Answers are checked in this browser.

Where next

That completes the secrets part: why env vars fail, build and runtime delivery, external managers, rotation, forensics on a leak, and prevention. The supply-chain part picks up the related question of trusting what is inside an image you did not build.