Skip to content
OpsAtlas

Dockerfile Mistakes That Leak Secrets and Bloat Images

By OpsAtlas · · 4 min read

Table of contents

A Dockerfile that builds and runs is not necessarily a good one. This one works fine on a laptop:

FROM node:22
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN apt-get update
RUN apt-get install -y python3 make g++
WORKDIR /app
COPY . .
RUN npm ci
RUN rm -rf /var/lib/apt/lists/*
CMD npm start

It also ships your npm token to everyone who can pull the image. It keeps the apt package lists it tries to delete, reinstalls every dependency when you change one line of code, and takes ten seconds to stop. None of that shows up as an error. Each problem below comes with a command that proves it on your own image, and then the fix.

1. Secrets in ENV and ARG end up in the image

ENV values are part of the image configuration. Anyone who can pull the image can read them:

docker inspect --format '{{json .Config.Env}}' myapp
# ["PATH=…","NPM_TOKEN=npm_9aF…", …]

Moving the value to ARG does not solve it. Build arguments used by a RUN step are recorded in the image history, which docker history --no-trunc myapp prints. Docker’s own build checks warn about this as SecretsUsedInArgOrEnv.

The fix is a build secret. It is mounted only for the one RUN that needs it and is never written to a layer:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

While you are at it, check .dockerignore. COPY . . copies everything it doesn’t exclude, including .env, .npmrc and .git. A secret that never appears in the Dockerfile can still end up in the image that way.

2. Deleting files in a later RUN does not shrink the image

Each instruction adds a layer, and layers only ever add to the image. RUN rm -rf /var/lib/apt/lists/* in its own step hides the files from the final filesystem, but the bytes are still in the earlier layer that created them. docker history shows it: the apt-get install layer keeps its full size, and the rm layer adds 0 B.

docker history myapp --format '{{.Size}}\t{{.CreatedBy}}' | head

Clean up in the same RUN that created the files. For build-only tools (compilers, make, dev dependencies), use a multi-stage build so they never reach the final image at all.

3. apt-get update in its own RUN goes stale

Docker caches each layer by its instruction text. RUN apt-get update never changes, so after the first build Docker reuses that layer forever. Weeks later, the next apt-get install runs against an old package index and fails with 404 errors for packages that have since been replaced.

Keep update, install and cleanup in one RUN:

RUN apt-get update \
    && apt-get install -y --no-install-recommends python3 make g++ \
    && rm -rf /var/lib/apt/lists/*

--no-install-recommends stops apt from pulling in “recommended” packages, which on Debian and Ubuntu are often larger than what you asked for.

4. COPY . . before installing dependencies busts the cache

Docker reuses a cached layer only if every layer before it is unchanged. When COPY . . comes before RUN npm ci, any edit to any source file invalidates the copy, and so the install runs again. You can see it on the next rebuild: the install step runs in full instead of showing CACHED.

Copy the dependency manifests first, install, then copy the rest:

COPY package.json package-lock.json ./
RUN npm ci
COPY . .

The same pattern works for requirements.txt, go.mod/go.sum and Gemfile.lock.

5. Shell-form CMD makes every stop take 10 seconds

CMD npm start is shell form. Docker runs it as /bin/sh -c "npm start", so a shell sits between Docker and your app. docker top myapp shows it as the first process. On stop, Docker sends SIGTERM to that process, and a shell running as PID 1 does not pass it on. Docker waits out the timeout (10 seconds by default, 30 seconds in Kubernetes) and then sends SIGKILL:

time docker stop myapp                                # ~10 s
docker inspect --format '{{.State.ExitCode}}' myapp   # 137 = killed

Use exec form, and start Node directly instead of through npm. The Node.js Docker best practices recommend this, so that SIGTERM reaches your process instead of being swallowed by npm:

CMD ["node", "server.js"]

If your app starts child processes, add --init to docker run (or init: true in Compose). This runs a small init process that forwards signals and reaps zombie processes.

6. No USER means the app runs as root

docker run --rm myapp id   # uid=0(root) gid=0(root)

If someone exploits the app, they get root inside the container, which is one less barrier between them and the host. Add a non-root USER. Prefer a numeric one: Kubernetes runAsNonRoot: true cannot verify a user name and refuses to start the pod with “image has non-numeric user”. The official Node.js images already include a node user. Give it ownership of anything the app writes, for example with COPY --chown=node:node.

The fixed Dockerfile

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends python3 make g++ \
    && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .

FROM node:22-slim
WORKDIR /app
COPY --from=build --chown=node:node /app ./
USER node
HEALTHCHECK CMD ["node", "healthcheck.js"]
CMD ["node", "server.js"]

Compilers and apt lists stay in the build stage, the token never touches a layer, code changes reuse the cached install, and the container stops as soon as Node exits.

Check your own Dockerfile

Paste your Dockerfile into the Dockerfile linter. It flags every problem above except .dockerignore, which it cannot see. Many fixes are one click: apt-get flags and cleanup, npm ci, exec-form CMD, USER, ADD to COPY. The original Dockerfile at the top of this post scores 55/100 (D) with nine findings; the fixed one scores 100. Everything runs in your browser, so pasting a Dockerfile that still contains a secret doesn’t send it anywhere.

If you are still starting containers with long docker run commands, the docker run to Compose converter turns them into a compose.yaml. For workloads headed to Kubernetes, the YAML validator checks that the pod spec matches the image, such as running as non-root with a numeric user.

Try it yourself

  • Dockerfile linter and fixer

    Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.

    DockerBeta
  • docker run to Docker Compose converter

    Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.

    Docker
  • Kubernetes YAML validator and visualizer

    Paste Kubernetes manifests to get a resource diagram and a security and best-practice review: privileged pods, root, probes, limits, deprecated APIs.

    KubernetesBeta