Dockerfile Reviews: Spotting and Fixing Common Anti-Patterns
AI generated
Docker · Dockerfile · Container Security · DevOps
Dockerfile Reviews: Common Anti-Patterns
spotted, understood, and fixed for good

Many Dockerfiles grow organically and accumulate patterns along the way that bloat images, double build times, or undermine the security of the container. A structured Dockerfile review surfaces these anti-patterns before they turn into a production problem.

18 min read Anti-Patterns · Multi-Stage · Layer Caching · Security · .dockerignore Docker 24+ · OCI · Linux

1. Why Dockerfile reviews are indispensable

A Dockerfile is the blueprint of every container image, and at the same time one of the most frequently neglected code files in a project. While application code is secured through tests, code reviews, and static analysis, Dockerfile anti-patterns often slip into production unnoticed. The result: images that weigh in at several gigabytes, ten minute build times, containers running as root, and credentials permanently frozen into image layers.

A structured Dockerfile review is not an academic exercise. It has a direct impact on build speed, deployment frequency, and attack surface. In practice, the same pattern shows up again and again: the original developer put the Dockerfile together quickly, it worked, and nobody has touched it since. Along the way, ten to fifteen Dockerfile anti-patterns have crept in, all of which are fixable once you know what to look for.

The following sections walk through the most common Dockerfile anti-patterns, from layer bloat to cache invalidation to secrets baked into the image, and show the direct fix for each one. Every section closes with a concrete rule that can be carried straight into a team checklist for Dockerfile reviews.

2. Layer bloat: too many RUN commands and unnecessary data

The most common Dockerfile anti-pattern is scattering package installations across several RUN commands. Every RUN command creates a new layer in the image. If one layer installs packages and a later layer deletes the cache for those packages, that reduces the size of the top layer, but not the actual image size, because the deleted files still exist in the previous layer. This pattern shows up in many official and unofficial images and is partly responsible for image sizes north of a gigabyte.

The correct counterpattern combines all related commands into a single RUN command and deletes the package cache within the same instruction. apt-get install followed by && rm -rf /var/lib/apt/lists/* in the same RUN block directly reduces layer size. The same applies to apk add --no-cache on Alpine based images. Another Dockerfile anti-pattern in the layer bloat category is copying the entire source tree before the install step, which invalidates the cache on every code change even though the dependencies have not changed.


# ANTI-PATTERN: separate RUN layers, cache of deleted files stays in layer 1
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl wget git
RUN rm -rf /var/lib/apt/lists/*   # too late, previous layer retains the cache

# CORRECT: combine into a single RUN, delete cache in the same layer
FROM ubuntu:22.04
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
       curl \
       wget \
       git \
    && rm -rf /var/lib/apt/lists/*

# ANTI-PATTERN: copy all sources before installing dependencies
COPY . /app
RUN pip install -r /app/requirements.txt   # cache busted on every code change

# CORRECT: copy only the dependency manifest first
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app   # source copy here, does NOT bust dependency cache

3. Cache invalidation caused by the wrong order

The Docker layer cache is one of the most powerful tools for fast builds, and it is governed by the order of instructions in the Dockerfile. The central anti-pattern here is placing frequently changing data early in the Dockerfile. As soon as one instruction invalidates the cache, every subsequent layer gets rebuilt, even the ones that did not change. A COPY . . at the top of the Dockerfile means every code change triggers a rebuild of all subsequent layers, including the dependency install step that often takes minutes.

The solution follows the principle "rarest to most frequent": system packages, then runtime configuration, then dependency files (package.json, composer.json, requirements.txt), then dependency installation, and only at the end the actual source code. In practice this reordering cuts the average build time for code changes from minutes down to seconds, because all the expensive steps stay cached. The Dockerfile anti-pattern of the wrong order is purely logical in nature: the build is correct, just unnecessarily slow.


# ANTI-PATTERN: wrong order invalidates cache on every code change
FROM node:22-alpine
WORKDIR /app
COPY . .                    # any file change busts ALL subsequent layers
RUN npm ci                  # expensive, runs on every commit, even doc changes
RUN npm run build

# CORRECT: dependency manifest first, source last
FROM node:22-alpine
WORKDIR /app
# Step 1: only copy manifests (changes rarely)
COPY package.json package-lock.json ./
# Step 2: install dependencies (cached unless manifests change)
RUN npm ci --omit=dev
# Step 3: copy source (changes often, but only triggers fast steps below)
COPY . .
RUN npm run build

# CORRECT PHP example, same principle with Composer
FROM php:8.4-fpm-alpine
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader
COPY . .
RUN composer run-script post-install-cmd

4. Root user: the most common security anti-pattern

Containers that run as root are one of the most discussed, and at the same time one of the most frequently ignored, Dockerfile anti-patterns. If a process inside the container runs as root and an attacker exploits a vulnerability in the application, they gain root privileges inside the container, and with weak kernel isolation potentially on the host as well. Kubernetes clusters with Pod Security Admission enabled reject containers with a root user in restricted profiles by default. Many cloud providers and compliance frameworks explicitly require non-root containers.

The fix pattern is simple: create a dedicated user in the Dockerfile and switch to it with USER before the final ENTRYPOINT or CMD. On Alpine based images: addgroup -S appgroup && adduser -S appuser -G appgroup. On Debian/Ubuntu: groupadd -r appgroup && useradd -r -g appgroup appuser. It is critical that application directories get the correct ownership before switching to the non-root user. Another subtle anti-pattern: the user gets created, but volumes and mounted directories still belong to root, which causes permission errors at runtime.

5. Uncontrolled build context without .dockerignore

When you run docker build, the Docker client sends the entire build context to the Docker daemon, by default the current directory and everything inside it. Without a .dockerignore file, that means node modules weighing hundreds of megabytes, git repository history, local secrets files, IDE configuration, and test data all end up in the build context and slow the build down considerably. This is a classic Dockerfile anti-pattern that a correct .dockerignore file fixes on its own.

The .dockerignore file follows the same pattern rules as .gitignore. Minimum content for most projects: node_modules, .git, *.log, .env*, dist, vendor (PHP/Composer), __pycache__. For projects with several gigabytes of dependencies, adding a .dockerignore can reduce the time spent sending the build context from thirty seconds to under a second. A related Dockerfile anti-pattern: using COPY . . without a prior .dockerignore, which lets secrets and credentials end up in the final image.


# .dockerignore, always place in project root alongside Dockerfile
# Prevents secrets, large deps, and VCS history from entering build context

# Version control
.git
.gitignore

# Environment and secrets, critical security item
.env
.env.*
*.pem
*.key
secrets/

# Dependency directories (reinstalled during build)
node_modules/
vendor/
__pycache__/
*.pyc

# Build artifacts and caches
dist/
build/
.cache/
var/cache/
pub/static/

# IDE and OS
.idea/
.vscode/
*.DS_Store
Thumbs.db

# Test and documentation files
tests/
*.test.js
*.spec.ts
README.md
docs/

# Logs
*.log
logs/

6. Missing multi-stage builds for compiled languages

For Go, Rust, Java, TypeScript, and PHP (with Composer), the multi-stage build is not an optional feature but a basic requirement for production ready images. The Dockerfile anti-pattern without a multi-stage build: the entire build stack, compiler, SDK, build tools, dev dependencies, ends up in the final image, even though only the compiled artifact is needed at runtime. A Go binary running in an image that still ships the full Go compiler is at best needlessly large and at worst a security risk, because the compiler itself can be used as an attack tool.

Multi-stage builds solve this Dockerfile anti-pattern structurally: a builder stage holds all the build tools and compiles the artifact, and a final runtime stage is based on a minimal base image and copies over only the finished artifact. The result is an image that contains only what is actually needed at runtime. For Go applications, the image can shrink from 800 MB to under 20 MB, purely through the correct use of multi-stage builds, with no change to the application code at all.

7. Secrets and credentials in the image: a fatal anti-pattern

Credentials, API keys, or SSH keys that make their way into a Dockerfile via COPY or ENV stay permanently frozen in the image history, even if a later layer appears to delete them. docker history image-name and docker save expose every layer and its contents. This is a critical Dockerfile anti-pattern that leads to immediate credential leaks in public registries and increases the attack surface in private registries in case of a compromise.

Secrets do not belong in Dockerfiles, ever. The correct alternatives: build time secrets via RUN --mount=type=secret (Docker BuildKit), which mounts the secret as a temporary filesystem only for the duration of the build command and never stores it in the final image. At runtime: inject secrets via environment variables from a secured source (Vault, Kubernetes Secrets, AWS Secrets Manager). Docker Compose supports this with the secrets key. The Dockerfile anti-pattern of baked-in secrets ranks among the ten most common security problems in container environments, and it is one of the easiest to avoid.


# ANTI-PATTERN: secret baked into image layer, visible in docker history
FROM php:8.4-fpm-alpine
ENV DATABASE_PASSWORD=supersecret123   # permanent in image history
RUN composer install --no-dev
# Even if you later do: RUN unset DATABASE_PASSWORD
# the ENV layer still contains the value in docker history

# CORRECT: BuildKit secret mount, not stored in any layer
# syntax=docker/dockerfile:1
FROM php:8.4-fpm-alpine
COPY composer.json composer.lock ./
# Secret is mounted as /run/secrets/composer_auth only during this RUN
RUN --mount=type=secret,id=composer_auth,dst=/root/.composer/auth.json \
    composer install --no-dev --no-scripts --optimize-autoloader

# CORRECT: runtime injection via Docker Compose secrets
# docker-compose.yml excerpt:
# services:
#   app:
#     secrets:
#       - db_password
# secrets:
#   db_password:
#     file: ./secrets/db_password.txt
# Inside container: /run/secrets/db_password, never in image

# Verify no secrets in image history
docker history --no-trunc your-image:tag | grep -i "password\|secret\|key\|token"

8. Missing healthchecks and incorrect ENTRYPOINT configuration

Containers without a HEALTHCHECK directive are treated as "running" by orchestrators like Kubernetes and Docker Swarm as soon as the process starts, regardless of whether the application is actually ready to handle requests. This Dockerfile anti-pattern causes startup race conditions: a load balancer routes traffic to a container that is not ready yet, and users see errors. A correctly configured HEALTHCHECK defines a command that checks the actual state of the application and tells the orchestrator to report the container as available only once it truly is.

Another Dockerfile anti-pattern in this area: using the shell form for ENTRYPOINT instead of the exec form. ENTRYPOINT ["sh", "-c", "myapp"] or ENTRYPOINT myapp wrap the process in a shell that does not correctly forward POSIX signals like SIGTERM. When Docker or Kubernetes stops a container, the shell receives the signal, but the actual application does not, which leads to timeouts and forced SIGKILL terminations. The exec form ENTRYPOINT ["/app/myapp"] makes the process PID 1 directly and receives signals correctly.

9. Anti-patterns compared side by side

The most common Dockerfile anti-patterns can be grouped into categories: some affect performance (build time, image size), others affect security (root user, secrets), and some affect both at once. The table below gives a structured overview of the most common anti-patterns, their impact, and the direct fix.

Anti-Pattern Category Impact Fix
Separate RUN layers Performance Large images, bloat remains in layers Combine RUN commands, delete cache in place
COPY . . too early Performance Cache invalidation on every change Copy dependencies before source code
Root user Security Privilege escalation on exploits USER instruction with a non-root account
No .dockerignore Performance + security Secrets in the image, slower builds .dockerignore with node_modules, .env, .git
Secrets in ENV/COPY Security Permanently frozen into image history BuildKit --mount=type=secret
No HEALTHCHECK Availability Containers reported available too early HEALTHCHECK with curl or wget

A complete Dockerfile review addresses all of these categories at once. In practice, the hadolint tool (a Dockerfile linter) is highly recommended: it statically detects many of these Dockerfile anti-patterns and, wired into CI, checks every commit automatically. hadolint Dockerfile returns numbered warnings with direct pointers to the issue. For security scans of finished images, tools like trivy or grype complement the review with vulnerability databases.

Mironsoft

Dockerfile optimization, container security, and DevOps consulting

Ready to eliminate Dockerfile anti-patterns in your project?

We analyze existing Dockerfiles and container setups, identify critical anti-patterns, and implement multi-stage builds, correct layer ordering, and secure secret management.

Dockerfile review

Analysis with hadolint, trivy, and a manual anti-pattern check

Image optimization

Multi-stage builds, layer consolidation, and build context control

CI integration

Wiring hadolint and trivy into GitLab CI / GitHub Actions

10. Summary and review checklist

Dockerfile anti-patterns rarely arise from carelessness, but from a lack of awareness about the consequences of certain patterns. The key points at a glance: combine RUN commands and delete the cache in the same layer. Copy dependency manifests before the source code. Consistently use a non-root user. Maintain a complete .dockerignore file. Use multi-stage builds for all compiled languages and build artifacts. Manage secrets exclusively through BuildKit secrets or runtime injection. Use HEALTHCHECK and the exec form for ENTRYPOINT.

Integrating hadolint as a linting step in the CI pipeline ensures that new Dockerfile anti-patterns get caught automatically before an image is built and deployed. That complements the manual review and establishes a baseline that applies across the whole team. A one-off review sprint that walks every existing Dockerfile through this checklist reduces image sizes, shortens build times, and closes the most common security gaps.

Dockerfile anti-patterns: review checklist at a glance

Performance

Combine RUN commands, delete the cache in the same layer. COPY order: manifests before source. Multi-stage build for compilers and build tools.

Security

Non-root USER. No secrets in ENV or COPY. BuildKit --mount=type=secret. .dockerignore with .env, .git, node_modules.

Availability

HEALTHCHECK with realistic start-period and interval values. ENTRYPOINT in exec form for correct signal forwarding.

Automation

Integrate hadolint into the CI pipeline. Use trivy for image vulnerability scans. Check docker history --no-trunc for secrets.

11. FAQ: Dockerfile Anti-Patterns

1What is a Dockerfile anti-pattern?
A pattern that works but leads to large images, long build times, or security problems. Separate RUN layers, root user, and COPY . . too early are the most common examples.
2Why does a deleted cache stay in the image?
Layers are immutable. A package installed in layer 2 stays in layer 2, even if layer 3 deletes it. Only within the same RUN command do the install and the deletion end up in the same layer.
3When is a multi-stage build worth it?
Whenever compilers, build tools, or dev dependencies are not needed at runtime. Go, Rust, Java, TypeScript, PHP/Composer: practically every compiled language benefits.
4Are secrets in ENV really a problem?
Yes, critically so. docker history --no-trunc shows all ENV values permanently. Overwriting the variable later does not remove the original layer. BuildKit --mount=type=secret is the safe alternative.
5Shell form vs. exec form for ENTRYPOINT?
Shell form starts a shell as PID 1 that does not forward SIGTERM. Exec form makes the application PID 1 directly and enables graceful shutdown. Always use exec form.
6How do I check an image for secrets?
docker history --no-trunc image-name, docker save | tar -tv, and trivy image. All three methods cover different aspects. trivy also detects known credential patterns.
7What belongs in .dockerignore?
.git, node_modules, vendor, .env*, *.pem, *.key, dist, build, logs. Minimal, but these entries prevent the most common performance and security problems.
8How does the BuildKit secret mount work?
The secret is mounted as a temporary filesystem under /run/secrets/name, only for the duration of the RUN command. It is not cached and does not appear in docker history. Invocation: docker build --secret id=name,src=./file .
9Which tools help with automated Dockerfile reviews?
hadolint for Dockerfile linting, trivy for image vulnerabilities, dockle for security best practices. All three integrate into CI and complement one another.
10How do I set up HEALTHCHECK correctly?
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 CMD curl -f http://localhost:PORT/health || exit 1. start-period gives the app time to start before failed attempts count.