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.
FROM node:24-alpine
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]
FROM node:24-alpine
WORKDIR /app
COPY server.js .
USER node
CMD ["node", "server.js"]
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.
FROM python:latest
CMD ["python3", "--version"]
FROM python:3.13-slim
CMD ["python3", "--version"]
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.
FROM nginx
COPY site/ /usr/share/nginx/html/
FROM nginx:1.29-alpine
COPY site/ /usr/share/nginx/html/
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.
FROM alpine:3.22
ENV API_TOKEN=s3cr3t-value-123
RUN ./fetch.sh
FROM alpine:3.22
RUN --mount=type=secret,id=api_token,env=API_TOKEN ./fetch.sh
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.
FROM alpine:3.22
ADD https://example.com/tool.tar.gz /opt/
FROM alpine:3.22
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d https://example.com/tool.tar.gz /opt/
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.
FROM debian:13-slim
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
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).
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
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.
FROM alpine:3.22
RUN apk add --no-cache git
RUN apk add --no-cache curl
RUN apk add --no-cache jq
FROM alpine:3.22
RUN apk add --no-cache \
curl \
git \
jq
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.
FROM node:24-alpine
WORKDIR /app
COPY . .
USER node
CMD ["node", "server.js"]
FROM node:24-alpine
WORKDIR /app
COPY package.json server.js ./
USER node
CMD ["node", "server.js"]
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.
FROM nginx:1.29-alpine
COPY site/ /usr/share/nginx/html/
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
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.
FROM node:24-alpine
USER node
CMD node server.js
FROM node:24-alpine
USER node
CMD ["node", "server.js"]
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.
FROM debian:13-slim
RUN curl -fsSL https://example.com/install.sh | sh
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
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
Related
Next step
A free public tool by ANAROQ, a company building compute infrastructure. What ANAROQ is building