The Architectural Decisions Hidden in a Dockerfile
The Dockerfile was six lines. One was COPY . . Another was an undocumented ARG from two engineers ago that baked a dev secret into the base image. The other four were boilerplate copied from a vendor tutorial. The file had shipped to production untouched for four years, and nobody had ever read it as a thing that contained decisions.
Every line answered a question about supply chain, trust, ownership, or operability. The question got answered by default, not by deliberate choice, and the default stuck around. Most of the architectural debt in containerized systems doesn’t sit in the orchestrator or the service mesh. It sits in the Dockerfile, where assumptions calcify into infrastructure long before anyone thinks to review them.
no HEALTHCHECK] Cmd[CMD ...] F --> Supply[Supply chain
maintainer, patch
cadence, license] C --> Trust[Trust boundary
what the build can see] R --> Own[Ownership of
dependency tree] U --> Priv[Privilege model] H --> Op[Operability contract] Cmd --> Life[Lifecycle and
signal handling] style F fill:#eaf2fa style C fill:#fff5e0 style R fill:#eaf2fa style U fill:#fff5e0 style H fill:#eaf2fa style Cmd fill:#fff5e0
Figure 1. A six-line Dockerfile maps to at least six architectural commitments. The decisions don’t disappear because the syntax is short. They just get made by whoever wrote the line, on the day they wrote it, with the context they had then.
The base image is a supply-chain decision
FROM python:3.8-slim looks like a runtime declaration. It’s a contract with a maintainer. Whoever ships that base image controls your patch cadence, your CVE exposure, your license posture, and the kernel-adjacent runtime your code sits on top of. You don’t get to opt out of their choices. You opted in the moment you wrote the line.
Pinned tags trade reproducibility for patching. python:3.8.10-slim-bullseye builds the same image today and eighteen months from now. It also builds the same vulnerabilities. Floating tags trade the other way: python:3.8 patches when the maintainer cuts a new image, and breaks your build the day they ship an incompatibility. Most teams have an unspoken policy that flips depending on whoever last touched the file.
Distroless, slim, and full aren’t a size decision. They’re an incident-response decision:
| Strategy | Shell | CVE surface | Patch cadence | When you need to debug |
|---|---|---|---|---|
| Distroless | None | Minimal | Maintainer | Rewrite the playbook |
| Slim | Yes | Moderate | Maintainer | Standard exec into container |
| Full (debian/ubuntu) | Yes | Largest | You (APT) | Full debugging toolset |
A small team I worked with, one that had done serious security work, shipped distroless to all production services. Half a year later, their incident playbook required a shell that wasn’t there. They kept distroless and rewrote the playbook. The right answer wasn’t obvious in advance, and I’ll admit it isn’t obvious in retrospect either: the tradeoff between attack surface and incident-response capability depends on which incident you’re preparing for. The decision had been made by whoever copied the example from a security blog.
The vendor question hidden in FROM is the one most teams don’t ask out loud: who do you trust to ship the runtime your business depends on, and on what cadence? “We use the official Python image” is an answer. So is “we maintain our own base image and inherit from upstream every six weeks.” The unstated answer is what produces a four-year-old base image with a four-year-old vulnerability profile.
The build context is a trust boundary
COPY . /app says the build process is permitted to see everything in the repo at the moment the build runs. Most teams treat that as a convenience. It’s a policy.
.dockerignore is the policy file. Most repos don’t have one. The ones that do treat it as a hygiene file: keep the image small, exclude .git, exclude node_modules. The primary purpose is different: declaring what the build is not allowed to see. Test fixtures with sample credentials. The .env.local someone committed and reverted. The terraform/ directory with state files. The default .dockerignore is the one nobody wrote, and it makes the opposite claim.
Multi-stage builds are sold as a size optimization. They’re a privilege-separation pattern. The build stage has access to source, tooling, package registries, and sometimes private credentials. The runtime stage carries forward only what you explicitly copy:
# Build stage: full access to source, tools, and private registries
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage: receives only what build stage explicitly hands over
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages \
/usr/local/lib/python3.12/site-packages
COPY src/ . # explicit: no .env, no fixtures, no .git
USER nobody # omit this and the container runs as root
HEALTHCHECK --interval=30s \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
LABEL git-sha="${GIT_SHA}" team="platform"
CMD ["python", "app.py"]
.env, fixtures
build secrets] --> Build[Build stage
compilers, toolchain
registries] Build -->|COPY --from=builder
artifacts only| Runtime[Runtime stage
app + deps
no build tools] Runtime --> Prod[Production] style Src fill:#fdd style Build fill:#fff5e0 style Runtime fill:#eaf2fa
Figure 2. Multi-stage builds as a privilege boundary. The runtime stage carries only what the build stage explicitly hands over. The trust surface shrinks from “everything in the repo” to “the artifact the app needs to run.”
That four-year-old .env file shipped because the original COPY . happened before anyone thought about what the build context contained. Nobody reviewed the file in four years. The credentials kept shipping.
The package install is an ownership statement
RUN pip install -r requirements.txt decides who owns the dependency tree, when it gets refreshed, and what happens when one of those dependencies turns out to be malicious or abandoned.
Lock files versus version ranges shapes what your future incident response looks like. A locked tree gives you reproducible dependencies and a reproducible vulnerability profile. A range gives you the latest of each at build time. Teams that haven’t decided which they’re using get the worst of both: locks that get regenerated when someone runs the lock command, ranges that drift, and a vulnerability tracker that argues with the build daily.
The decision to trust upstream registries (PyPI, npm, Docker Hub) doesn’t disappear when nobody thinks about it. Internal mirrors, signature verification, and an SBOM produced at build time are mitigations. A Slack message saying “we should look at supply chain stuff at some point” is not. This has the same shape as the optionality kind of architectural debt: invisible until you need the option, expensive when you do.
The CMD is an operability commitment
CMD ["python", "app.py"] says this application handles its own lifecycle: signal handling, graceful shutdown, log buffering, child process management. Whatever Python does as PID 1 is what your container does. Most application code wasn’t written with PID 1 in mind.
The PID 1 problem stops being theoretical the moment your application spawns subprocesses. Most teams discover it through an incident: shutdowns hang, restarts take too long, in-flight requests get cut off. The fix (an init system like tini, or an entrypoint that handles signals correctly) is a one-line change that belongs in the review, not in the post-mortem.
HEALTHCHECK is the same shape. Without it, the orchestrator decides what “unhealthy” looks like, using its own probe defaults. The container restarts you can’t explain are usually here. A health check directive answers the question explicitly; its absence cedes the answer to defaults that often differ from what the application would say about itself.
What’s not in the file is also a decision
Every Dockerfile carries the directives that aren’t there.
No USER means the container runs as root. That’s a decision about kernel exposure and breakout risk, whether or not anyone made it.
No labels means provenance doesn’t travel with the artifact. When a vulnerability scanner flags the image six months later, you can’t tell which commit produced it, which team owns it, or where it’s running. Anonymous images don’t get patched. They get rediscovered.
A SaaS company I worked with, a team with strong code review culture that had never extended that discipline to infrastructure files, ran a post-audit review across hundreds of services. The vast majority had no USER, no HEALTHCHECK, and no labels. The remediation took two engineers a quarter. The gap had existed for four years because nobody had ever treated the Dockerfile as a reviewable artifact. Code review skipped over it. Architecture review never looked at it. The file lived in the repo the way a .gitignore does: present, useful, unread.
This is the same pattern as decisions hidden in IaC: a small file accumulates commitments faster than its readers do.
Read the file before it reads you
A Dockerfile is documentation of decisions whether the author intended that or not. The right move is rarely “rewrite all our Dockerfiles.” It’s “name the decisions next time we change one.” The line you’re about to add answers a question. Write the question down in the commit message. The next reader will know whether the answer still holds.
The team finally deleted their four-year-old .env file. They also deleted the COPY . that let it ship, and rewrote the build to be explicit about what the runtime stage was allowed to carry. Six lines became eleven. The eleven are decisions they can defend. The six were decisions nobody had made.