DevOps engineer comparing root and Dockerfile-specific .dockerignore rules and validating included and excluded files across controlled Docker builds.

Your Dockerfile Has Its Own Ignore File. Which Exclusions Still Apply?

Mon, Oct 12, 2026

A repository can display a reassuring root .dockerignore while one build silently follows another exclusion policy. The trigger is ordinary: a maintainer adds docker/build.Dockerfile.dockerignore beside docker/build.Dockerfile to tune one entrypoint. The root file still excludes payload/token.secret, code review still sees that rule, and the build still succeeds. Yet the exported payload may now contain the synthetic token because Docker selects the Dockerfile-specific file instead of combining it with the root rules.

That is a release-control problem, not merely a filename fact. The bounded question is: when the selected Dockerfile acquires its own nonempty ignore file, which source paths remain eligible for COPY payload/ /payload/, and does the resulting filesystem match the policy declared before the build? The answer requires more than a green command. It requires the selected rule-file bytes, stable source bytes, explicit context and builder selection, and an exact manifest of every regular file exported for each case.

This article defines a four-case planned reproduction: root only, an incomplete specific override, a repaired specific policy, and root behavior restored by removing the override. Docker was unavailable during preparation, so every case result below is an expected laboratory outcome, not an observed integration result. The release decisions are nevertheless explicit: retain a suitable root-only contract, repair the selected specific file, reject an artifact containing the canary, or mark the run HOLD when the evidence is incomplete.

1. Identify the policy Docker actually selects

The filename relationship is exact. Selecting docker/build.Dockerfile makes docker/build.Dockerfile.dockerignore its Dockerfile-specific ignore file when that file exists. In this experiment, the context-root .dockerignore is the fallback. A file named docker/.dockerignore is neither the root fallback nor the required specific filename.

Docker’s build-context documentation states that a Dockerfile-specific ignore file sits beside the Dockerfile and takes precedence over the .dockerignore at the context root. “Precedence” matters: the safe operating assumption is selection, not concatenation. The current BuildKit master source provides corroborating evidence: ReadEntrypoint attempts to read <selected-Dockerfile>.dockerignore; the root file is read only when ignore content has not already been selected; and MainContext applies the resulting exclusions. Because that source is a moving branch, it does not identify or certify the unexecuted builder in this laboratory. Both sources were accessed October 12, 2026. Unless stated otherwise, the remaining linked Docker documentation in this article was also accessed on that date and is treated as living documentation rather than a dated release announcement.

This is a build-time selection issue. It is distinct from runtime files hidden by Docker bind mounts, where a completed image stays fixed and a later mount changes what a container sees.

Selection happens before the payload is copied

Keep three stages separate. First, the selected ignore rules shape the accessible default context. Second, the fixed COPY requests a particular directory from that context. Third, the local exporter exposes the built root filesystem for inspection. A result at one stage does not automatically prove every detail of the others.

2. Declare the payload policy before running anything

An oracle written after seeing output is only a description of what happened. This fixture therefore declares source bytes, expected exported paths, and acceptable statuses before any Docker command runs. The root policy excludes the canary while permitting the local notes file. The repaired specific policy excludes both. The incomplete specific file excludes notes only, deliberately creating a counterexample in which one unwanted path disappears while another appears.

Case

Specific ignore file

Expected regular paths

Matched status

ROOT_ONLY

Absent

payload/public.txt; payload/notes.txt

ROOT_POLICY_MATCH

SPECIFIC_INCOMPLETE

Excludes payload/notes.txt

payload/public.txt; payload/token.secret

EXPECTED_INCLUSION_POLICY_REJECT

SPECIFIC_REPAIRED

Excludes notes and token

payload/public.txt

SPECIFIC_POLICY_MATCH

ROOT_RESTORED

Absent again

payload/public.txt; payload/notes.txt

ROOT_POLICY_RESTORED

Every regular file must also match its predeclared SHA-256 digest. Path equality without byte equality would allow a changed payload to masquerade as the same case. Conversely, a single expected output for all four cases would erase the policy distinction the laboratory is intended to test.

The oracle describes two policy contracts rather than one universal “clean” artifact. Root selection intentionally retains notes; repaired-specific selection intentionally removes them. That distinction prevents a reviewer from treating fewer files as automatically safer or more correct. The release requirement is the declared file set for the selected entrypoint, not the smallest possible directory.

The expected directory list is part of the oracle as well. A correct pair of regular-file hashes inside an unexpected nested directory, or an extra empty directory, is still a mismatch. This strictness keeps the comparison deterministic and makes later scope changes visible instead of silently normalizing them away.

EXPECTED_INCLUSION_POLICY_REJECT is intentionally a matched counterexample. It means the export agrees with the documented selection behavior while violating the intended release policy. That artifact is rejected even though the experiment succeeded. Broader cloud-native DevSecOps practices can motivate delivery controls, but they are not a substitute for this path-and-byte oracle.

3. Establish one controlled local execution context

The protocol requires an installed Docker CLI and engine, Docker Buildx, Python 3.10 or later, and an already-running Buildx builder using the docker driver. The effective Docker endpoint must be an actually controlled local UNIX endpoint, and the builder must support the requested linux/amd64 target and local exporter. A unix:// string is necessary for this fixture’s precondition, but it is not by itself proof that no proxy, redirection, or unusual host arrangement exists.

Before execution, the operator should be able to answer four concrete questions:

  • Which named Docker context resolves the CLI request?

  • Which named Buildx builder is already running, and which driver does it use?

  • Which endpoint and BuildKit version does inspection report?

  • Can that builder produce a single-platform local export for linux/amd64?

Those are preconditions, not conclusions inferred from a later successful build. A build can succeed against the wrong endpoint, and a familiar builder name can resolve differently after configuration changes.

There is no RUN instruction, so this is not an emulation benchmark. Platform selection remains explicit because it belongs in the command record, but the fixture does not exercise generated machine code or userspace execution. If Docker, Buildx, Python, the target platform, or the controlled endpoint is unavailable, publish the procedure as planned and mark execution HOLD. Do not install infrastructure ad hoc and then present the resulting environment as the controlled baseline.

The related Docker and CI/CD prerequisites are prior knowledge; this test assumes readers already operate a basic build environment.

Record the builder without starting another one

Pass both --context and --builder. Docker contexts carry endpoint configuration, and environment variables can override ordinary CLI selection; the fixture removes the names DOCKER_HOST, DOCKER_CONTEXT, BUILDX_BUILDER, and DOCKER_DEFAULT_PLATFORM from the child environment while recording only which names were removed, never their values. It then inspects the named context and records client and server versions. Docker’s context documentation confirms that context selection can be changed by environment configuration.

docker buildx inspect is called without --bootstrap because the precondition is an existing running builder. Its output can expose driver, endpoint, status, and BuildKit version. The parser intentionally accepts one narrow, single-node text shape and holds on a different driver, stopped status, unfamiliar endpoint, missing BuildKit field, or changed format. Such a parser failure is an environment-evidence failure, not a conclusion about .dockerignore. The protocol creates no context, builder, or global selection.

4. Freeze the source tree and the four variants

The driver creates one owned temporary run directory with three siblings: context, evidence, and exports. Only context is passed as the build context. Across four cases, the context root, Dockerfile, root ignore file, payload bytes, builder, platform, and invocation structure remain fixed. The only changing input is whether docker/build.Dockerfile.dockerignore is absent or which exact nonempty bytes it contains.

The intended source layout is small enough to review independently:

context/

├── .dockerignore

├── docker/

│   ├── build.Dockerfile

│   └── build.Dockerfile.dockerignore   # present only in cases 2 and 3

└── payload/

    ├── notes.txt

    ├── public.txt

    └── token.secret

The specific file is deliberately nonempty whenever present. That avoids conflating “selected but containing no exclusions” with a missing-file fallback. Literal bytes also avoid line-ending, encoding, template-rendering, or editor-normalization disputes.

The three literal payloads each end with one LF byte:

  • payload/public.txt: PUBLIC-PAYLOAD-v1\n

  • payload/notes.txt: LOCAL-NOTES-v1\n

  • payload/token.secret: SYNTHETIC-DOCKERIGNORE-CANARY-NOT-A-CREDENTIAL\n

The Dockerfile is exactly:

FROM scratch
COPY payload/ /payload/

There is no remote syntax directive, pullable base, external stage, ADD, COPY --from, named context, build argument, secret or SSH forwarding, or RUN. Docker’s COPY instruction reference says that a source without --from comes from the default build context, its path is resolved from that context’s root, and copying a directory copies its contents. Therefore payload/ is not relative to the docker/ directory containing the Dockerfile. Patterns likewise describe paths under the context root. These choices narrow the experiment; they do not prove a complete network fence.

Keep evidence outside the context and exports fresh

Command records, expectations, metadata, and result files live outside the context so they cannot become payload inputs. Each case uses a destination that must not already exist. The local exporter writes the build result’s root filesystem into a client-side directory; the fixture treats freshness as its own control rather than claiming the exporter removes stale files.

Before and after every build, the driver walks the source tree, rejects symlinks and other unexpected entry types, and compares exact path-to-digest maps. It performs the same type check on exports. No concurrent writer is in scope, and one reused output directory is never used to infer exclusion from leftovers.

5. Run the complete evidence-producing fixture

Save the following as dockerignore_lab.py. Run it only after identifying an existing controlled local context and builder:

python dockerignore_lab.py --context LOCAL_CONTEXT --builder LOCAL_BUILDER

Replace the selectors with simple existing names. The command above is an instruction, not a claim that the preparation environment executed it. The driver uses explicit builder, platform, Dockerfile, plain progress, no cache, metadata file, and local output options. Docker’s docker buildx build reference documents these controls, including that --metadata-file writes a JSON object and --output type=local exports to the client filesystem. --network would govern RUN networking; because there is no RUN, omitting it neither widens this fixture nor proves that all build-related communication is impossible.

import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time


p = argparse.ArgumentParser()

p.add_argument("--context", required=True)

p.add_argument("--builder", required=True)

a = p.parse_args()

for value in (a.context, a.builder):

    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value):

        p.error("Use a simple existing context/builder name.")

root = Path(tempfile.mkdtemp(prefix="dockerignore-policy-"))

ctx, evidence, exports = (root / x for x in ("context", "evidence", "exports"))

for directory in (ctx, evidence, exports):

    directory.mkdir()

print("Evidence retained at:", root, flush=True)

env = os.environ.copy()

removed = {}

for key in ("DOCKER_HOST", "DOCKER_CONTEXT", "BUILDX_BUILDER",

            "DOCKER_DEFAULT_PLATFORM"):

    if key in env:

        removed[key] = env.pop(key)

docker = ["docker", "--context", a.context]

counter = 0

 

def save(path, value):

    path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")

 

def digest(value):

    return hashlib.sha256(value).hexdigest()

 

def oracle(files):

    dirs = set()

    for name in files:

        parent = Path(name).parent

        while str(parent) != ".":

            dirs.add(parent.as_posix())

            parent = parent.parent

    return {"files": {k: digest(v) for k, v in sorted(files.items())},

            "dirs": sorted(dirs)}

 

def tree(folder):

    files, dirs = {}, []

    def visit(here):

        for child in sorted(here.iterdir()):

            name = child.relative_to(folder).as_posix()

            mode = child.lstat().st_mode

            if stat.S_ISDIR(mode):

                dirs.append(name)

                visit(child)

            elif stat.S_ISREG(mode):

                files[name] = digest(child.read_bytes())

            else:

                raise RuntimeError("Unexpected entry type: " + name)

    if not folder.is_dir() or folder.is_symlink():

        raise RuntimeError("Missing or invalid tree root: " + str(folder))

    visit(folder)

    return {"files": files, "dirs": sorted(dirs)}

 

def capture(folder):

    try:

        return tree(folder)

    except Exception as exc:

        return {"capture_error": str(exc)}

 

def command(label, args, timeout=60):

    global counter

    counter += 1

    record = {"argv": args, "cwd": str(root), "started_utc":

              time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}

    try:

        result = subprocess.run(args, cwd=root, env=env, capture_output=True,

                                timeout=timeout, check=False)

        record.update(exit_code=result.returncode, stdout=result.stdout.decode(

            "utf-8", "replace"), stderr=result.stderr.decode("utf-8", "replace"))

    except subprocess.TimeoutExpired as exc:

        record.update(exit_code=None, timed_out=True,

                      stdout=(exc.stdout or b"").decode("utf-8", "replace"),

                      stderr=(exc.stderr or b"").decode("utf-8", "replace"))

    except OSError as exc:

        record.update(exit_code=None, start_error=str(exc))

    record["ended_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

    save(evidence / ("%02d-%s.json" % (counter, label)), record)

    if record.get("exit_code") != 0:

        raise RuntimeError("Command failed or timed out: " + label)

    return record["stdout"]

 

def environment(label):

    context = json.loads(command(label + "-context",

        docker + ["context", "inspect", a.context]))

    if len(context) != 1 or context[0]["Name"] != a.context:

        raise RuntimeError("Unexpected context identity")

    host = context[0]["Endpoints"]["docker"]["Host"]

    if not host.startswith("unix://"):

        raise RuntimeError("This fixture requires its documented local UNIX endpoint")

    versions = json.loads(command(label + "-versions",

        docker + ["version", "--format", "{{json .}}"] ))

    buildx = command(label + "-buildx", docker + ["buildx", "version"]).strip()

    inspected = command(label + "-builder",

        docker + ["buildx", "inspect", a.builder])

    fields = {}

    for line in inspected.splitlines():

        match = re.match(r"^(Name|Driver|Endpoint|Status|BuildKit):\s*(.+)$",

                         line.strip())

        if match:

            fields.setdefault(match[1], []).append(match[2].strip())

    if (fields.get("Driver") != ["docker"] or

        fields.get("Status") != ["running"] or

        len(fields.get("Endpoint", [])) != 1 or

        fields["Endpoint"][0] not in (a.context, host) or

        len(fields.get("BuildKit", [])) != 1):

        raise RuntimeError("Unsupported builder topology or inspection format")

    return {"context": a.context, "host": host, "builder": fields,

            "client": versions["Client"]["Version"],

            "server": versions["Server"]["Version"], "buildx": buildx}

 

payload = {

    "payload/public.txt": b"PUBLIC-PAYLOAD-v1\n",

    "payload/notes.txt": b"LOCAL-NOTES-v1\n",

    "payload/token.secret": b"SYNTHETIC-DOCKERIGNORE-CANARY-NOT-A-CREDENTIAL\n",

}

fixed = {**payload,

         "docker/build.Dockerfile": b"FROM scratch\nCOPY payload/ /payload/\n",

         ".dockerignore": b"payload/token.secret\n"}

specific_name = "docker/build.Dockerfile.dockerignore"

cases = [

    ("ROOT_ONLY", None, ("public.txt", "notes.txt"), "ROOT_POLICY_MATCH"),

    ("SPECIFIC_INCOMPLETE", b"payload/notes.txt\n",

     ("public.txt", "token.secret"), "EXPECTED_INCLUSION_POLICY_REJECT"),

    ("SPECIFIC_REPAIRED", b"payload/notes.txt\npayload/token.secret\n",

     ("public.txt",), "SPECIFIC_POLICY_MATCH"),

    ("ROOT_RESTORED", None, ("public.txt", "notes.txt"), "ROOT_POLICY_RESTORED"),

]

expected = {}

for name, rules, included, status in cases:

    source = dict(fixed)

    if rules is not None:

        source[specific_name] = rules

    expected[name] = {

        "source": oracle(source),

        "export": oracle({"payload/" + f: payload["payload/" + f]

                          for f in included}),

        "specific_text": None if rules is None else rules.decode(),

        "matched_status": status,

    }

save(evidence / "expected-before-builds.json", expected)

report = {"status": "HOLD", "root": str(root), "python": sys.version,

          "platform": "linux/amd64",

          "removed_environment_override_names": sorted(removed),

          "cases": [{"id": x[0], "status": "NOT_RUN"} for x in cases]}

save(evidence / "result.json", report)

current = None

out = None

try:

    for name, value in fixed.items():

        path = ctx / name

        path.parent.mkdir(parents=True, exist_ok=True)

        path.write_bytes(value)

    before = environment("before")

    report["environment_before"] = before

    for index, (name, rules, included, matched_status) in enumerate(cases):

        current = report["cases"][index]

        current["status"] = "RUNNING"

        out = exports / name

        specific = ctx / specific_name

        if rules is None:

            specific.unlink(missing_ok=True)

        else:

            specific.write_bytes(rules)

        current["source_before"] = tree(ctx)

        save(evidence / "result.json", report)

        if current["source_before"] != expected[name]["source"]:

            raise RuntimeError("Source drift before " + name)

        if out.exists():

            raise RuntimeError("Export destination must be fresh")

        command(name, docker + [

            "buildx", "build", "--builder", a.builder,

            "--platform", "linux/amd64", "--progress=plain", "--no-cache",

            "-f", str(ctx / "docker/build.Dockerfile"),

            "--metadata-file", str(evidence / (name + "-build.json")),

            "--output", "type=local,dest=" + str(out), str(ctx)], timeout=180)

        current["metadata"] = json.loads(

            (evidence / (name + "-build.json")).read_text())

        if not isinstance(current["metadata"], dict):

            raise RuntimeError("Missing or invalid exporter metadata object")

        current["source_after"] = tree(ctx)

        current["export"] = tree(out)

        if current["source_after"] != expected[name]["source"]:

            raise RuntimeError("Source drift after " + name)

        if current["export"] != expected[name]["export"]:

            raise RuntimeError("Export does not match independent oracle: " + name)

        current["status"] = matched_status

        save(evidence / "result.json", report)

    report["environment_after"] = environment("after")

    if report["environment_after"] != before:

        raise RuntimeError("Environment selection or version changed")

    report["status"] = "REPRODUCTION_MATCH"

except Exception as exc:

    report["error"] = str(exc)

    if current is not None:

        current["status"] = "HOLD"

        current["partial_source"] = capture(ctx)

        if out is not None:

            current["partial_export"] = capture(out)

finally:

    save(evidence / "result.json", report)

print(json.dumps({"status": report["status"], "evidence": str(evidence)}))
raise SystemExit(0 if report["status"] == "REPRODUCTION_MATCH" else 1)

Preserve a failed run instead of manufacturing a pass

The fixture writes expected-before-builds.json and an initial HOLD result before contacting Docker. Every command gets an argument vector, working directory, UTC boundaries, exit status, stdout, and stderr record. On a later failure it retains the source tree, any partial export it can safely walk, completed case records, and the evidence directory printed at startup. It exits nonzero unless all four cases and the final environment comparison match.

A client-side timeout means only that this Python process stopped waiting. It does not prove the daemon canceled an in-flight solve. Preserve the printed directory, establish daemon status, and investigate before another run. Likewise, adapt the narrow inspection parser only through a reviewed change that documents the actual CLI shape; never weaken it silently to obtain a pass.

For the local exporter, an empty JSON metadata object is acceptable because metadata varies by exporter. A missing file, malformed JSON, or non-object value is an evidence failure. The driver performs no cache pruning or directory deletion. Its Python syntax was checked during article preparation. Docker was unavailable in that preparation environment: command discovery found no Docker binary, and no integration case, version record, BuildKit output, manifest, duration, or digest was observed.

6. Establish the root-only baseline

ROOT_ONLY begins with the specific ignore path absent. Absence is a state to record, not an empty file to substitute: a zero-byte specific file would still be a successfully read Dockerfile-specific file and could therefore change selection behavior. The source oracle requires exactly the three payload files, docker/build.Dockerfile, and the root .dockerignore; it requires no docker/build.Dockerfile.dockerignore entry.

The expected export contains only:

Path

Required byte content

payload/public.txt

PUBLIC-PAYLOAD-v1\n

payload/notes.txt

LOCAL-NOTES-v1\n

payload/token.secret must be absent, and the export must contain no extra regular file, directory, symlink, device, socket, or other unexpected type beyond the declared payload directory and files. The driver compares hashes rather than trusting displayed text.

The baseline answers three independent questions. The source record proves that the canary existed in the local context tree before the build. The selected-policy condition proves that no Dockerfile-specific file occupied the sibling filename. The export record proves that the resulting root filesystem omitted the canary while retaining both approved root-policy files. No single one of those records can replace the other two.

The root .dockerignore itself remains in the source manifest even though it is not expected in the exported filesystem. The Dockerfile requests only payload/, so neither ignore file is part of the output contract. That distinction prevents a reviewer from treating absence of .dockerignore in the export as evidence that it was not selected.

This baseline does not create the oracle for later cases. Its expected manifest already exists in expected-before-builds.json, derived from declared fixture bytes. That separation matters because an incorrect baseline observation should produce HOLD, not rewrite subsequent expectations. The source-before and source-after records must also prove that the root ignore bytes and Dockerfile bytes remained unchanged at their exact paths.

A build error, inaccessible builder, unsupported flag, missing metadata file, invalid export tree, or timeout is HOLD. Seeing public.txt alone is not enough: it does not establish whether notes or the canary are present. The full path-and-type manifest, exact digests, command record, and stable source tree are required. Because Docker was unavailable during preparation, no BuildKit progress lines or exported files are presented here as observations.

7. Demonstrate the incomplete override's unsafe inclusion

For SPECIFIC_INCOMPLETE, the driver creates a nonempty file at exactly docker/build.Dockerfile.dockerignore containing one line:

payload/notes.txt

Everything else stays fixed. Under the documented precedence rule, the expected export contains payload/public.txt and payload/token.secret, while payload/notes.txt disappears. Both surviving files must match the declared bytes and SHA-256 digests.

A one-sided observation would be ambiguous. If notes disappeared but the canary were also absent, the result could be consistent with an undocumented union, another policy layer, or a source mismatch. If the canary appeared but notes remained, the specific file might have been ignored, malformed, or resolved from the wrong place. Requiring both changes, plus stable source hashes, sharply narrows the interpretation.

The canary name looks sensitive to exercise the policy, but its bytes explicitly state that it is not a credential. The test should remain safe to retain in evidence systems and code review. Substituting a real token would add risk without improving the precedence proof.

The paired change is stronger evidence than either side alone. Notes disappearing shows that the specific file was found, parsed, and applied to the context consumed by this build. The token appearing shows that the root rule payload/token.secret was not combined with that selected rule set. A misspelled or unused specific file would be expected to leave notes present and the token absent, matching root behavior instead of this counterexample.

Docker’s build-context documentation explicitly says the specific file takes precedence over the root file, and the moving BuildKit source corroborates a single selected ignore-content path rather than a merge step. That is why the expected state is selection, not union. The source inspection remains corroboration only; an actual run must record its installed client, engine, Buildx, and BuildKit versions.

EXPECTED_INCLUSION_POLICY_REJECT therefore carries two truths. The reproduction would match expected engine behavior, but the candidate payload would violate the intended exclusion policy because the synthetic canary is positively present. A zero exit code authorizes neither release nor reinterpretation. Reject that exported artifact, retain it as controlled evidence, and repair the selected policy.

8. Repair the selected specific policy

The repair is explicit. Keep the notes exclusion and add the token exclusion to the selected file:

payload/notes.txt
payload/token.secret

Build into the fresh SPECIFIC_REPAIRED destination. The expected export has one regular file, payload/public.txt, with exactly PUBLIC-PAYLOAD-v1\n; the only expected directory is payload. Notes and the synthetic token must both be absent. The source manifest must show unchanged payload bytes, unchanged root .dockerignore hash, unchanged Dockerfile hash, and the new two-line specific-file hash.

Do not describe this result as Docker merging root and specific rules. The maintainer has duplicated the required token exclusion into the file Docker selects and has retained that file’s independent notes exclusion. The root file remains visible but is not the source of the expected two-rule behavior for this case.

Two explicit contracts are now available. A root-only entrypoint inherits whatever the repository root declares and exports notes in this fixture. A specific-policy entrypoint owns a complete rule set for its selected Dockerfile and excludes notes here. Neither contract is inherently superior; each is acceptable only when it matches the application’s intended build payload.

Review the repaired file as policy source, not as a patch layered over the root file. Its contents must be understandable without mentally importing root rules, because Docker’s selection behavior does not perform that import.

This choice creates a maintenance contract. Repository owners must decide whether each Dockerfile-specific policy is independently complete or whether the entrypoint should rely on the root fallback by having no specific file. When common exclusions change, every independently complete specific policy may need a reviewed update. The laboratory does not prescribe an untested include directive or inheritance feature.

A match earns SPECIFIC_POLICY_MATCH, which means the narrow exported-file contract is satisfied for these literal paths and bytes. It does not prove every sensitive path has been anticipated, that unrelated Dockerfiles use equivalent rules, that a prior cache contains nothing, or that a real credential would be safe. The canary is deliberately synthetic, and one repaired canary tests one declared policy condition.

9. Remove the override and verify root behavior returns

The fourth case removes only the owned path docker/build.Dockerfile.dockerignore. It does not edit the root file, Dockerfile, payload, context root, builder, platform, or command template. The expected selection returns to the context-root .dockerignore, so payload/public.txt and payload/notes.txt reappear with their original bytes while payload/token.secret is absent.

The driver compares this output first with the independently declared ROOT_RESTORED oracle. It can then compare the resulting manifest conceptually with ROOT_ONLY as a useful control. Agreement between two observed exports is not itself independent proof: both could be identically wrong. The pre-build oracle, source manifests, and recorded selection conditions supply the independent basis.

Removal is materially different from truncating the specific file. An empty specific file would still occupy the selected sibling path and, under the selection model being tested, would provide no exclusions rather than falling back to root rules. The control therefore uses unlink, records the path as absent in the source oracle, and requires the root-policy payload to return.

The fourth case also catches accidental state leakage in the fixture. If the repaired specific file remained present, notes would stay absent. If a previous export directory were reused, stale files could make either inclusion or exclusion look plausible. Fresh output and a source-before manifest make those mistakes observable.

ROOT_POLICY_RESTORED validates this bounded rule-selection cycle. It is not rollback evidence for a deployment, registry tag, running container, or release already consumed elsewhere. All earlier export directories remain in the evidence package, including the rejected counterexample. No claim is made that removing the specific file erased material from a BuildKit cache, previous export, log, snapshot, or other retained system.

Root-only selection is an acceptable repair only when notes being included agrees with the intended artifact contract. A team that must exclude both notes and token should keep the repaired specific policy or change the root contract and remove the override under a separately declared oracle. The test distinguishes these legitimate file sets; it does not decide which product requirement the repository ought to adopt.

10. Reconcile what each evidence layer establishes

A useful evidence package connects each fact to a limit. More logs are not automatically better evidence.

Evidence record

What it establishes

What it does not establish

Specific-file bytes or recorded absence

Which owned variant existed at the selected filename

That the builder applied it successfully

Source manifests before and after

Fixed paths, types, and bytes; no detected source drift

Absence of an unobserved concurrent change outside the bounded walk

Recorded argv, context, builder, endpoint, platform, and versions

Which client-side request and inspected environment were selected

Universal behavior of other builders or future versions

Exit status, stdout, stderr, timeout flag

Whether the client command reported success, failure, or uncertainty

Release acceptability or daemon cancellation after timeout

Local export tree and digests

Exact regular files and directories in this exported root filesystem

Registry contents, runtime visibility, cache erasure, or transfer history

Case status

Comparison with the predeclared oracle and policy disposition

General secret protection beyond this fixture

Separate context eligibility, COPY reachability and exported bytes

Docker describes the build context as files the build can access and notes that a local builder loads files as needed. COPY payload/ /payload/ requests the eligible contents of that entire synthetic directory, so this fixture’s exported file set is meaningful for the three declared payload files. The local exporter then exposes the resulting root filesystem without loading or starting a container.

Still, an absent export path does not prove a file was never enumerated, transmitted, cached, retained, or available through some other mechanism. Progress byte counts do not identify every path. Files outside payload/ might be eligible yet never requested by COPY. The correct claim is narrower: under the recorded source, selected rules, command, and exporter, the exported filesystem did or did not contain the declared paths and bytes.

11. Apply explicit acceptance and HOLD criteria

Apply decisions in order. First validate the execution preconditions and source invariants. Next validate each export against its independent oracle. Only then apply the release-policy disposition attached to the matched case. This ordering prevents a desirable-looking file set from overriding missing environment evidence or source drift.

A complete REPRODUCTION_MATCH requires all four expected case statuses, valid metadata capture for every build, stable source manifests before and after each command, fresh export destinations, exact export manifests, and identical recorded environment selection before and after the sequence. It means the bounded reproduction matched its oracle. It does not mean all four artifacts are releasable: the incomplete-specific export remains an intentional rejection.

Evidence outcome

Release decision

Canary present in SPECIFIC_INCOMPLETE with notes absent and exact bytes

REJECT that artifact; retain the counterexample

SPECIFIC_REPAIRED contains only exact public.txt

Selected specific contract is ACCEPTABLE for this narrow payload policy

ROOT_ONLY or ROOT_RESTORED contains exact public plus notes, no canary

Root contract is ACCEPTABLE only when notes belong in the artifact

Unexpected path, digest, type, or directory

HOLD

Source drift before or after a build

HOLD

Missing or invalid metadata, capture failure, command failure, timeout

HOLD

Unsupported driver, topology, endpoint, status, platform, or inspect format

HOLD

Observed engine behavior conflicts with current documentation

Preserve versions and raw evidence; HOLD pending reconciliation

A failed command is not proof that an exclusion protected a file. The failure could precede context evaluation, arise from an option mismatch, or leave daemon work uncertain. Stop after the unresolved failure; do not continue later cases merely to obtain a favorable final line.

This gate fits within existing CI/CD quality gates and artifact traceability, but its status must remain independent of a generic “build succeeded” check. Store the oracle, command evidence, result JSON, and export manifest together so a reviewer can distinguish engine conformance from release authorization.

12. Recover through a fresh build and retain the counterexample

There are two deliberate repairs. First, update the selected Dockerfile-specific file so it explicitly contains every exclusion required by that entrypoint’s contract. Second, remove the specific file so the root policy is selected again. Choose according to the intended artifact: the repaired-specific case excludes notes and token; the restored-root case permits notes while excluding the token.

Record the repair as a reviewable source change. The diff should show either the added token line in the selected specific file or removal of that file, and the expected manifest should show why the choice is correct. A verbal assurance that “the root file already excludes it” is insufficient when the specific file remains selected.

After either change, rebuild into a destination that has never existed and rerun the exact path, type, and digest comparison. Preserve the earlier SPECIFIC_INCOMPLETE command record, source manifest, specific-file bytes, rejected export, and status. That counterexample explains why the policy changed and prevents a later reviewer from mistaking the repair for an arbitrary file-list adjustment.

Do not manually delete payload/token.secret from the already-exported candidate and relabel the directory as a correct build. That would break provenance between selected rules, build invocation, and artifact. The repair must be expressed in controlled input and reproduced through a fresh build.

If the Python client timed out, establish whether the daemon still has related work before operational cleanup. The script’s timeout cannot answer that question. Cleanup of the owned temporary directory may occur only after evidence review and is intentionally outside the executable demonstration. The playbook performs no shared-cache pruning, broad recursive deletion, registry action, credential rotation, or remediation claim. The token is not a credential, and the experiment does not imply that any real secret was exposed.

13. Assign ownership when another Dockerfile is added

Build maintainers own entrypoint names, selected Dockerfile paths, context roots, and the mapping to Dockerfile-specific ignore files. Repository owners own the intended exclusion policy. Reviewers own the independently declared manifests and the release disposition. Executors own evidence about the effective Docker context, endpoint, builder, platform, and installed Docker, Buildx, and BuildKit versions.

A pull-request review should ask which Dockerfile the command selects, which directory is the context root, whether the exact sibling-specific filename exists, and which manifest that entrypoint is expected to export. These questions are inexpensive and directly tied to rule selection. They are more useful here than a repository-wide checkbox saying “a .dockerignore exists.”

A check that merely confirms a root .dockerignore exists cannot answer the selected-policy question. For every maintained entrypoint, keep one explicit mapping such as:

Dockerfile selection

Expected ignore-policy source

Payload contract owner

docker/build.Dockerfile

Root fallback or docker/build.Dockerfile.dockerignore, never an assumed merge

Named repository/build team

Another selected Dockerfile

Its exact sibling-specific filename or root fallback

Named repository/build team

This is one focused control within the broader DevOps toolchain, not a replacement for the rest of build and delivery ownership.

Revalidate the mapping when inputs or tooling change

Rerun the bounded check when a specific ignore file is created, renamed, edited, emptied, or removed; when the selected Dockerfile or context root changes; when COPY scope changes; or when the builder, frontend, engine, Buildx, platform, or exporter behavior changes. A moving source snapshot and one successful run cannot promise perpetual compatibility.

In CI, provision a controlled existing builder through the platform’s normal mechanism, pass explicit selectors, execute the fixture, and retain the evidence directory as a reviewable artifact. Treat EXPECTED_INCLUSION_POLICY_REJECT as a successful test observation but a failed release candidate. Treat HOLD as non-approval. The workflow must not automatically convert REPRODUCTION_MATCH into a universal release approval, because the result includes both acceptable contracts and the deliberate unsafe counterexample.

14. Build repeatable container-delivery skills with Refonte Learning

This laboratory practices habits that matter beyond one ignore filename: freeze inputs, declare expected outputs before execution, record the effective tool target, preserve counterexamples, and separate a successful command from an acceptable artifact.

Refonte Learning’s DevOps Engineering Program is listed as three months at 12–14 hours per week. It covers Linux/scripting, Git/GitHub, CI/CD, Docker/Kubernetes, Terraform, AWS/Azure/GCP, monitoring/logging, and a capstone. The page names MSc Oskar Eriksson as lead instructor and mentor, requires applicants to be working toward a bachelor’s or higher-level degree, and says successful completion offers a Training Certificate and Certificate of Internship. “Potential Internship” is not a placement guarantee. This exact lab, tooling versions, and local builder access are not specified.