Shipping automation: locks, artifacts and runtimes

Declare, lock, build, verify, distribute and isolate: wheels, zipapps and containers.

Advanced40 min · lesson 13 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (18 files, 6 KB): scr-package.tar.gz. Unpack it with tar -xzf scr-package.tar.gz, which creates scr-package/. SHA-256: 02194ae9575ded6a43a33c09f96b99530f9c480701c76653f874c19efb5d3e16

A tool you ship runs on machines you do not control, weeks after you tested it, and what runs there should be the thing you tested. You will lock a project with uv for every platform at once and catch a lock that no longer matches its pyproject.toml, export the lock to the PEP 751 pylock.toml standard and install from it with pip, choose between a wheel, a zipapp and a container for delivery (and see exactly where a zipapp breaks), install the tool for people who only run it, and build a container image from a base pinned by digest that runs as a non-root user and refuses a dependency that does not match its lock.

Refresher: py-sec "Packaging, pinning and auditing your tool" owns the basics, and this lesson assumes them: a src layout, pyproject.toml with dependency ranges, a console-script entry point, python -m build, a hash lock from pip-compile --generate-hashes installed with pip install --require-hashes, pip-audit and pipx. The tool is certcheck, which prints how many days each PEM certificate has left. The container part assumes you can read a Containerfile (FROM picks the base image, RUN runs a build command, COPY adds files, USER and ENTRYPOINT set who runs what); the site's "Docker for beginners" course covers them. Unpack the lesson files in your home directory: scr-package/ holds certcheck's source, pyproject.toml, the Containerfile, containers.conf, pyz_main.py and a copy of blsync. Commands run in ~/scr-package, on Ubuntu 26.04 with its Python 3.14.4 (the same lab passes on upstream 3.14.7).

Six jobs between source and a running tool

Shipping breaks down into six jobs. Declare: pyproject.toml says what the tool needs, as ranges. Lock: a lock file records the exact versions and file hashes that were resolved and tested. Build: the source becomes an artifact, here a wheel. Verify: the installer checks every file against the lock and stops on a mismatch. Distribute: the artifact reaches its users as a wheel, a single-file zipapp or a container image. Isolate: the tool runs in its own environment (a venv, a tool environment or a container) with no more privilege than it needs.

From source to a running tool
1Declare
pyproject.toml: dependency ranges
2Lock
uv.lock: exact versions and hashes, every platform
3Build
uv build: a py3-none-any wheel
4Verify
hash-checked install; a mismatch stops it
5Distribute
wheel, zipapp or container image
6Isolate
venv, uv tool or container, as a non-root user
A lock proves which files were chosen; it does not prove they were safe to choose.
deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ find src -type f | sort
src/certcheck/__init__.py src/certcheck/__main__.py src/certcheck/cli.py
src/certcheck/cli.py
"""Print the days each certificate has left; exit 1 if any is inside the warning window."""
import argparse
import sys
from datetime import UTC, datetime
from pathlib import Path
from cryptography import x509
OK, EXPIRING, USAGE, UNREADABLE = 0, 1, 2, 3 # USAGE is argparse's own exit status
def days_left(pem: bytes, now: datetime) -> int:
cert = x509.load_pem_x509_certificate(pem)
return (cert.not_valid_after_utc - now).days
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="certcheck", allow_abbrev=False,
epilog="exit status: 0 ok, 1 expiring, 2 usage, 3 unreadable (3 wins over 1)")
parser.add_argument("--warn-days", type=int, default=30, metavar="N")
parser.add_argument("certs", nargs="+", type=Path, metavar="CERT")
args = parser.parse_args(argv)
now = datetime.now(UTC)
status = OK
for path in args.certs:
try:
left = days_left(path.read_bytes(), now)
except (OSError, ValueError) as err: # missing file, or not a PEM certificate
print(f"certcheck: {path}: {err}", file=sys.stderr)
status = UNREADABLE # keep going: one bad file must not hide the certificates after it
continue
flag = ""
if left < args.warn_days:
status, flag = max(status, EXPIRING), " (expiring)"
print(f"{path.name}: {left} days left{flag}")
return status
pyproject.toml
[build-system]
requires = ["hatchling==1.32.4"]
build-backend = "hatchling.build"
[project]
name = "certcheck"
version = "0.1.0"
description = "Report how many days each PEM certificate has left"
requires-python = ">=3.14"
dependencies = ["cryptography>=50"]
[project.scripts]
certcheck = "certcheck.cli:main"
[tool.hatch.build.targets.sdist]
include = ["/src"]

certcheck has one dependency, cryptography, declared as a floor (>=50) and no ceiling; the next lesson explains where the floor comes from. That dependency matters here because it is not pure Python: it ships compiled code, which decides what kind of artifact can carry it. Two throwaway certificates to check, one valid for 400 days and one for 10 (-keyout /dev/null discards the private keys; the ----- lines openssl prints while generating them are left out):

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ mkdir -p certs openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -keyout /dev/null \ -subj /CN=web01.lab -days 400 -out certs/web01.pem openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -keyout /dev/null \ -subj /CN=api.lab -days 10 -out certs/api.pem ls certs
… api.pem web01.pem

A lock for every platform, with uv

uv is a Python package and project manager written in Rust. It is not in the Ubuntu archive, so install one pinned release the way you would in CI: the official release file from GitHub, checked against a SHA-256 you recorded, unpacked into the project's bin/ instead of system-wide. (Piping the documented installer script into sh runs whatever the server sends that day.)

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ arch=$(uname -m) case $arch in aarch64) sha=0804e9b164c64b6914182d5920c08551958a095986f10a3731056df701126436 ;; x86_64) sha=23bf5552d220e0842b65c862097b2ebaeba0064b74eda5e565e77fd25969d8c8 ;; *) echo "no pinned checksum for $arch"; exit 1 ;; esac tarball=uv-$arch-unknown-linux-gnu.tar.gz curl -fsSLO "https://github.com/astral-sh/uv/releases/download/0.12.19/$tarball" echo "$sha $tarball" | sha256sum -c - mkdir -p bin && tar -xzf "$tarball" -C bin --strip-components=1 && rm "$tarball" ./bin/uv --version
uv-aarch64-unknown-linux-gnu.tar.gz: OK uv 0.12.19 (aarch64-unknown-linux-gnu)

uv lock resolves the ranges in pyproject.toml and writes uv.lock:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv lock
Using CPython 3.14.4 interpreter at: /usr/bin/python3 Resolved 4 packages in 816ms
$ head -n 3 uv.lock grep -A1 "^name = " uv.lock | grep -v -- "^--" grep -c "cryptography-50.0.1-.*\.whl" uv.lock
version = 1 revision = 3 requires-python = ">=3.14" name = "certcheck" version = "0.1.0" name = "cffi" version = "2.1.1" name = "cryptography" version = "50.0.1" name = "pycparser" version = "3.0" 39

Four packages: the project, cryptography and the two packages it needs, cffi and pycparser. The last number counts the cryptography wheels in the lock: 39 of them, for Linux on x86-64, ARM and other CPUs (glibc and musl), macOS, Windows and the free-threaded 3.14t build, each with its SHA-256. The difference from the pip-compile lock in py-sec is the resolution, not the hashes. pip-compile resolves for the Python and platform it runs on, so a dependency that applies only elsewhere (an environment marker such as sys_platform == "linux" in some package's metadata) is simply missing from a lock made on a Mac. uv resolves once for every platform and Python version the project allows and keeps the markers, so one lock is valid on the laptop and on the server. uv sync --locked then builds the venv from the lock, and refuses to run if the lock is out of date:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv sync --locked
Using CPython 3.14.4 interpreter at: /usr/bin/python3 Creating virtual environment at: .venv Resolved 4 packages in 2ms Building certcheck @ file:///home/deploy/scr-package Downloading cryptography (4.5MiB) Downloaded cryptography Built certcheck @ file:///home/deploy/scr-package Prepared 4 packages in 1.42s Installed 4 packages in 1ms + certcheck==0.1.0 (from file:///home/deploy/scr-package) + cffi==2.1.1 + cryptography==50.0.1 + pycparser==3.0
$ .venv/bin/certcheck certs/web01.pem certs/api.pem; echo "exit status $?"
web01.pem: 399 days left api.pem: 9 days left (expiring) exit status 1
$ .venv/bin/certcheck certs/missing.pem certs/api.pem; echo "exit status $?"
certcheck: certs/missing.pem: [Errno 2] No such file or directory: 'certs/missing.pem' api.pem: 9 days left (expiring) exit status 3
$ find .venv -name "*.so" | sort
.venv/lib/python3.14/site-packages/_cffi_backend.cpython-314-aarch64-linux-gnu.so .venv/lib/python3.14/site-packages/cryptography/hazmat/bindings/_rust.abi3.so

uv sync created .venv, took the wheels for this machine out of the lock, and installed certcheck itself from the source tree. The tool exits 1 because api.pem is inside the 30-day window. With a missing file first in the list it still checks api.pem, then exits 3: one unreadable file must not hide a certificate that expires tomorrow, and "could not check" outranks "expiring". The last command shows the two compiled extension modules that came with cryptography; they come back when we try a zipapp. Now the failure a lock gate exists for: someone adds a dependency to pyproject.toml and forgets to relock.

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ cp pyproject.toml pyproject.toml.orig sed -i "s/\"cryptography>=50\"/\"cryptography>=50\", \"idna>=3.10\"/" pyproject.toml grep "^dependencies" pyproject.toml ./bin/uv lock --check; status=$? mv pyproject.toml.orig pyproject.toml exit $status
dependencies = ["cryptography>=50", "idna>=3.10"] Resolved 5 packages in 231ms error: The lockfile at `uv.lock` needs to be updated, but `--check` was provided. hint: To update the lockfile, run `uv lock`.

uv lock --check resolves again, sees that the result would differ from uv.lock, and exits 1 without touching the file (the step then put the original pyproject.toml back). Run it in CI, and a change to the declared dependencies without a new lock cannot merge.

pylock.toml: the lock format any installer can read

uv.lock is uv's own format. PEP 751 defines a standard one, pylock.toml, so a lock made by one tool can be installed by another. uv keeps uv.lock as its source of truth and exports the standard file on demand. pip 26.1 and later can install from it, marked experimental. The first attempt fails in a useful way:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ python3 -m venv pipcheck pipcheck/bin/python -m pip install -q --timeout 60 pip==26.2.1 pipcheck/bin/python -m pip --version
pip 26.2.1 from /home/deploy/scr-package/pipcheck/lib/python3.14/site-packages/pip (python 3.14)
$ ./bin/uv export -q --format pylock.toml -o pylock.toml pipcheck/bin/python -m pip install --timeout 60 -r pylock.toml
WARNING: Using pylock.toml as a requirements source is an experimental feature. It may be removed/changed in a future release without prior warning. Obtaining file:///home/deploy/scr-package/ (from pylock.toml) ERROR: The editable requirement file:///home/deploy/scr-package/ (from pylock.toml) cannot be installed when requiring hashes, because there is no single file to hash.

By default the export includes the project itself as an editable install of the current directory. A lock with hashes switches pip into hash-checking mode, and a directory has no single file to hash, so pip refuses. Export only the dependencies (--no-emit-project) and install the project separately, from its wheel:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv export -q --format pylock.toml --no-emit-project -o pylock.toml head -n 11 pylock.toml pipcheck/bin/python -m pip install --timeout 60 -r pylock.toml pipcheck/bin/python -m pip list
# This file was autogenerated by uv via the following command: # uv export --format pylock.toml --no-emit-project -o pylock.toml lock-version = "1.0" created-by = "uv" requires-python = ">=3.14" [[packages]] name = "cffi" version = "2.1.1" marker = "platform_python_implementation != 'PyPy'" index = "https://pypi.org/simple" WARNING: Using pylock.toml as a requirements source is an experimental feature. It may be removed/changed in a future release without prior warning. Collecting cffi==2.1.1 (from pylock.toml) Using cached cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (222 kB) Collecting cryptography==50.0.1 (from pylock.toml) Using cached cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl (4.7 MB) … Using cached pycparser-3.0-py3-none-any.whl (48 kB) Installing collected packages: pycparser, cffi, cryptography Successfully installed cffi-2.1.1 cryptography-50.0.1 pycparser-3.0 Package Version ------------ ------- cffi 2.1.1 cryptography 50.0.1 pip 26.2.1 pycparser 3.0

pip warned that pylock.toml support is experimental, then chose the one cffi and one cryptography wheel that fit this machine from the list in the file and checked their hashes. Because the format may still change in pip, keep uv.lock (or a pip-compile requirements.txt) as the lock you review and commit, and treat pylock.toml as an export. The container build below uses the oldest and most widely understood export, a hashed requirements.txt:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv export -q --format requirements.txt --no-emit-project -o requirements.txt grep -E "^[a-z]" requirements.txt grep -c -- "--hash=sha256:" requirements.txt
cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ cryptography==50.0.1 \ pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ 91

Three pinned lines and 91 hashes (every wheel and sdist of each version). Whichever lock you keep, choose one as the source of truth and generate the rest from it. pip-compile from py-sec is still a sound choice for a single-platform service; pip lock writes pylock.toml directly but, like reading it, is experimental and resolves only for the current platform.

Artifacts: a wheel, a zipapp, and where a zipapp stops

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv build
Building source distribution... Building wheel from source distribution... Successfully built dist/certcheck-0.1.0.tar.gz Successfully built dist/certcheck-0.1.0-py3-none-any.whl
$ python3 -m zipfile -l dist/certcheck-0.1.0-py3-none-any.whl
File Name Modified Size certcheck/__init__.py 2020-02-02 00:00:00 53 certcheck/__main__.py 2020-02-02 00:00:00 48 certcheck/cli.py 2020-02-02 00:00:00 1461 certcheck-0.1.0.dist-info/METADATA 2020-02-02 00:00:00 169 certcheck-0.1.0.dist-info/WHEEL 2020-02-02 00:00:00 87 certcheck-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00 49 certcheck-0.1.0.dist-info/RECORD 2020-02-02 00:00:00 533

uv build built an sdist and a wheel with the pinned hatchling backend. The wheel is py3-none-any: pure Python, any platform, because it contains only certcheck's own three modules and its metadata. Its dependencies are not inside; they are installed next to it, from the lock, for the target's platform. That is why a wheel plus a lock is the default artifact for Python tools.

A zipapp is the standard library's single-file format: a zip archive with a __main__.py and a shebang line, which Python runs directly (python3 -m zipapp, no extra tool). It still needs a Python on the target, and it has two sharp edges. The first shows with blsync, the standard-library-only tool from "Designing a CLI tool" earlier in this course:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ mkdir -p pyz/blsync && cp blsync/*.py pyz/blsync/ python3 -m zipapp pyz -m blsync.cli:main -p "/usr/bin/env python3" -o blsync.pyz ./blsync.pyz --config missing.toml plan; echo "exit status $?" python3 -m blsync --config missing.toml plan; echo "exit status $?"
blsync: config: missing.toml: [Errno 2] No such file or directory: 'missing.toml' exit status 0 blsync: config: missing.toml: [Errno 2] No such file or directory: 'missing.toml' exit status 78

The same missing config file gives exit status 78 (EX_CONFIG) from python3 -m blsync and 0 from the zipapp. With -m blsync.cli:main, zipapp writes a __main__.py that calls main() and throws away its return value, so every failure that main() reports by returning a number looks like success to cron or CI. Supply your own __main__.py that passes the value on:

pyz_main.py (copied into the archive as __main__.py)
# __main__.py for a zipapp: pass main()'s return value on as the exit status.
from blsync.cli import main
raise SystemExit(main())
deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ cp pyz_main.py pyz/__main__.py python3 -m zipapp pyz -p "/usr/bin/env python3" -o blsync.pyz ./blsync.pyz --config missing.toml plan; echo "exit status $?" ls -l blsync.pyz
blsync: config: missing.toml: [Errno 2] No such file or directory: 'missing.toml' exit status 78 -rwxrw-r-- 1 deploy deploy 13558 Sep 28 19:41 blsync.pyz

Exit status 78 again, from a 13.6 KB file you can copy to any machine with Python 3.14. The second edge is compiled code. Here is certcheck with cryptography installed into the archive from the hashed lock:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv pip install -q --target pyz-cc --require-hashes -r requirements.txt cp -r src/certcheck pyz-cc/ python3 -m zipapp pyz-cc -m certcheck.cli:main -p "/usr/bin/env python3" -o certcheck.pyz ./certcheck.pyz certs/api.pem; echo "exit status $?"
Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/home/deploy/scr-package/./certcheck.pyz/__main__.py", line 2, in <module> import certcheck.cli File "/home/deploy/scr-package/./certcheck.pyz/certcheck/cli.py", line 7, in <module> from cryptography import x509 File "/home/deploy/scr-package/./certcheck.pyz/cryptography/x509/__init__.py", line 7, in <module> from cryptography.x509 import certificate_transparency, oid, verification File "/home/deploy/scr-package/./certcheck.pyz/cryptography/x509/certificate_transparency.py", line 8, in <module> from cryptography.hazmat.bindings._rust import x509 as rust_x509 ImportError: cannot import name 'x509' from 'cryptography.hazmat.bindings._rust' (unknown location) exit status 1

Python imports modules from a zip archive only when they are Python source or bytecode; it cannot load an extension module (_rust.abi3.so) from inside one. The import found only a directory of type stubs named _rust and failed. A zipapp is for pure-Python tools. pex goes further: a .pex file is a zipapp that carries dependency wheels, compiled ones included, and unpacks them into a cache (PEX_ROOT) to run them, so it works only on the platforms you built it for (--platform adds more). That is one more tool and one more cache on every host, worth it only when a single file is a hard requirement. (shiv, which older guides use for the same job, has had no release since November 2024.) For a tool with compiled dependencies, ship the wheel and its lock, or a container.

For people who only run the tool

py-sec showed pipx: one venv per application, only its commands on PATH. uv tool install is the same idea with the uv you already have:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ ./bin/uv tool install dist/certcheck-0.1.0-py3-none-any.whl
Resolved 4 packages in 2ms Prepared 1 package in 1ms Installed 4 packages in 2ms + certcheck==0.1.0 (from file:///home/deploy/scr-package/dist/certcheck-0.1.0-py3-none-any.whl) + cffi==2.1.1 + cryptography==50.0.1 + pycparser==3.0 Installed 1 executable: certcheck warning: `/home/deploy/.local/bin` is not on your PATH. To use installed tools, run `export PATH="/home/deploy/.local/bin:$PATH"` or `uv tool update-shell`.
$ ./bin/uv tool list ~/.local/bin/certcheck certs/api.pem; echo "exit status $?"
certcheck v0.1.0 - certcheck api.pem: 9 days left (expiring) exit status 1
$ ./bin/uv tool uninstall certcheck
Uninstalled 1 executable: certcheck

uv created a venv for certcheck under ~/.local/share/uv/tools and put one launcher in ~/.local/bin; the warning says that directory is not on this shell's PATH yet (Ubuntu's ~/.profile adds it at the next login once it exists). Like pipx, uv tool install resolved the wheel's ranges when it ran; it did not read uv.lock, so the versions match the lock today only because nothing newer exists. For a laptop that is fine. For a server, install from the lock.

A container image you can rebuild

A container ships the runtime too: the interpreter, the OS libraries and the tool, in one image. This lab uses podman 5.7.0 from the Ubuntu archive (sudo apt install podman), running rootless as deploy. A rootless podman normally asks the user's systemd instance to manage its cgroups; this shell (like cron, su or many CI runners) has no systemd user session, so a two-line containers.conf tells podman to manage cgroups itself:

~/.config/containers/containers.conf
# Rootless podman from a shell with no systemd user session (cron, su, runuser, many CI runners):
# manage cgroups directly instead of asking a per-user systemd that is not there.
[engine]
cgroup_manager = "cgroupfs"
deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ mkdir -p ~/.config/containers && cp containers.conf ~/.config/containers/ podman --version podman info --format "{{.Host.CgroupManager}} {{.Host.CgroupsVersion}} rootless={{.Host.Security.Rootless}}"
podman version 5.7.0 cgroupfs v2 rootless=true time="2026-09-28T19:41:31Z" level=warning msg="Failed to add pause process to systemd sandbox cgroup: dbus: couldn't determine address of session bus"

cgroupfs and rootless=true confirm the setup. The one warning is podman failing to reach a per-user D-Bus that this session does not have; containers run normally without it. Next, the base image. A tag such as python:3.14-slim is a name that the publisher moves on every rebuild; a digest is the SHA-256 of the content and cannot move. Resolve the tag to its digest once, record it, and build from the digest:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ podman pull -q docker.io/library/python:3.14-slim podman image inspect --format "{{range .RepoDigests}}{{println .}}{{end}}" docker.io/library/python:3.14-slim grep "^FROM" Containerfile
1195f96420d020fef9906988b14813d770ced8a16651abc546efb60e50b56d00 docker.io/library/python@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d docker.io/library/python@sha256:67994a05c712036dbfc4385b4bceafc0ce20df950f54b9ea355582c153bf6157 FROM docker.io/library/python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d
$ podman manifest inspect docker.io/library/python@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d \ | jq -r ".manifests[] | select(.platform.os == \"linux\") | \"\(.platform.architecture) \(.digest)\""
amd64 sha256:7bf6c3111fe094f8ee1a1cbcdc63c4cfb345b0e3df42d5aa9a90b3b4b022ab6d arm sha256:eb2ef612db8b6473c701ae258c6db9e6941ddb72a2f7f037497d52c4398dff35 arm sha256:870c3f16f98c916c1c2e2d84c7036fdf8fe901611abc2096cd2c59e71868fbc9 arm64 sha256:67994a05c712036dbfc4385b4bceafc0ce20df950f54b9ea355582c153bf6157 386 sha256:1e27ec3180ae8e80e0fd9d49ac3a42948d32c617c33e4587b924c9479ca8f0c6 ppc64le sha256:a205dc9270ef4f707545b77fea3734eb8622ed5e6db8ad24ceb13bdd149f46fd riscv64 sha256:472eef1b4d3e7d9d431fa92c329a0038b37d6d3d90a67f6f3c03a43fc4176c69 s390x sha256:f8a8de3606ee4f7749fd6d83c03c4210281e71ce6fc4e2d89bad486c4f0d8fda

podman pull printed the local image ID, then the image carries two digests. The first, sha256:51daf..., is the index: the list of per-architecture images the official python image on Docker Hub publishes under one name. The second is the ARM64 image inside it that this machine pulled; the listing of the index shows it next to the amd64, s390x and other builds. The Containerfile pins the index digest the tag pointed to when this lab was written (the same value podman reports above), so one line works on every architecture and never changes under you. To take a newer base, you resolve and review the new digest on purpose. That cuts both ways: a pinned base also keeps its old OS packages, security fixes included, until someone moves the pin. Let a bot such as Renovate or Dependabot propose the new digest as a reviewed change, rebuild on a schedule, and scan the image's OS packages, which the next lesson counts in its SBOM.

Containerfile
# python:3.14-slim from Docker Hub, pinned to the index digest the tag pointed to on 2026-09-28.
# The tag moves on every rebuild; the digest names these exact bytes for every architecture.
FROM docker.io/library/python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d
# The account the tool runs as: no login shell, no home, not root.
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin certcheck
# Dependencies from the hash lock: a file whose SHA-256 is not in the lock fails the build.
# The venv gets no pip of its own; the base image's pip installs into it (--python), so the
# runtime environment holds only the tool and what it needs.
COPY requirements.txt /tmp/requirements.txt
RUN python -m venv --without-pip /opt/certcheck \
&& python -m pip --python /opt/certcheck/bin/python install --no-cache-dir \
--disable-pip-version-check --require-hashes -r /tmp/requirements.txt
# Then the tool's own wheel, with nothing else resolved.
COPY dist/certcheck-0.1.0-py3-none-any.whl /tmp/
RUN python -m pip --python /opt/certcheck/bin/python install --no-cache-dir \
--disable-pip-version-check --no-deps /tmp/certcheck-0.1.0-py3-none-any.whl \
&& rm /tmp/certcheck-0.1.0-py3-none-any.whl /tmp/requirements.txt
USER 10001
ENTRYPOINT ["/opt/certcheck/bin/certcheck"]
.containerignore
# The build sees only what the Containerfile copies: no venvs, caches, keys or certificates.
*
!requirements.txt
!dist/certcheck-0.1.0-py3-none-any.whl

The image gets a user with UID 10001, no home and no login shell, and USER 10001 makes every process in it run as that user. Dependencies come from the hashed requirements.txt into a venv, then the wheel goes in with --no-deps, as on a server. The venv is created without pip, and the base image's pip installs into it with --python, so the runtime environment carries no installer it does not need. .containerignore is an allow-list: the build context contains only the lock and the wheel, so .venv, the caches and the certificates in this directory never reach the image.

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ podman build -t certcheck:0.1.0 .
STEP 1/8: FROM docker.io/library/python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d STEP 2/8: RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin certcheck --> 38f903b08be1 STEP 3/8: COPY requirements.txt /tmp/requirements.txt --> c8f3375d824a STEP 4/8: RUN python -m venv --without-pip /opt/certcheck && python -m pip --python /opt/certcheck/bin/python install --no-cache-dir --disable-pip-version-check --require-hashes -r /tmp/requirements.txt Collecting cffi==2.1.1 (from -r /tmp/requirements.txt (line 3)) Downloading cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl (222 kB) Collecting cryptography==50.0.1 (from -r /tmp/requirements.txt (line 54)) Downloading cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl (4.7 MB) … Collecting pycparser==3.0 (from -r /tmp/requirements.txt (line 96)) Downloading pycparser-3.0-py3-none-any.whl (48 kB) Installing collected packages: pycparser, cffi, cryptography Successfully installed cffi-2.1.1 cryptography-50.0.1 pycparser-3.0 --> 2d47d3428fdf STEP 5/8: COPY dist/certcheck-0.1.0-py3-none-any.whl /tmp/ --> ad127d0f9331 STEP 6/8: RUN python -m pip --python /opt/certcheck/bin/python install --no-cache-dir --disable-pip-version-check --no-deps /tmp/certcheck-0.1.0-py3-none-any.whl && rm /tmp/certcheck-0.1.0-py3-none-any.whl /tmp/requirements.txt Processing ./tmp/certcheck-0.1.0-py3-none-any.whl Installing collected packages: certcheck Successfully installed certcheck-0.1.0 --> 4eba086cd3af STEP 7/8: USER 10001 --> efeb117044b5 STEP 8/8: ENTRYPOINT ["/opt/certcheck/bin/certcheck"] COMMIT certcheck:0.1.0 --> e7c7b364b88e Successfully tagged localhost/certcheck:0.1.0 e7c7b364b88ebd65c30120ce9e82d5409e6e98ef28f984aee14ce315ca903867
$ podman run --rm -v ./certs:/certs:ro certcheck:0.1.0 /certs/web01.pem /certs/api.pem echo "exit status $?" podman run --rm --entrypoint id certcheck:0.1.0
web01.pem: 399 days left api.pem: 9 days left (expiring) exit status 1 uid=10001(certcheck) gid=10001(certcheck) groups=10001(certcheck)

The image runs the tool as uid=10001 with exit status 1 for the expiring certificate, reading the certificates through a read-only mount. Now the failure the lock is there for. A teammate "just bumps" a version in requirements.txt by hand, without regenerating the lock:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ mkdir -p edited/dist cp Containerfile .containerignore edited/ && cp dist/*.whl edited/dist/ sed "s/^cryptography==50.0.1/cryptography==50.0.0/" requirements.txt > edited/requirements.txt podman build -t certcheck:edited edited; echo "exit status $?" podman image exists certcheck:edited || echo "no image certcheck:edited"
STEP 1/8: FROM docker.io/library/python:3.14-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d … STEP 4/8: RUN python -m venv --without-pip /opt/certcheck && python -m pip --python /opt/certcheck/bin/python install --no-cache-dir --disable-pip-version-check --require-hashes -r /tmp/requirements.txt … Collecting cryptography==50.0.0 (from -r /tmp/requirements.txt (line 54)) Downloading cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl (4.7 MB) … ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them. cryptography==50.0.0 from https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl (from -r /tmp/requirements.txt (line 54)): Expected sha256 01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 Expected or 05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 Expected or 16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 … Expected or ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef Got 07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 Error: building at STEP "RUN python -m venv --without-pip /opt/certcheck && python -m pip --python /opt/certcheck/bin/python install --no-cache-dir --disable-pip-version-check --require-hashes -r /tmp/requirements.txt": while running runtime: exit status 1 exit status 1 no image certcheck:edited

pip downloaded cryptography 50.0.0, found its hash among none of the 40 the lock allows for that line (they belong to 50.0.1), and stopped. The RUN step failed, so the build failed and no image was tagged. The same check stops a file that was swapped on a mirror. It cannot stop a package that was already malicious when you locked it: the lock records what was resolved that day, and the next lesson adds the gates that look at what the lock contains.

Try this

Raise the floor to cryptography>=50.0.1 in pyproject.toml, run ./bin/uv lock --check and confirm it exits 1, run ./bin/uv lock, and check that --check now exits 0 and that the requires-dist line in uv.lock shows the new specifier. Then prove the image's user cannot change the installed tool: run podman run --rm --entrypoint /opt/certcheck/bin/python certcheck:0.1.0 -c "open('/opt/certcheck/x', 'w')". Expect PermissionError: [Errno 13] Permission denied and exit status 1, because /opt/certcheck was created by root during the build and the container runs as UID 10001.

When you are done, remove the image; the next lesson reuses podman, containers.conf and the base image, and removes them at its end:

deploy@web01:~/scr-package · Ubuntu 26.04 LTS
$ podman rmi certcheck:0.1.0 podman image exists certcheck:0.1.0 || echo "certcheck:0.1.0 removed"
Untagged: localhost/certcheck:0.1.0 Deleted: e7c7b364b88ebd65c30120ce9e82d5409e6e98ef28f984aee14ce315ca903867 Deleted: efeb117044b5ead1808ea36d1a0a00e1570276d1b3e85a008c1a231650cc190a Deleted: 4eba086cd3af18cb46f2e9405943f5d6c83d0acca8700a8db232e4bb0ce29244 Deleted: ad127d0f933190eeda10e50a1f3b14304d26b9095731660d4be8b3e5db84c344 Deleted: 2d47d3428fdf9ec1750401ad91d6c35d7f5f33a6358d785bc2c8a3024b6de880 Deleted: c8f3375d824adbf02c4cba595ac4c00a3d4982c0fe2180efe8e8a93fd7fbccf8 certcheck:0.1.0 removed

Takeaway

Keep one lock as the source of truth, fail CI when it no longer matches pyproject.toml, and install only from it with hashes, in a venv, a tool environment or an image built from a base pinned by digest that runs as a non-root user. Ship a zipapp only for pure-Python tools, with a __main__.py that passes on the exit status.

Quick check
01Your team runs pip-compile --generate-hashes on macOS laptops. One dependency declares pyinotify; sys_platform == "linux" in its metadata. On the Linux server, pip install --require-hashes -r requirements.txt refuses to install. What is the cause, and the fix?
Incorrect — pip-compile records the hash of every file of each chosen version, so the Linux wheels of locked packages are covered.
Incorrect — A file's hash does not depend on the connection, and dropping the check removes what the lock is for.
Correct — uv resolves for every supported platform and keeps the marker, so the Linux-only package is locked and hashed.
Incorrect — Hash mode accepts sdists that are in the lock; the problem here is a package that is not in the lock at all.
02A nightly job runs ./tool.pyz --config /etc/tool.toml, built with python3 -m zipapp src -m tool.cli:main. The config file was deleted weeks ago, main() prints an error and returns 78, yet the scheduler shows every run as successful. Why?
Incorrect — cron reports the command's exit status; the value was already 0 when the process ended.
Incorrect — A zipapp exits with whatever __main__.py produces; raise SystemExit(main()) gives 78.
Incorrect — The error text was printed normally; the problem is the exit status, not where the message went.
Correct — Write your own __main__.py that ends with raise SystemExit(main()).
03A Containerfile starts FROM python:3.14-slim, and a rebuild of an unchanged repository today produces an image with different OS libraries than last month's. What is the most direct fix?
Correct — The tag is moved on every upstream rebuild; the digest names fixed content.
Incorrect — That re-pulls whatever the tag points to that day, so builds drift more often, not less.
Incorrect — Both tags are rebuilt as their base changes; neither is pinned content.
Incorrect — Hash checking covers the Python packages pip installs, not the base image the build starts from.

Related