Docker / Build systems
Same Dockerfile, Different Build: Reproducible Container Builds
Pin it, lock it, and verify what you actually built—across local and cloud.
I once pushed a change after a healthy local run of aceu-trust-demo. The cloud build resolved a newer FastAPI version—0.142.2 instead of 0.141.1 locally—and pulled an additional package, opentelemetry-api. The Dockerfile was the same; the dependency ranges were not locked.
That kind of drift is easy to miss. A repeatable build takes more than a Dockerfile: it needs a locked dependency graph, a deliberate base-image update policy, and a way to inspect the artifact that actually shipped.
Why “same Dockerfile” can produce a different image
Version ranges allow resolvers to choose newer compatible releases. A mutable base-image tag can also point to a newer image later. Build caches may keep an older layer locally while CI fetches fresh inputs. The source recipe is unchanged, but the resolved inputs differ.
Lock the dependency graph
Use exact direct-dependency pins and commit a lock file that records the resolved transitive versions. Where the package manager supports it, include hashes and install in locked mode. A short input file might look like this:
# requirements.in — human-maintained direct dependencies
fastapi==0.141.1
uvicorn==0.30.1
# Resolve and lock transitive dependencies, including hashes.
pip-compile --generate-hashes --output-file requirements.txt requirements.in
Commit both files. Install from the generated lock in CI rather than re-resolving ranges during every image build:
pip install --require-hashes -r requirements.txt
Pins make change deliberate; they do not remove the need to update packages for fixes.
Choose tags or digests deliberately
Tags are convenient and can move to newer images. Digests identify a specific image immutably. A digest gives a reproducible base, but it will not advance to security fixes on its own—you must monitor updates and refresh it deliberately.
# Illustrative digest placeholder: replace with a verified digest
FROM python:3.12-slim@sha256:<verified-digest>
If you use tags, track when they change and refresh the base during scheduled builds. --pull tells Docker to attempt to pull referenced images before the build:
docker build --pull --tag aceu-trust-demo:main .
Make updates reviewable
Use a dependency update bot or scheduled update pull requests, then gate those changes with tests and image scanning. For production-critical dependencies, document which updates need an explicit reviewer. Prefer a visible, tested update over a silent change arriving through a floating version range.
Keep registry access safe and documented
CI needs a clear path to authenticate, tag, and push. Store credentials in the CI secret manager, use a short-lived token where available, and send passwords over standard input instead of exposing them in command arguments:
# REGISTRY_USER and REGISTRY_TOKEN come from the CI secret store.
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" \
--username "$REGISTRY_USER" --password-stdin
IMAGE="$REGISTRY_HOST/aceu-trust-demo:$GIT_SHA"
docker build --pull --tag "$IMAGE" .
docker push "$IMAGE"
Document who owns the credentials, how rotation works, and what a failed login looks like. Never print secret values into build logs.
Compare artifacts when behavior changes
If local and cloud behavior diverge, inspect the image’s package inventory or generate an SBOM. Compare the resolved versions and base-image digest, then decide whether to re-lock, refresh the image, or accept the change with an appropriate test update. Looking at the built artifact beats guessing from the Dockerfile alone.
Trade-offs and failure modes
- Exact pins vs. security updates: pins improve repeatability, while scheduled updates and scans keep them current.
- Tags vs. digests: tags are easier to refresh; digests make the selected base explicit and immutable.
- Locks vs. setup time: generating a lock adds a step, but saves time when builds need to be compared.
- Registry authentication: expired tokens or changed scopes should produce an explicit CI failure, not a misleading successful pipeline.
Try it in your pipeline
- Replace floating version ranges with exact direct pins and commit a full lock file.
- Pick a base-image policy: tag plus scheduled refresh, or digest plus an explicit update cadence.
- Build with
--pullwhen you want CI to check for updated base images. - Record the image tag or digest produced by CI and inspect its packages when behavior changes.
Closing thought
“Same Dockerfile” rarely guarantees “same runtime.” Lock files, explicit base-image policy, and artifact inspection turn hidden drift into a change the team can review.