Docker Doctor is an opinionated static analysis CLI for Dockerfile and Docker Compose files. This post covers the design decisions behind it: the doctor-tool pattern it borrows from React Doctor, how the 0-100 health score is computed, and where static analysis stops being useful.
You push to main. The image builds, CI is green, the container starts, and nobody looks at the Dockerfile again. Six months later it is still running as root, still pulling node:latest, still shipping a gigabyte of build tooling to production. Nothing failed, so nothing got fixed.
I built Docker Doctor because I kept writing that Dockerfile. It is an opinionated static analysis CLI for Dockerfile and Docker Compose files: one command, 21+ rules across security, performance, best practices, Compose, and image size, and a 0–100 health score at the end. Rather than walk through the feature list (the repo covers that), this post is about the design decisions behind it: why a score instead of a wall of errors, why the score decays instead of subtracting, and where a tool like this stops being useful.
The doctor-tool pattern
The shape of the tool is borrowed. React Doctor scans a React codebase and hands you a score; Shad Scan does the same for shadcn/ui projects. What both get right is the onboarding: you don't write a config, you don't pick a preset, you don't negotiate rule severities with your team. You run one command against the project you already have and get a number you can react to.
That matters because the alternative already exists and mostly goes unused. Every team I have worked with has a carefully configured linter for application code, and a Dockerfile that no tool has ever looked at. The Dockerfile sits outside every feedback loop we build for ourselves: no type checker, no test suite, no review culture around COPY ordering. If a tool needs setup before its first useful output, for infrastructure files it simply never runs.
What a scan looks like
npx @docker-doctor/cli@latestNo install, no config. Docker Doctor discovers the Dockerfiles and Compose files in your project, runs the rule set, and prints diagnostics with fix guidance, grouped under a score:
Here is the kind of file it is built to catch, one I have written more than once:
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Six lines, at least four problems. node:latest makes the build non-reproducible; the base image can change underneath you between two builds of the same commit. COPY . . before npm install invalidates the dependency layer on every source change, so the build cache does nothing. npm install ignores the stricter lockfile semantics of npm ci. And nothing here drops root: the process runs as UID 0, which is the default nobody remembers to change.
That last one is the rule I care about most, and every rule can explain itself from the CLI:
npx @docker-doctor/cli@latest rules list
npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-userWhen a rule genuinely doesn't fit your project, severities can be overridden in a config file. The config is an escape hatch, not a prerequisite:
import type { DockerDoctorConfig } from "@docker-doctor/cli";
const config: DockerDoctorConfig = {
rules: {
"docker-doctor/no-root-user": "error",
},
};
export default config;After the first scan, the CLI also offers to wire the same check into a GitHub Actions workflow, so the score has somewhere to live beyond your terminal history.
How the score works
The score is where most of the design effort went, so it gets its own section. Every diagnostic adds a penalty based on severity:
| Severity | Penalty |
|---|---|
| error | 10 |
| warning | 4 |
| info | 1 |
The obvious move is to subtract the total from 100. That has a failure mode on exactly the projects that need the tool most: a repository with years of accumulated Dockerfiles bottoms out at 0 almost immediately, and once the score is pinned there it stops saying anything. Fixing ten errors on a project stuck at 0 produces no visible change, which kills the motivation the score exists to create.
So the score is an asymptotic decay curve instead of a subtraction:
score = round(100 * e^(-penalty / K)) // K = 70A clean project scores exactly 100, since e to the power of zero is one; no special-casing needed. As penalties pile up the score keeps falling but never pins to the floor, so a messy project that fixes things still sees the number move. K = 70 is tuned so a single warning (penalty 4) lands around 94, comfortably inside the Excellent bucket, because one warning should not feel like failure, while errors and repeated warnings keep eroding the score meaningfully. The labels sit on fixed thresholds: 90 and up is Excellent, 75 is Good, 50 is Needs Work, and below that is Critical.
Where it sits next to hadolint, dockle, and trivy
Docker Doctor is not trying to replace the existing container-security stack, and it is worth being precise about the boundaries:
| Tool | Input | When it runs | What it tells you |
|---|---|---|---|
| hadolint | A single Dockerfile | Pre-build | Dockerfile lint violations, shell issues via ShellCheck |
| dockle | A built image | Post-build | CIS-benchmark-style image checks |
| trivy | Images, filesystems, repos | Post-build / CI | Known CVEs in OS packages and dependencies |
| Docker Doctor | The whole project: Dockerfiles + Compose | Pre-build | Health score, prioritized fix guidance, Compose-aware rules |
The gap it fills is project-level and pre-build. hadolint sees one file at a time and has no opinion about your Compose topology; trivy reports on the image you already built. Docker Doctor reads the sources that produce the image and scores the project as a whole.
Sharp edges
Static analysis of Dockerfiles has real limits, and pretending otherwise would undermine the tool.
- Build args hide information.
FROM ${BASE_IMAGE}cannot be judged without knowing the value at build time, and a rule that guesses will be wrong in both directions. Some diagnostics are necessarily softer than the underlying risk. - The score is a proxy, not a guarantee. A 100 means the files follow the rules; it says nothing about CVEs in your base image. You still need a scanner like trivy in the pipeline, because a health score and a vulnerability report answer different questions.
- Opinionated defaults are wrong somewhere.
no-root-useris non-negotiable in most environments and pure noise in a rootless-container setup that already enforces it at the runtime layer. Downgrading a rule deliberately is healthy; silencing a whole category because one rule annoyed you is how tools die. - Compose is a moving target. The spec has absorbed years of drift (
versionkeys that are now no-ops, fields that moved between releases), and rules have to target the current spec while most real-world files predate it.
The reason I keep building doctor-shaped tools is that the hard part of infrastructure hygiene was never knowledge. Everyone knows to pin base images and drop root; the docs have said so for a decade. What is missing is a feedback loop cheap enough to actually run. One command with a number at the end is the cheapest loop I know how to build.
Your Dockerfile is probably wrong. It now costs one command to find out why.