🐳 Dockerfile Linter & Best-Practices Checker

Last updated: May 9, 2026
.tw *{box-sizing:border-box;margin:0;padding:0} .tw{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#e6edf3;border-radius:12px;padding:24px;max-width:900px;margin:0 auto} .tw h2{font-size:1.25rem;font-weight:700;color:#58a6ff;margin-bottom:6px;display:flex;align-items:center;gap:8px} .tw .subtitle{font-size:.85rem;color:#8b949e;margin-bottom:18px} .tw label{display:block;font-size:.8rem;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px} .tw textarea{width:100%;background:#161b22;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-family:'JetBrains Mono','Fira Code','Consolas',monospace;font-size:.82rem;line-height:1.6;padding:14px;resize:vertical;min-height:260px;outline:none;transition:border-color .2s} .tw textarea:focus{border-color:#58a6ff} .tw .btn-row{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap} .tw button{padding:10px 22px;border-radius:7px;border:none;cursor:pointer;font-size:.9rem;font-weight:600;transition:opacity .15s} .tw .btn-primary{background:#238636;color:#fff} .tw .btn-primary:hover{opacity:.85} .tw .btn-clear{background:#21262d;color:#8b949e;border:1px solid #30363d} .tw .btn-clear:hover{color:#e6edf3} .tw .btn-sample{background:#1f3b5a;color:#58a6ff;border:1px solid #2f5a8a} .tw .btn-sample:hover{opacity:.8} .tw #tw-result{margin-top:20px;display:none} .tw .summary-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap} .tw .badge{padding:5px 14px;border-radius:20px;font-size:.8rem;font-weight:700;display:flex;align-items:center;gap:6px} .tw .badge-err{background:#3d1f1f;color:#f85149;border:1px solid #6e2222} .tw .badge-warn{background:#2d2208;color:#e3b341;border:1px solid #5c3f0a} .tw .badge-info{background:#102035;color:#58a6ff;border:1px solid #1a4060} .tw .badge-ok{background:#102316;color:#3fb950;border:1px solid #1a4a24} .tw .issue-list{display:flex;flex-direction:column;gap:10px} .tw .issue-card{border-radius:8px;padding:14px 16px;border-left:4px solid;background:#161b22} .tw .issue-card.error{border-color:#f85149} .tw .issue-card.warning{border-color:#e3b341} .tw .issue-card.info{border-color:#58a6ff} .tw .issue-header{display:flex;align-items:flex-start;gap:10px;margin-bottom:6px} .tw .sev-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;margin-top:5px} .tw .sev-dot.error{background:#f85149} .tw .sev-dot.warning{background:#e3b341} .tw .sev-dot.info{background:#58a6ff} .tw .issue-title{font-size:.88rem;font-weight:600;color:#e6edf3;flex:1} .tw .line-badge{font-size:.72rem;background:#21262d;border:1px solid #30363d;color:#8b949e;padding:1px 8px;border-radius:10px;flex-shrink:0;white-space:nowrap} .tw .rule-tag{font-size:.7rem;color:#8b949e;font-family:monospace;background:#0d1117;padding:1px 6px;border-radius:4px;margin-left:2px} .tw .issue-fix{font-size:.82rem;color:#8b949e;line-height:1.5;background:#0d1117;border-radius:6px;padding:8px 12px;margin-top:4px;border:1px solid #21262d} .tw .fix-label{color:#3fb950;font-weight:700;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:3px} .tw .all-good{text-align:center;padding:32px;color:#3fb950;font-size:1rem;font-weight:600} .tw .all-good span{font-size:2.5rem;display:block;margin-bottom:8px} .tw .section-title{font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#8b949e;margin-bottom:10px;display:flex;align-items:center;gap:6px} .tw .section-title::after{content:'';flex:1;height:1px;background:#21262d}

🐳 Dockerfile Linter

Paste your Dockerfile below to catch security issues, image bloat, and broken layer ordering — with copy-paste fixes.

]]> Most Dockerfiles in the wild are a graveyard of well-intentioned shortcuts. A quick FROM ubuntu:latest, a few RUN apt-get install lines, maybe a stray ADD . /app, and suddenly you have a 1.4 GB image that takes twelve minutes to pull in CI, runs as root, and will silently change behaviour on the next rebuild because nobody pinned anything. None of these problems are hard to fix. They just require knowing what to look for.

The Tag Pinning Problem Is Worse Than You Think

Unpinned image tags are the most deceptively dangerous Dockerfile mistake. FROM node:18 today and FROM node:18 in six months may pull different images — different OS libraries, different OpenSSL versions, different locale settings. If you're lucky, the difference shows up as a test failure. If you're unlucky, it shows up as a production incident that takes hours to root-cause because nothing in your code changed.

The fix is straightforward: pin to a specific minor version (node:18.20-bookworm-slim) or go further and pin by digest (node:18@sha256:e4c9...7ab). Digest pinning is airtight — the image cannot change without you explicitly updating the hash. Tools like Dependabot and Renovate can automate digest bumps, giving you reproducibility without the maintenance burden.

Why apt-get Layer Order Breaks Your Cache

The single most common cache-busting pattern in Debian/Ubuntu-based images is splitting apt-get update and apt-get install across two RUN instructions:

RUN apt-get update
RUN apt-get install -y curl wget

Docker caches each layer independently. After the first build, the apt-get update layer is cached. On the next rebuild — even weeks later — Docker serves that stale cached layer and runs apt-get install against an outdated package index. You end up installing old package versions without knowing it.

The canonical fix is to combine them, add --no-install-recommends to avoid bloat, and clean up the cache in the same layer (you cannot clean it later — Docker layers are immutable):

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
       curl \
       ca-certificates \
    && rm -rf /var/lib/apt/lists/*

That last line alone typically saves 30–80 MB per image.

The Root User Default That Ships to Production

Docker containers run as root by default. That single fact is responsible for a staggering number of container escape vulnerabilities. If an attacker exploits your application inside a container running as root, they can potentially write files to bind-mounted host directories, exhaust host resources without namespaced limits, and in some misconfigurations break out of the container entirely.

The fix takes three lines and costs nothing in terms of functionality for the vast majority of applications:

RUN groupadd -r appuser && useradd -r -g appuser -u 1001 appuser
USER appuser

Place this before your CMD or ENTRYPOINT, and your application runs as an unprivileged user. If your app genuinely needs to bind to port 80 or 443, use a reverse proxy in front and run the app on a high port instead — a much cleaner architecture anyway.

Dependency Manifests Must Come First

One of the highest-leverage build speed tricks in Docker is staging your dependency install to exploit layer caching. The pattern is simple: copy only your dependency manifest first, install dependencies, then copy the rest of your source code.

# Good — dependency layer is cached unless package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

# Bad — every source change invalidates the npm ci cache
COPY . .
RUN npm ci

In the bad version, every single file change — including editing a comment in a JavaScript file — busts the npm ci cache and forces a full re-install. On a project with hundreds of dependencies, this can add three to ten minutes to every CI run. The correct ordering makes dependency caching work as intended: npm only re-runs when package.json or package-lock.json actually changes.

The same pattern applies to Python (requirements.txt first, then pip install, then COPY . .), Java (pom.xml first), Go (go.mod and go.sum first), and every other ecosystem with a dependency manifest.

ADD Is Almost Never What You Want

ADD has two behaviours that make it dangerous as a default: it can fetch from URLs, and it auto-extracts tar archives. This makes it non-obvious and error-prone for the common case of just copying a local file. The Docker team's own best practices documentation explicitly recommends using COPY for all local file copies and reserving ADD only for cases where you specifically need its extended functionality — which is rare.

Using COPY is not pedantry. It makes your intent explicit and avoids surprises when someone else reads or modifies the Dockerfile later.

The HEALTHCHECK Nobody Writes

Without a HEALTHCHECK, Docker considers a container healthy the instant the process inside it starts. This means a web server that starts its process but fails to bind to a port — or an app that is in a crash-restart loop — will appear green in your orchestrator's dashboard until something downstream notices the problem.

A basic healthcheck adds almost no overhead and dramatically improves observability:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

Kubernetes has its own liveness and readiness probes, but adding a HEALTHCHECK in the Dockerfile gives you the same safety net when running containers directly with Docker Compose or in simpler environments.

Multi-Stage Builds: The Image Size Multiplier

The single most effective technique for shrinking production images is multi-stage builds, yet many teams skip them. The concept is simple: use a full build environment in the first stage (compilers, dev dependencies, test tools), then copy only the compiled artifacts into a minimal final stage.

FROM node:18.20-bookworm AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18.20-bookworm-slim AS production
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

The production image never contains your build tools, test frameworks, TypeScript compiler, or development-only dependencies. A typical Node.js image can shrink from 1.2 GB to under 200 MB with this pattern — meaningfully faster pulls, a smaller attack surface, and a reduced vulnerability footprint in every security scan.

Getting into the habit of writing Dockerfiles defensively — pinned tags, layer-ordered installs, non-root users, explicit COPY — pays dividends across every service your team ships. These aren't premature optimisations. They're the baseline that prevents the kind of slow-motion build rot that turns a 3-minute pipeline into a 25-minute one over the course of a year.

]]>

FAQ

Why does my image get larger every time I add a RUN rm command?
Docker layers are additive and immutable — each RUN instruction creates a new layer that adds to the total image size. A file created in layer 2 and deleted in layer 3 still exists in layer 2; the deletion just hides it in the final filesystem view. To actually remove files, you must delete them in the exact same RUN instruction where they were created. For apt-get, this means: RUN apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* — all in one RUN, on one layer.
What is the difference between CMD and ENTRYPOINT, and which should I use?
ENTRYPOINT sets the executable that always runs — it cannot be overridden by arguments to docker run. CMD provides default arguments that can be overridden. The typical pattern is ENTRYPOINT ["node"] and CMD ["server.js"] — so docker run myimage app.js overrides CMD but still uses node. For most application containers, using just CMD ["node", "server.js"] in exec form (JSON array, not shell string) is the simplest and most flexible approach. Always use the JSON array form — CMD node server.js spawns a shell, which means signals like SIGTERM won't reach your process.
Should I use a minimal base image like Alpine or stick with Debian/Ubuntu?
Alpine images (5 MB) are dramatically smaller than Debian slim (70–100 MB), but Alpine uses musl libc instead of glibc. Most software works fine, but some compiled binaries and native Node/Python packages have compatibility issues with musl and require recompilation. For Go and static binaries, Alpine (or even FROM scratch) is ideal. For Python and Node.js, Debian-slim is usually a better tradeoff — you get a much smaller image than the default without the compatibility headaches of Alpine.
Is it safe to use pip install with a requirements.txt in Docker?
It is safe but requires a few practices to be reproducible and lean. Always pin exact versions in requirements.txt (use pip freeze > requirements.txt), always pass --no-cache-dir to avoid storing wheel caches in the layer, and consider pip install --no-cache-dir -r requirements.txt. For stricter reproducibility, generate a requirements.txt with hashes: pip-compile --generate-hashes and then install with pip install --require-hashes -r requirements.txt. This prevents tampered packages from being installed even if PyPI is compromised.
Does running as non-root actually prevent container escapes?
Running as non-root closes a large class of vulnerabilities but is not a complete security boundary by itself. It prevents privilege escalation via SUID binaries, limits damage if your app is compromised, and is required by many Kubernetes security policies. However, a fully privileged container (--privileged flag) can still escape regardless of USER. For defence-in-depth, combine non-root USER with read-only root filesystems (--read-only), dropping unnecessary Linux capabilities (--cap-drop=ALL), and using seccomp profiles.
How do I pass secrets like API keys into a Docker build without baking them into the image?
Never put secrets in ENV, ARG, or any COPY — they persist in image history and are visible with docker history. Use Docker BuildKit's --secret flag: RUN --mount=type=secret,id=api_key cat /run/secrets/api_key lets you read the secret during build without it appearing in any layer. For runtime secrets, inject them via environment variables at docker run time, or use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets) mounted into the container at startup.