A private package that resolves fine on your machine can fail with a 404 or a timeout inside the build, and the Dockerfile is rarely the reason. The build step runs in a different network namespace, with a different DNS view and a different credential store than the shell you tested in. Work through these checks in order and stop at the first one that fails, because every later check assumes the earlier one passed. Each step names the command that settles it.

Start by naming the builder, not the Dockerfile

BuildKit can be local, containerized or running on another machine, and each of those has its own default network. Run docker buildx ls and read the node marked with an asterisk before you edit anything. A docker-container driver or a cloud builder does not share the private route on your laptop just because you typed the command there. If the active builder is remote, every remaining check has to run against that host instead of your workstation.

The builder also carries its own resolver configuration and proxy variables. Both are read at build time and are not inherited from the terminal that started the build.

Prove the network path from inside a build step

Write a throwaway stage that resolves the dependency host and opens a connection to it. This is the only test that answers the question for the builder rather than for your shell.

FROM alpine:3.20
RUN apk add --no-cache curl bind-tools  && getent hosts registry.example.internal  && curl -sS -o /dev/null -w "%{http_code}
" https://registry.example.internal/simple/

Build that file with the same builder, network mode and secret mounts your real build uses: docker buildx build --builder remote0 -f Dockerfile.probe --progress=plain .. If getent prints nothing, the failure is name resolution and no credential will fix it. If the name resolves but curl reports a timeout, the blocker is the route or the egress policy in front of the registry.

Delete the probe once it answers. It is a diagnostic stage, not something that should ever be tagged or pushed.

Mount the credential instead of copying it

Once the path works, authentication is the next failure in line. A BuildKit secret mount exposes a file to one RUN step without writing it into a layer, which is what the dependency tool needs and what the image must not keep.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc     npm ci --omit=dev

The secret has to be supplied from outside the Dockerfile: docker buildx build --secret id=npmrc,src=/home/me/.npmrc .. The file stays on the client and never enters the image history. An ARG or ENV holding the same token does not do this job, because both are recorded in the image configuration.

A non-root image needs its own target path, such as /home/app/.npmrc or the equivalent file for pip, Maven or Cargo. The mount target must match the user that runs the command, or the tool will report a missing configuration file that is actually present.

Audit what the dependency tool leaves in the image

A temporary mount keeps the credential out of a layer, but it does not stop the tool from writing something else. Package managers leave lockfiles, cache directories and sometimes the resolved registry URL behind.

Read the finished filesystem instead of trusting the Dockerfile. docker history --no-trunc image:tag lists every layer command, and docker run --rm image:tag sh -c "ls -a /root" shows what survived into the running container. A token echoed into a log line during npm ci is still a leak even when the file itself was only mounted.

Read the build log as well, since --progress=plain prints command output verbatim. If the token appears there, rotate it before anything else and pass it through a file the tool reads quietly.

Verify the runtime image no longer needs the private path

The build and the running application are two different consumers of the same dependency, and only the first one needs the private route. A finished image should start, resolve its runtime dependencies and serve traffic without the build-time credential.

Test it where the private registry is unreachable, or simply start the container with --network none and confirm it boots without the mounted secret. A container that only starts when the build secret is present is still coupled to the build environment.

If the container genuinely needs the registry at runtime, that is a separate access decision, and it belongs in the orchestrator secret store rather than in the build. Document the two paths apart so nobody tries to repair a runtime pull with a build flag. When all five checks pass, the original symptom has a named cause at every layer instead of a guess about Docker networking.

Mikanda mikatakata