Dockerfile lint rules

What each rule of the Dockerfile linter checks, why it matters and how to fix it, with an example before and after. Every rule cites the Docker documentation it comes from.

Check a Dockerfile with the linter

AQ-DF-001 The final image runs as root

Severity: High

Why it matters

Without a USER, the process runs as root inside the container. A flaw in the application then has root's rights over everything mounted into it, and a container escape starts from root. Official images of servers such as PostgreSQL or nginx start as root on purpose and drop to their own user in their entrypoint; the linter cannot see that, so check the image's documentation before you change it.

How to fix it

Create an unprivileged user (or use one the image has, such as node), give it only the paths it writes to, and add USER before CMD.

Triggers the rule
Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]
Fixed
Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY server.js .
USER node
CMD ["node", "server.js"]

Docker documentation

AQ-DF-002 Base image tagged latest

Severity: Medium

Why it matters

latest is whatever the publisher pushed last. Two builds of the same Dockerfile can get different base images, and a new major version can break the build or the application without any change on your side.

How to fix it

Pin a version tag (python:3.13-slim), or a digest (@sha256:…) for builds that must be reproducible.

Triggers the rule
Dockerfile
FROM python:latest
CMD ["python3", "--version"]
Fixed
Dockerfile
FROM python:3.13-slim
CMD ["python3", "--version"]

Docker documentation

AQ-DF-003 Base image without a tag

Severity: Medium

Why it matters

An image with no tag and no digest means latest, with the same problem: the base can change under you between builds.

How to fix it

Add a version tag (nginx:1.29-alpine), or a digest for reproducible builds.

Triggers the rule
Dockerfile
FROM nginx
COPY site/ /usr/share/nginx/html/
Fixed
Dockerfile
FROM nginx:1.29-alpine
COPY site/ /usr/share/nginx/html/

Docker documentation

AQ-DF-004 Secret in ENV or ARG

Severity: High

Why it matters

ENV values are stored in the image and visible to anyone who can pull it (docker image inspect). ARG values are recorded in the build history and provenance. Neither is a place for a password, token or key.

How to fix it

Remove the value, rotate it if it was ever committed, and pass it at build time with a secret mount (RUN --mount=type=secret) or at run time as an environment variable or a file.

Triggers the rule
Dockerfile
FROM alpine:3.22
ENV API_TOKEN=s3cr3t-value-123
RUN ./fetch.sh
Fixed
Dockerfile
FROM alpine:3.22
RUN --mount=type=secret,id=api_token,env=API_TOKEN ./fetch.sh

Docker documentation

AQ-DF-005 ADD from a URL without a checksum

Severity: Medium

Why it matters

ADD <url> downloads at build time and trusts whatever the server returns. If the file changes or the server is compromised, the image changes without a trace in the Dockerfile.

How to fix it

Add --checksum=sha256:… with the published hash of the file, or download it in a RUN step that verifies the hash.

Triggers the rule
Dockerfile
FROM alpine:3.22
ADD https://example.com/tool.tar.gz /opt/
Fixed
Dockerfile
FROM alpine:3.22
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d https://example.com/tool.tar.gz /opt/

Docker documentation

AQ-DF-006 apt-get install without --no-install-recommends

Severity: Low

Why it matters

By default apt also installs recommended packages. They make the image larger and add software, and so vulnerabilities, that the application does not use.

How to fix it

Add --no-install-recommends, and list the packages you need explicitly.

Triggers the rule
Dockerfile
FROM debian:13-slim
RUN apt-get update && apt-get install -y curl \
  && rm -rf /var/lib/apt/lists/*
Fixed
Dockerfile
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
  && rm -rf /var/lib/apt/lists/*

Docker documentation

AQ-DF-007 apt lists left in the image

Severity: Low

Why it matters

apt-get update downloads package lists to /var/lib/apt/lists. Removed in a later RUN, they still take space in an earlier layer; they have to go in the same RUN that installs.

How to fix it

End the same RUN with && rm -rf /var/lib/apt/lists/*, or use a cache mount (--mount=type=cache,target=/var/cache/apt).

Triggers the rule
Dockerfile
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl
Fixed
Dockerfile
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
  && rm -rf /var/lib/apt/lists/*

Docker documentation

AQ-DF-008 Many RUN layers in a row

Severity: Info

Why it matters

Each RUN is a layer. Steps that belong together, such as installing packages, are smaller and cache better as one RUN. This is a suggestion: separate steps can be right when they change at different rates.

How to fix it

Join related commands with && and line continuations, one package per line, sorted.

Triggers the rule
Dockerfile
FROM alpine:3.22
RUN apk add --no-cache git
RUN apk add --no-cache curl
RUN apk add --no-cache jq
Fixed
Dockerfile
FROM alpine:3.22
RUN apk add --no-cache \
    curl \
    git \
    jq

Docker documentation

AQ-DF-009 COPY . without a .dockerignore

Severity: Low

Why it matters

COPY . copies the whole build context. Without a .dockerignore, that includes .git, .env files, local node_modules and anything else in the directory: secrets get into the image and every change breaks the cache. The linter sees only the Dockerfile, so it cannot tell whether you have one.

How to fix it

Add a .dockerignore next to the Dockerfile (at least .git, .env*, node_modules, build output), or copy only the files the image needs.

Triggers the rule
Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY . .
USER node
CMD ["node", "server.js"]
Fixed
Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package.json server.js ./
USER node
CMD ["node", "server.js"]

Docker documentation

AQ-DF-010 No HEALTHCHECK

Severity: Info

Why it matters

Without a HEALTHCHECK, Docker only knows whether the process is running, not whether it answers. Compose depends_on: service_healthy and orchestrators need a health status. The base image may define one; Kubernetes ignores it and uses its own probes.

How to fix it

Add a HEALTHCHECK that calls a cheap endpoint with a tool the image has (wget -qO-, curl -f), or HEALTHCHECK NONE if health is checked elsewhere.

Triggers the rule
Dockerfile
FROM nginx:1.29-alpine
COPY site/ /usr/share/nginx/html/
Fixed
Dockerfile
FROM nginx:1.29-alpine
COPY site/ /usr/share/nginx/html/
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

Docker documentation

AQ-DF-011 CMD or ENTRYPOINT in shell form

Severity: Low

Why it matters

In shell form the command runs under /bin/sh -c, so the shell is PID 1 and docker stop sends SIGTERM to it, not to your process: the container waits out the timeout and is killed. A shell-form ENTRYPOINT also ignores CMD and docker run arguments.

How to fix it

Use the exec form, a JSON array: CMD ["node", "server.js"]. If you need the shell, end the script with exec.

Triggers the rule
Dockerfile
FROM node:24-alpine
USER node
CMD node server.js
Fixed
Dockerfile
FROM node:24-alpine
USER node
CMD ["node", "server.js"]

Docker documentation

AQ-DF-012 Downloaded script piped into a shell

Severity: High

Why it matters

curl … | sh runs whatever the server returns, with no check and with the build's rights. A changed or compromised script changes the image silently, and a dropped connection can run half a script.

How to fix it

Download the file with ADD --checksum=sha256:… (or download and verify its hash in RUN), then run it; better, install from a package repository.

Triggers the rule
Dockerfile
FROM debian:13-slim
RUN curl -fsSL https://example.com/install.sh | sh
Fixed
Dockerfile
FROM debian:13-slim
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d https://example.com/install.sh /tmp/install.sh
RUN sh /tmp/install.sh && rm /tmp/install.sh

Docker documentation

Known false positives

Ten official Dockerfiles are part of the linter's tests. Where a finding is wrong for one of them, it is listed here with the reason:

  • AQ-DF-001 (postgres, redis): The official entrypoint starts as root to fix the ownership of the data volume, then runs the server as its own unprivileged user (gosu or su-exec).
  • AQ-DF-001 (nginx, httpd): The main process binds ports 80 and 443 as root; the worker processes that serve requests run as an unprivileged user.
  • AQ-DF-001 (gitea): A process supervisor (s6) starts as root to run sshd on port 22 and runs the application itself as its own user; a rootless variant of the image exists.
  • AQ-DF-001 (python): A language base image: images built from it set their own USER, and a USER here would break the package installs they run.

What it does

Each rule has a stable id (AQ-DF-001 to AQ-DF-012) that the linter shows next to its findings, and a severity: high for what exposes the container or its secrets, medium for what makes builds unpredictable, low and info for size, cache and operations. The examples below are checked by the linter's tests, so each one triggers its rule and its fix does not.

Sources

  1. Docker Docs: Dockerfile reference
  2. Docker Docs: Building best practices
  3. Docker Docs: Build checks
  4. Docker Docs: Build secrets
  5. Docker Docs: Build context and .dockerignore

Updated

Next step

Dockerfile linter

A free public tool by ANAROQ, a company building compute infrastructure. What ANAROQ is building