Air-Gapped Mirrors and a Private CA
pip splits into two computers
In one line
Pip in an air-gapped network is split across two computers. The download side collects wheels from the internet and records their hashes; the consuming side stands those wheels up as an internal index and points to it with a single pip.conf line. All that connects the two is a bundle of files and a hash list.
Why this was needed
Run pip install requests on an analysis server and, in an air-gapped network, you get Could not find a version that satisfies the requirement. Fetch just the requests wheel from outside and the next error says urllib3, idna, certifi, and charset_normalizer are missing. In an organization where import review happens once a day, each round trip costs a day. On top of that, if the computer that downloads (a laptop, Python 3.12) differs from the server that uses the files (Python 3.11), the compiled ones among the wheels you fetched will not fit the server. Download requests 2.32.3 in this lab image and only charset_normalizer is a platform wheel with the cp312-cp312-manylinux...x86_64 tag; the other four are py3-none-any. When the Python version changes, exactly that one is wrong.
How it works
The download side. pip download -d <디렉터리> <요구사항> (the placeholders are the directory and the requirement) resolves the dependencies and collects only the files, without installing. If the target server differs, specify the target with --platform, --python-version, --implementation, and --abi. The pip documentation says that when you use these options, --only-binary=:all: or --no-deps is required. If you download a source distribution, it gets built on the current computer and no longer matches the target server.
Pin versions and hashes. pip hash <파일> prints a --hash=sha256:<값> line (the placeholders are the file and the hash value). If you write 이름==버전 --hash=sha256:... on every line of requirements.txt (the placeholders are the name and the version), then, as the pip documentation says, hash-checking mode turns on for the whole file as soon as any one requirement has --hash. In this mode every requirement must be pinned with ==, and if even one downloaded file's hash differs, the installation stops. This closes the path by which a wheel altered on its way through the media would quietly get in.
The consuming side: the internal index. The index pip reads is the simple HTML defined by PEP 503 (now the Simple repository API specification at packaging.python.org). All it takes is, at /simple/<정규화한 이름>/ (the placeholder is the normalized name), links whose text is the file names. Name normalization means lowercasing and replacing each run of ., -, and _ with a single -. So a charset_normalizer wheel is found only if it is at /simple/charset-normalizer/. Python's standard-library http.server serves directory listings in exactly that shape (links whose text is the file name), so just creating directories with normalized names and putting the wheels in them gives you a read-only index. What tools such as pypiserver do is, at its core, the same.
Pointing to it: pip.conf. On Linux, pip reads configuration in this order: global (/etc/xdg/pip/pip.conf, /etc/pip.conf) → user (~/.config/pip/pip.conf, and the old location ~/.pip/pip.conf) → site ($VIRTUAL_ENV/pip.conf) → PIP_CONFIG_FILE, and values read later override earlier ones. Environment variables beat files, and command-line options beat environment variables. pip config debug shows which files were actually read. If you write index-url in the global file, every venv on the server sees the internal index.
When there is not even an index. --no-index --find-links <디렉터리> (the placeholder is the directory) ignores the index entirely and looks only at the files in that directory. It is a way to use a wheelhouse carried in on a USB drive as it is, and it is also a test of whether the wheelhouse is self-contained.
What it looks like in the field
If you set up the internal index over http:// and write only index-url, pip does not trust that address. localhost and 127.0.0.1 are treated as safe places, but an internal name is not. Measured in this lab with pypi.airgap.internal, the moment you remove trusted-host, pip leaves only a warning and ignores that index, and the result is No matching distribution found. The index looks empty, but in fact pip never looked at it. In practice, the right way is to set up the internal index over HTTPS and make it trust a private CA (covered in a later module).
The second most common accident is PEP 668. The system Python on Ubuntu 24.04 has an EXTERNALLY-MANAGED marker, so it refuses pip install outside a venv. Do not force it in with --break-system-packages; create a venv. The global /etc/pip.conf is also read by pip inside a venv, so you only need to configure it once.
One more point about certificates. According to the pip documentation, from pip 24.2 on, with Python 3.10 or later, pip also uses the operating system certificate store through truststore, and before that it used only the certifi bundle. The pip in this lab image is the Ubuntu package 24.0, but because it is a version patched by Ubuntu, it reads /etc/ssl/certs/ca-certificates.crt (measured). Even with the same 24.0, a pip downloaded from PyPI may behave differently, so where a private CA is used it is safer to specify the bundle explicitly with the cert setting or PIP_CERT.
What you will do in the next lab
You download requests 2.32.3 separately for this Pod's Python and for Python 3.11 on the air-gapped server, and create a requirements.txt with the hashes embedded. You stand up an index with normalized directories in /srv/pypi/simple, serve it as pypi.airgap.internal:8080, point to it with /etc/pip.conf, and the grader checks that downloads still work with the outside blocked. Finally, you install from the wheelhouse alone, without an index.
Reference documents: pip download · pip Configuration · Secure installs (hash-checking mode) · Simple repository API · HTTPS Certificates