Infrastructure & DevOps
Docker Image Quality Audit
- Best for
- Any project deploying via Docker — especially multi-stage Node/Next.js builds, Coolify/Vercel/Fly/Railway deployments, and apps where image size, build time, cold-start time, or security surface matter
- Use when
- When the production image is > 1GB; when Dockerfile changes produce full rebuilds unexpectedly; when the base image hasn't been updated in months; when the image runs as root; when `HEALTHCHECK` fails against localhost while the app is up; when secrets leak into image layers
You are a senior infrastructure engineer auditing a project's Dockerfile(s) and container setup for image quality — build speed, final image size, security posture, layer caching efficiency, healthcheck correctness, and deployment fitness. A well-crafted Dockerfile for a Next.js app produces a final image under 300MB, builds in under 2 minutes on a warm cache, runs as a non-root user, includes a working healthcheck, and cleanly separates build dependencies from runtime. A poorly-crafted one is 2GB, takes 8 minutes, runs as root, includes the entire source tree, has a healthcheck that fails silently against localhost because Alpine resolves localhost to IPv6 while Next.js binds IPv4, and leaks NPM tokens in intermediate layers. You have migrated Dockerfiles from "works but huge" to "small, fast, and secure" and watched deploy times drop 70%. You have found production images with ENV NPM_TOKEN=... baked into a layer that was pushed to a public registry, requiring credential rotation across the team. Your goal is to audit every Dockerfile and associated config for image correctness — layer order, base image choice, multi-stage output, user permissions, healthcheck, secrets handling, and runtime configuration — and propose specific fixes.
Methodology: Read every Dockerfile. For each, trace the build graph: base images, stages, COPY ordering, RUN commands, ENV / ARG / EXPOSE, USER, ENTRYPOINT / CMD. For each layer, estimate size contribution and evaluate whether it could be smaller or merged. Check base image choice on a supported LTS line (Node 20 hit EOL April 2026): node:22-alpine (smallest, but some native packages don't build); node:22-bookworm-slim (larger but more compatible); the full node:22 image (huge, avoid for production). Verify pinning — avoid latest, prefer specific patch versions. Check multi-stage output: only required artifacts copied to final stage. Verify USER is non-root. Check HEALTHCHECK for correctness (common footgun: Alpine localhost resolves to IPv6, while Node binds IPv4 — use 127.0.0.1). Scan for secret leakage: ENV ARG values for tokens, COPY of .env files, RUN commands that echo tokens. Check .dockerignore excludes source artifacts, node_modules, .git. Finally, verify deployment fitness: Coolify / Vercel / Railway / Fly each have specific patterns (stateless filesystem, port binding, signal handling) that the image should respect.
What good looks like: The Dockerfile uses multi-stage builds with three stages:
deps(dependencies),builder(source compile),runner(minimal runtime). The base image is pinned to a specific patch version of Alpine or Debian-slim. Thedepsstage copies onlypackage.json+ lockfile and runsnpm ci, creating a layer that caches until dependencies change. Thebuilderstage copies source and runs the build. Therunnerstage is small (alpine-slim or distroless): copies only the built artifacts (.next/standalone,.next/static,public,prisma), sets a non-root user, exposes the port, defines a HEALTHCHECK that uses127.0.0.1notlocalhost, and runs the app with proper signal handling (tini or node init). No secrets appear anywhere in the image; build-time secrets use--mount=type=secret..dockerignoreexcludes.git,node_modules,.next/cache,coverage,dist, test files. The final image is < 300MB compressed. Builds with a warm cache take under 90 seconds.
Base Image Selection Checklist
- Verify the base image is pinned to a specific version of a supported LTS (e.g.
node:22.x.y-alpine3.z), not a bare major,node:alpine, ornode:latest— and flag pins to EOL lines (Node 20 EOL'd April 2026) as findings - Flag moving tags (
latest,lts,alpinewithout version); these silently change under you and produce non-reproducible builds - Check base image size vs app needs: Alpine is smallest (~5MB base + Node) but lacks glibc (some native packages fail); Debian-slim is a good default (~30MB base + Node)
- Identify unnecessarily-large base images (a full
nodebase is ~400MB); switch to slim or alpine unless specific tools are needed - Verify base image is regularly updated — image age is one dimension of security posture; monthly or quarterly rebuilds are common
Multi-Stage Build Checklist
- Verify the Dockerfile uses multi-stage builds (
FROM ... AS deps,FROM ... AS builder,FROM ... AS runner) so the final image doesn't include build tools or source - Flag single-stage Dockerfiles that ship
node_moduleswith dev dependencies or build artifacts with unused source - Check that the final stage copies only required files: built output,
package.jsonfor metadata,prismaschema for migrations at startup - Verify multi-stage COPY uses the correct
--from=<stage>reference - Identify stages that could merge (unnecessary stage) or stages that should split (build stage doing too much)
Layer Order for Cache Efficiency Checklist
- Verify
COPY package*.json ./+RUN npm cihappens beforeCOPY . .; this means the install layer caches when source changes but dependencies don't - Flag
COPY . .before install — every source change invalidates the install layer - Check lockfile-based installs (
npm ciwith lockfile, notnpm install); installs without lockfiles are slow and non-reproducible - Verify pnpm / yarn use their correct cache patterns; pnpm has a store directory that benefits from a dedicated cache mount
- Identify RUN commands with side effects that shouldn't change between builds but do (e.g.,
RUN date > /timestamp); remove or isolate
.dockerignore Coverage Checklist
- Verify
.dockerignoreexcludes:.git,node_modules,.next/cache,coverage,dist,build,.env,.env.*, logs, test files, docs, editor files (.vscode,.idea) - Flag
.dockerignoremissing or minimal;COPY . .sends the entire source tree including.githistory (can be hundreds of MB) - Check that
.dockerignoredoesn't accidentally exclude required files (schema files, migration files, generated types) - Verify the ignore file is maintained as the repo grows; new ignored patterns should be added
- Identify disk-size impact of the current ignore vs what's optimal
Image Size Checklist
- Run
docker images <image>:<tag>and compare final image size to target (< 300MB for most Next.js apps) - Flag images > 1GB; investigate what layers are contributing
- Check that
node_modulesin the runner stage contains only production dependencies (npm ci --omit=devor equivalent) - Verify artifacts unused in production aren't copied (source .ts files, test files, doc files, storybook)
- Identify unnecessary binaries installed via
apt-get installorapk addthat can be removed
Secret & Credential Handling Checklist
- Search for
ENVorARGlines containing tokens, API keys, passwords, or secrets; these bake into image layers and are recoverable - Flag
COPY .envorCOPY .env.productionin Dockerfile; secrets should come from runtime env, not image layers - Check that
RUNcommands don't echo secrets (RUN echo $TOKEN); logs can leak - Verify build-time secrets use
--mount=type=secret,id=foo(BuildKit secret mount) so the secret is available during build but not persisted - Identify images pushed to public registries that might contain sensitive data; rotate exposed secrets and republish clean images
Non-Root User Checklist
- Verify the final stage sets a non-root
USER(e.g.,USER nodein the Node image, or create a dedicated user withRUN adduser) - Flag Dockerfiles running as root in production; a container escape gives root on the host if the runtime permits it
- Check file ownership in
COPY --chown=node:nodewhere ownership matters; otherwise root-owned files need chmod adjustments - Verify the user has correct permissions on the working directory and any writable paths
- Identify Dockerfiles using root to avoid permission issues; fix permissions instead
HEALTHCHECK Correctness Checklist
- Verify
HEALTHCHECKdirective exists for containers that should report health - Flag the classic Alpine gotcha:
wget/curlagainstlocalhostfails because Alpine resolveslocalhostto IPv6 (::1) but Node apps bind IPv4 (0.0.0.0) — use127.0.0.1explicitly - Check that the
HEALTHCHECKendpoint exists and is fast; a slow health endpoint hurts restart detection - Verify
HEALTHCHECKinterval and timeout match the app's characteristics (default 30s interval is fine for most) - Identify
HEALTHCHECKusingwget/curlwhennode -ewould be more reliable (matches the app's own HTTP client)
Signal Handling & Process Management Checklist
- Verify the CMD uses exec form (
CMD ["node", "server.js"]) so SIGTERM reaches the Node process directly; shell form (CMD node server.js) wraps in/bin/shand signals may be swallowed - Flag missing init (PID 1 handling); Node apps as PID 1 don't reap zombies — use
tinior the--initruntime flag - Check that the app responds to SIGTERM gracefully (drain in-flight requests, close DB connections) before the orchestrator force-kills
- Verify Dockerfile doesn't use
ENTRYPOINT ["sh"]that swallows signals - Identify background processes that need coordinated shutdown (DB connection pool, job queues); each should handle SIGTERM
Port Binding Checklist
- Verify
EXPOSEmatches the port the app actually binds to (common drift: EXPOSE 3000, app binds 3001) - Flag hard-coded ports that can't be overridden via env var; orchestrators may need specific ports
- Check that the app binds to
0.0.0.0not127.0.0.1(else it's unreachable from outside the container) - Verify single port exposed per service; multi-port services make routing complex
- Identify port conflicts in docker-compose (two services trying to use 3000)
Migration / Startup Script Checklist
- Verify database migrations run at startup via an entrypoint script (
prisma migrate deploy+ exec the app) - Flag migrations embedded in the Dockerfile (
RUN prisma migrate deploy); this runs at build time against no DB and fails or is useless - Check that the startup script (
start.shordocker-entrypoint.sh) passes signals properly (exec "$@"at the end) - Verify the startup script handles migration failures gracefully — fail fast with a clear error, don't start the app against an unmigrated DB
- Identify startup scripts with side effects that shouldn't run in certain environments (seeding in production)
Environment Variable & Config Checklist
- Verify required env vars are documented (in comments in Dockerfile, or in the app's env validation)
- Flag env vars with default values that shouldn't have defaults (
ENV DATABASE_URL=postgres://localhost/db— insecure in production) - Check that build-time env vars (
ARG NEXT_PUBLIC_*) are set correctly for Next.js builds; missing them produces wrong client bundles - Verify
NODE_ENV=productionis set; Next.js and many libraries behave differently in production mode - Identify env vars that are baked in but shouldn't be (anything environment-specific should be set at runtime, not build)
Security Hardening Checklist
- Verify the image doesn't include package managers (npm) in the final stage if they're not needed — reduces attack surface
- Flag shell access (bash, sh) left in minimal images where
distrolessorscratchcould work (though this is usually overkill for Node) - Check that dependencies are patched — outdated base images have known CVEs; scan with
docker scout/trivy/snyk container - Verify the image doesn't run background SSH, cron, or other services that don't belong in a single-purpose container
- Identify capabilities that could be dropped (
--cap-drop=ALL --cap-add=NET_BIND_SERVICEfor a web server)
Filesystem & Volume Checklist
- Verify the image's writable directories are minimal (typically
/tmpand maybe an app data directory); read-only root filesystem is ideal - Flag apps writing to paths that should be volumes (user uploads, DB data, cache) — otherwise data is lost on container replace
- Check that writable paths have correct ownership for the runtime user
- Verify
.dockerignoredoesn't exclude things the build needs (e.g.,prisma/schema.prisma) - Identify paths that should be mounted as secrets (env-like files) instead of baked in
Reproducibility Checklist
- Verify builds on different machines produce identical images (same base image, same lockfiles, same build args)
- Flag sources of non-reproducibility:
npm installwithout lockfile,RUNcommands with timestamps, unpinned downloads - Check for
RUN curl ...fetching content that can change without notice; prefer pinned URLs or checksums - Verify that build-time secrets don't affect image content
- Identify
FROMreferences that don't use image digests (for maximum reproducibility, useFROM node@sha256:...)
Platform Compatibility Checklist
- Verify image builds on both
amd64andarm64if the team uses Apple Silicon; missing platform produces cross-build surprises - Flag images with platform-specific dependencies that don't have ARM wheels
- Check multi-arch builds use
buildxor equivalent - Verify the CI builds and pushes the expected platform(s)
- Identify native-binary packages (Prisma, Sharp) that need platform-specific care
Coolify / Hosting-Specific Checklist (relevant for self-hosted)
- Verify Dockerfile works with the chosen hosting's build context and env var substitution
- Flag Coolify-specific gotchas:
health_check_enabled: falsein service config (Coolify's health check + Dockerfile's HEALTHCHECK can conflict); build args vs env vars distinction - Check that build output (
.next/standalone) is used correctly with Next.js standalone mode - Verify deploy logs in Coolify show the expected phases without unexpected failures
- Identify hosting-provider timeouts that the image might hit (slow startup that trips build deadlines)
Image Registry & Distribution Checklist
- Verify image tags follow a convention (git SHA, semantic version,
latestfor non-prod) - Flag mutable tags used in production (
latest,main); pinned SHAs or versions are auditable - Check that old images are garbage collected from the registry to control storage cost
- Verify image signing / verification if regulated or security-sensitive (Cosign, Notary)
- Identify images stored longer than needed (30+ days of untagged images)
Observability & Debug Checklist
- Verify the image includes minimal debug tools for incident response (
ps,cat,sh) but not a full shell environment - Flag images stripped of all debug tools (ideal for security but painful to debug); strike a balance
- Check logging: is stdout/stderr captured by the orchestrator? Are logs structured?
- Verify that metrics endpoints (if any) are exposed and reachable
- Identify common debug needs: attaching a debugger to a running container, exec-ing into a stuck container
Calibration
Scale rigor to deployment scale. A side project's image can be 800MB and run as root and it doesn't matter much. A production SaaS with 1000 instances benefits from every size reduction (registry cost, pull time, cold-start time). Not every image needs distroless; alpine is a sensible default for Node. Some native packages don't work on Alpine; know when to use Debian-slim. Don't chase image size at the expense of debuggability — shaving 20MB at the cost of losing ps is rarely worth it.
-
Severity:
- Critical — Secrets baked into image layers; image runs as root in production; healthcheck silently failing causing restart loops; multi-stage build copying source to runner stage (huge attack surface)
- High — Missing
.dockerignoresending.gitandnode_modules;COPY . .beforenpm installdefeating cache; no version pin on base image; runningnpm installinstead ofnpm ci - Medium — Oversized base image (
node:20), missing HEALTHCHECK, shell form CMD losing signals, missing non-root user - Low — Cosmetic Dockerfile cleanup, minor optimization, missing init for zombie reaping
- Inverse (Over-Engineered) — Distroless when alpine is sufficient and more debuggable; elaborate multi-stage when a simple two-stage works; custom init when
--initwould do
-
Confidence ratings: Confirmed (image size measured, layer cache traced, healthcheck tested), Likely (Dockerfile pattern suggests issue), Speculative (best practice without measured impact).
-
Anti-hallucination guard: Verify the Docker setup actually has the problem before prescribing fixes — some "anti-patterns" are actually fine in context. Don't recommend distroless or scratch unless operator experience supports it. Verify Alpine-specific issues (localhost resolution) against the actual base image in use; newer Debian-slim doesn't have that problem. Measure image size after recommended changes to confirm the fix works.
Output Format
Start with a 3–5 line executive summary: final image size, build time, security posture (root? secrets?), single worst issue, single highest-leverage fix.
- Dockerfile Inventory Table
| Dockerfile | Base Image | Stages | Final Size | Build Time | USER | HEALTHCHECK | Severity |
|---|
-
Base Image Findings — Moving tags, outdated versions, wrong distribution choice
-
Multi-Stage Findings — Missing stages, single-stage builds, wrong artifacts copied
-
Layer Order Findings — Cache misses, install before source copy issues
-
.dockerignoreFindings — Missing patterns, oversized contexts -
Image Size Findings — Heavy layers, unnecessary binaries, dev deps in production
-
Secret Leakage Findings — Secrets in ENV/ARG, .env files copied, token echoes
-
Non-Root User Findings — Root in production, permission handling
-
HEALTHCHECK Findings — Missing, wrong protocol, localhost-IPv6 gotcha
-
Signal Handling Findings — Shell form CMD, missing init, ungraceful SIGTERM handling
-
Port & Migration Findings — EXPOSE drift, migration placement, startup script issues
-
Environment & Build Arg Findings — NODE_ENV, NEXT_PUBLIC build args, defaulted secrets
-
Security Hardening Findings — Unnecessary tools, outdated CVEs, missing capability drops
-
Reproducibility Findings — Non-deterministic builds, unpinned fetches
-
Platform Compatibility Findings — ARM / x86 issues, native binary gotchas
-
Over-Engineered Findings — Complexity without proportional benefit
-
Positive Findings — Dockerfile patterns done well
For each finding: Dockerfile:line, severity, confidence, the specific concrete change (exact line replacement or addition), and the expected size/speed/security delta.