A Dockerfile that builds is not a Dockerfile that is safe to run, and it is rarely one that builds quickly. Most of the problems are a single line, and most have been copied from a tutorial.
Here are the twelve I see most. The first three are security problems. The rest cost you in build time, image size, or builds that cannot be repeated. The Dockerfile Linter checks for all of them and gives the line number of each.
The three that are dangerous
1. No USER instruction
Without USER, the process runs as root. A bug in the application then runs with root's permissions inside the container, and if a host volume is mounted or the container is escaped, with root's permissions outside it.
RUN useradd --system --uid 10001 app
USER app
2. Secrets in ENV or ARG
ENV API_TOKEN=abc123
The value is stored in the image. Anyone who can pull the image can read it with docker history. Build arguments are recorded the same way. Use a build secret, which is available during one step and is not written to a layer:
RUN --mount=type=secret,id=token ./install.sh
Pass run-time secrets when the container starts, not when the image is built.
3. Piping a download into a shell
RUN curl -fsSL https://example.com/install.sh | sh
Whatever that URL serves on the day of the build runs as root in your image. Download the file, verify its checksum, then run it.
The nine that cost you
| Mistake | Why it hurts | Fix |
|---|---|---|
FROM node:latest | Two builds of the same commit can differ | Pin a version tag or a digest |
apt-get update in its own RUN | The layer is cached, so later installs use a stale index | Put update and install in one RUN |
No --no-install-recommends | Installs packages you did not ask for | Add the flag |
| Package lists left behind | The index is stored in the layer | Delete /var/lib/apt/lists at the end of the same RUN |
COPY . . before installing dependencies | Any code change discards the dependency layer | Copy the lockfile, install, then copy the rest |
npm install in a build | Can change versions between builds | Use npm ci |
Shell-form CMD | The process does not receive SIGTERM, so stops take the full timeout | Use CMD ["node", "server.js"] |
| One stage for build and run | Compilers and dev dependencies ship to production | Use a multi-stage build |
ADD for plain files | It also unpacks archives and fetches URLs | Use COPY |
The layer order that makes builds fast
Docker reuses a layer only if nothing before it changed. Order the file from what changes least to what changes most:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
RUN useradd --system --uid 10001 app
USER app
CMD ["node", "dist/server.js"]
With this order, a code change reuses the dependency layer. The final image has no build tools, a pinned base, a non-root user, and a command that receives signals.
What a linter does not cover
A linter reads the Dockerfile. It does not know what is inside the base image. Scan the built image for vulnerable packages as well, and add a .dockerignore so the build context does not include .git, node_modules, or local environment files.
Check your Dockerfile. It runs in your browser, with nothing uploaded.