Builds with no internet access, the secure way
Your build can reach the whole internet, so a malicious dependency can send your source, your secrets and your signing token anywhere it likes. Taking the internet away from the build is simple. Finding out everything the build quietly downloaded is the real work.
The short answer
Split every build in two. A fetch stage with network access resolves, hash-checks and stores every input: wheels, vendored modules and base images mirrored by digest. The build stage runs on a network with no route out, reaches only your registry, and installs with --no-index, -mod=vendor and GOPROXY=off.
On this page
What goes wrong
A build that can reach the internet can be made to do anything the internet asks. The risks are specific:
- A compromised package's install script sends environment variables, including CI tokens, to an outside host.
- A dependency is fetched fresh on every build, so a package replaced upstream is picked up without a lockfile change.
- A build step pipes
curlintosh, and nobody notices because it has always worked.
Blocking egress without preparation just breaks the build. Every download has to move to a stage that verifies it, and the build has to be told not to look anywhere else.
What the docs say
-mod=vendor tells the go command to use the vendor directory. In this mode, the go command will not use the network or the module cache.
Source: Go Modules Reference
Ignore package index (only looking at --find-links URLs instead).
Source: pip docs, pip install --no-index
The command is run with no network access (lo is still available, but is isolated to this process)
Source: Dockerfile reference, RUN --network
Kaniko's README has no per-step network option like BuildKit's
RUN --network=none; the only network-related flag it documents,
--skip-push-permission-check, is about delaying the first registry request.
With kaniko, the network restriction has to come from where the pod or
container runs, which is what this page does.
The secure configuration
Stage 1: fetch (network on, trusted inputs only)
# Python: lock with hashes, then download exactly those files as wheels.
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
pip download --only-binary :all: --require-hashes -r requirements.txt -d wheelhouse
# Go: vendor (see the vendoring page for the re-vendor check).
go mod vendor
# Base images: mirror by digest into the registry the build can reach.
BASE=$(crane digest python:3.14-slim)
crane copy "index.docker.io/library/python@${BASE}" registry.example.com/mirror/python:3.14-slimThe fetch job has no secrets except a push credential for the mirror, and
its outputs (the wheelhouse, vendor/, the mirrored digests) are stored as
build inputs.
Stage 2: build (no route to the internet)
FROM registry.example.com/mirror/python@sha256:<digest>
COPY requirements.txt /tmp/requirements.txt
COPY wheelhouse/ /tmp/wheelhouse/
# No index, no network: only the hashed wheels from the build context.
RUN pip install --no-cache-dir --no-index --find-links /tmp/wheelhouse \
--require-hashes --only-binary :all: -r /tmp/requirements.txt \
&& rm -rf /tmp/wheelhouse
USER 65532:65532For Go stages, set the environment so nothing falls back to the network:
ENV GOFLAGS=-mod=vendor GOPROXY=off GOTOOLCHAIN=localWhere the build runs decides whether the network is really gone:
- On Kubernetes: a NetworkPolicy on the build pods that allows egress only to the registry mirror and DNS (see the page on egress isolation for CI build pods).
- On a plain Docker runner: an
--internalnetwork that contains the registry mirror and the build container, and nothing else.
docker network create --internal build-net
docker network connect --alias registry build-net registry-mirror
docker run --rm --network build-net \
-v "$PWD:/workspace:ro" "$KANIKO_IMAGE" \
--context dir:///workspace --destination registry:5000/team-a/app:1.0 \
--insecure-registry registry:5000 --no-push --tar-path /out/app.tarUse --insecure-registry only for a mirror on a private network like this
test; a production mirror serves TLS.
Prove it
The fetch stage, with network:
pip download --only-binary :all: --require-hashes -r requirements.txt -d wheelhouse
crane copy python:3.14-slim@sha256:... registry:5000/mirror/python:3.14-slimcertifi-2026.7.22-py3-none-any.whl
charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
idna-3.20-py3-none-any.whl
requests-2.32.5-py3-none-any.whl
urllib3-2.8.0-py3-none-any.whl
mirrored registry:5000/mirror/python@sha256:7bf6c3111fe094f8ee1a1cbcdc63c4cfb345b0e3df42d5aa9a90b3b4b022ab6dThe build network really has no way out:
docker run --rm --network build-net alpine wget -T 5 https://pypi.org/simple/wget: bad address 'pypi.org'Kaniko builds the image on that network, pulling the base from the mirror and installing from the wheelhouse (snapshot log lines trimmed):
level=info msg="Retrieving image registry:5000/mirror/python@sha256:<digest> from registry registry:5000"
level=info msg="COPY requirements.txt /tmp/requirements.txt"
level=info msg="COPY wheelhouse/ /tmp/wheelhouse/"
Looking in links: /tmp/wheelhouse
Processing ./tmp/wheelhouse/certifi-2026.7.22-py3-none-any.whl (from -r /tmp/requirements.txt (line 1))
Processing ./tmp/wheelhouse/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (from -r /tmp/requirements.txt (line 4))
Processing ./tmp/wheelhouse/idna-3.20-py3-none-any.whl (from -r /tmp/requirements.txt (line 177))
Processing ./tmp/wheelhouse/requests-2.32.5-py3-none-any.whl (from -r /tmp/requirements.txt (line 180))
Processing ./tmp/wheelhouse/urllib3-2.8.0-py3-none-any.whl (from -r /tmp/requirements.txt (line 183))
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2026.7.22 charset-normalizer-3.5.1 idna-3.20 requests-2.32.5 urllib3-2.8.0
requests 2.32.5
level=info msg="USER 65532:65532"
level=info msg="Skipping push to container registry due to --no-push flag"The same Dockerfile with the wheelhouse lines replaced by a normal index install fails, because the index is unreachable:
ERROR: Could not find a version that satisfies the requirement certifi==2026.7.22 (from versions: none)
ERROR: No matching distribution found for certifi==2026.7.22
error building image: error building stage: failed to execute command: waiting for process to exit: exit status 1That failure is the feature: any download you forgot to move into the fetch stage shows up as a broken build, not as a quiet request to the internet.
Mistakes people make
Blocking egress but allowing "just DNS"
DNS to any resolver is a data channel. Allow DNS only to the cluster or network resolver, and have that resolver answer only for the mirror's name.
A proxy that allows everything
An HTTP proxy for the build that forwards to any host is egress with extra steps. If you need a proxy, it allows the mirror host and nothing else.
Go toolchain downloads
A go.mod that asks for a newer Go version makes go try to download a
toolchain. Set GOTOOLCHAIN=local so the build fails instead of fetching.
Fetch and build in the same job
If one job both downloads and builds, the build steps run with network access. Separate jobs, separate networks, and the build job gets the fetch job's outputs as files.
Forgetting the base image
FROM python:3.14-slim makes the builder pull from Docker Hub. The build
network cannot reach it, so the build fails, which is correct. Fix it with a
mirrored digest, not by opening the network.
Checklist
- Every build has a fetch stage and a build stage in separate jobs.
- The fetch stage verifies hashes and digests before storing inputs.
- The build stage runs where only the registry mirror and a resolver are reachable.
- Python installs use
--no-index --find-linkswith--require-hashes. - Go builds set
GOFLAGS=-mod=vendor,GOPROXY=offandGOTOOLCHAIN=local. - Base images come from the mirror, pinned by digest.
- DNS from build pods goes only to the internal resolver.
- A test build without the wheelhouse fails, proving the network is closed.
An offline build fails loudly on every download you forgot. That is much better than succeeding quietly on the one you did not know about.
H2-CSDE
Learn it on a live range
Dependencies and vendoring, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.
Start freeH2 Scanner
Want this caught before it merges?
The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.
Talk to us