Skip to content
Published on

Mistakes That Keep Repeating in Dockerfiles — Running as root, PID 1, and Secrets Left in the Image

Share
Authors

Introduction — The Things That Pass the Build and Only Break in Production

Dockerfile problems barely surface during the build stage. The image gets created, the container comes up, and it runs fine locally. The problems show up after that.

On every deploy docker stop waits exactly 10 seconds before force-killing the container, and the exit code is 137 rather than 143. The requests in flight during those 10 seconds are simply cut off. Months later there are thousands of zombies in the process table, and the security scan finds a deployment token in the image history.

This article looks at those problems from the cause side rather than the symptom side. Most of them come down to two or three lines of Dockerfile, but without knowing why, you repeat them identically on the next project.

Running as root — If Nobody Specifies, You Get UID 0

An image that does not use USER runs as UID 0.

docker run --rm node:22-bookworm-slim id
uid=0(root) gid=0(root) groups=0(root)

"Containers are isolated, so being root inside does not matter" circulated for a long time, but under default settings the container's root is the same UID as the host's root. If you have not enabled the user namespace, this becomes plain the moment you mount a host filesystem as a volume.

docker run --rm -v /etc:/host-etc alpine:3.20 \
  sh -c 'echo "# injected" >> /host-etc/hosts && tail -1 /host-etc/hosts'
# injected

The host's /etc/hosts was modified from inside the container. Add a single runtime vulnerability to that and an escape immediately means host root privileges.

The fix is well established. Create a user, give that user ownership of the paths it needs, and switch with USER.

FROM node:22-bookworm-slim
WORKDIR /app

RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --shell /usr/sbin/nologin --create-home app

COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=10001:10001 . .

USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/server.js"]

Using a numeric UID rather than a name is deliberate. Kubernetes' runAsNonRoot cannot determine whether the image config's USER value is non-root when it is a name, and it rejects the pod.

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

Write USER app and apply the setting above and you will see an event like this.

Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root

Switching to an unprivileged user means you cannot open ports below 1024. Adding the NET_BIND_SERVICE capability is one option here, but changing the application port to 8080 and exposing it as 80 at the service layer is far simpler.

The latest Tag and Reproducibility

FROM node:22 is convenient but points at different things over time. Today's build and next week's build sit on different bases, and when an incident hits you cannot trace the cause of "but it worked last week."

docker pull node:22-bookworm-slim
docker inspect --format '{{index .RepoDigests 0}}' node:22-bookworm-slim
node@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

Pinning to a digest guarantees the same base no matter when you build.

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

The objection that comes up here is "then we never get security patches." That is a fair point, which is why updates are handed to a bot rather than a person. Dependabot or Renovate opens a PR bumping the digest, CI validates that combination, and then it merges. That is the difference between something flowing in automatically and something coming in after validation.

The same goes for application image tags. Deploy as myapp:latest and there is nothing to roll back to. Using the commit SHA as the tag, and additionally attaching latest if you need it, is about as safe as it gets.

ADD and COPY — the Default Is COPY

The two commands look similar, but ADD does far more. And most of that extra behavior is a problem.

ADD automatically extracts local tar archives.

ADD release.tar.gz /opt/app/

That single line does not copy release.tar.gz, it unpacks it. If the archive contains ../ paths or symbolic links, files get written to unintended locations. If you wanted the file left as it is, you should use COPY.

ADD also accepts URLs.

ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gz

There is no checksum verification, the archive stays in the layer, and deleting it in a later RUN does not change the size. If you need a remote file, it is better to download, verify, and delete it inside a RUN.

RUN set -eux \
 && curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool-1.4.2.tar.gz \
 && echo "3f0a91c2e4d7b5a8f16c9d02e3b7a41f8c5d29e06b3a7f1c4d8e2b0a95f3c716  /tmp/tool.tar.gz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tar.gz -C /opt \
 && rm -f /tmp/tool.tar.gz

There are only two cases where ADD is legitimate: deliberately unpacking a local tar, and fetching a git repository directly on BuildKit. Everything else is always COPY.

The Seat Called PID 1 — Why SIGTERM Never Reaches the App

The most expensive mistake is here. The symptom is a 10-second delay that repeats on every deploy.

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
CMD npm start
docker run -d --name shellform demo:shell
time docker stop shellform
docker inspect -f '{{.State.ExitCode}}' shellform
shellform

real    0m10.412s
137

Ten seconds is Docker's default termination grace period, and 137 is SIGKILL. A clean shutdown would have finished immediately and produced 143 (SIGTERM).

The cause has two layers. CMD npm start written in shell form actually executes as /bin/sh -c "npm start". Then PID 1 is the shell. And PID 1 gets special treatment from the kernel: the default action for signals with no explicitly registered handler does not apply. In other words, SIGTERM arrives and is simply ignored.

The explanation that commonly circulates here is "the shell does not forward signals to its child," which is only half right. Debian's dash and Alpine's busybox ash optimize a sh -c containing a single simple command by replacing themselves with exec instead of forking. Then the shell disappears and the target process becomes PID 1. The problem is that the optimization is conditional. The moment the command contains a pipe, &&, variable expansion, or a redirect, the shell stays. Behavior that depends on a shell will break silently one day.

And the second layer is more common. Wrappers like npm, yarn, and pnpm are themselves managers that spawn child processes, even if the shell was replaced via exec. npm did not forward SIGTERM to its children for a long time. So even with the shell removed, the app does not receive the signal.

The fix is to write exec form and strip out the wrapper.

CMD ["node", "dist/server.js"]
docker run -d --name execform demo:exec
time docker stop execform
docker inspect -f '{{.State.ExitCode}}' execform
execform

real    0m0.284s
143

You also need code on the application side that receives the signal and cleans up. Docker only delivers the signal; it does not perform a graceful shutdown for you.

const server = app.listen(8080)

for (const sig of ['SIGTERM', 'SIGINT']) {
  process.on(sig, () => {
    server.close(() => process.exit(0))
    setTimeout(() => process.exit(1), 15000).unref()
  })
}

When an entrypoint script is genuinely necessary, always use exec on the last line. Leave it out and the script stays as PID 1 and the same problem reproduces.

#!/bin/sh
set -e
./wait-for-db.sh
exec "$@"
COPY --chmod=0755 entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/server.js"]

The termination grace period needs adjusting too. Leave the 10-second default on an app that needs 15 seconds and it gets cut short every time.

docker run -d --stop-timeout 30 api:v312
# Kubernetes
spec:
  terminationGracePeriodSeconds: 30

PID 1's Other Duty — Zombie Reaping

Fixing signal delivery does not end the PID 1 problem. On Linux, a process that loses its parent is adopted by PID 1, and PID 1 bears the duty of collecting its exit status with wait. An init system does this job. A Node or Python process does not.

If your application frequently creates child processes inside the container, zombies pile up.

docker exec api ps -eo pid,stat,comm | grep -c 'Z'
1847

Once the process table fills, creating new processes fails, and the symptom appears in the application log as fork: Resource temporarily unavailable.

The solution is to put a real init at number 1. The simplest way is Docker's built-in option.

docker run -d --init api:v312
docker exec api ps -eo pid,comm
    PID COMMAND
      1 docker-init
      7 node

To bake it into the image itself, use tini.

FROM node:22-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

Kubernetes has no pod option equivalent to --init, so it has to be handled on the image side. That said, a single-process server that never creates child processes does not really need it. Check whether it spawns subprocesses first, then decide.

Secrets Left in the Image History

Pulling packages from a private registry requires a token during the build. Two approaches are commonly used here and both are wrong.

# Anti-pattern 1: ENV
ENV NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
RUN npm ci

# Anti-pattern 2: ARG
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
 && npm ci \
 && rm ~/.npmrc

The first one stays permanently in the image config.

docker inspect -f '{{json .Config.Env}}' bad:v1 | tr ',' '\n'
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
"NODE_VERSION=22.14.0"
"NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"]

The second deletes ~/.npmrc, but as seen in the previous section it remains in the lower layer untouched, and on the classic builder it remains in the history as well.

docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ

If you pushed to a registry, treat that token as already leaked and revoke it. Deleting the image does not undo it.

The correct approach is BuildKit's secret mount. The secret is mounted as tmpfs only while that RUN executes and is never written into a layer.

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400 \
    npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > /tmp/npmrc
docker buildx build --secret id=npmrc,src=/tmp/npmrc -t api:v312 .
shred -u /tmp/npmrc

You can also pass it straight from an environment variable.

docker buildx build --secret id=npmtoken,env=NPM_TOKEN -t api:v312 .
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci --omit=dev

If you need to fetch a private git repository, you can forward the SSH agent.

RUN --mount=type=ssh \
    git clone git@github.com:org/private-lib.git /opt/lib
docker buildx build --ssh default -t api:v312 .

Verification has to become a habit. Skimming the history and the config once before pushing filters out most incidents.

docker history --no-trunc api:v312 | grep -Ei 'token|secret|password|key='
docker inspect -f '{{json .Config.Env}}' api:v312

HEALTHCHECK, Attack Surface, and File Ownership

The remaining items are individually small but together they change operational quality.

HEALTHCHECK embeds the health verdict criteria in the image.

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

There are two misconceptions here. First, even when the health check fails, docker run does not restart the container. The status is merely marked unhealthy, and automatic action is the job of Swarm or an orchestrator. Second, Kubernetes ignores an image's HEALTHCHECK entirely. In a pod you have to define livenessProbe and readinessProbe separately. And a distroless image has neither wget nor curl, so it is better to include a small health-check binary alongside it or add a status-check subcommand to the application.

Attack surface is a question of what remains in the final image. If the curl, git, and gcc used during the build are left in the runtime image, you are handing an intruder a toolkit. Splitting with multi-stage is the first answer; when that is impossible, at least narrow the install scope.

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends alone removes tens of MB and dozens of packages on Debian derivatives.

File ownership is directly tied to size. The pattern of copying and then changing ownership rewrites all the files into a new layer.

# Anti-pattern: doubles the layer
COPY . /app
RUN chown -R app:app /app
COPY . /app          286MB
RUN chown -R app:app  286MB

With --chown, ownership is set during the copy, so there is a single layer.

COPY --chown=10001:10001 . /app

If you run with a read-only root filesystem, open up only the paths that need to be writable.

docker run -d --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=8m \
  api:v312
MistakeSymptom that appearsHow to fix it
No USERContainer runs as root, violates k8s policyUSER 10001:10001 with a numeric UID
FROM node:22Yesterday's build differs from today'sPin the digest + Renovate
Overusing ADDAutomatic archive extraction, unverified downloadsCOPY, or checksum verification in RUN
shell form CMDdocker stop takes 10s, exit code 137JSON array exec form
Running via a wrapper (npm start)SIGTERM never reaches the appRun the runtime binary directly
No initZombies accumulate, fork fails--init or tini
Passing tokens via ENV/ARGPlaintext exposure in docker historyRUN --mount=type=secret
RUN chown -RImage size doublesCOPY --chown
Relying on HEALTHCHECKIgnored on k8s, does nothingliveness/readiness probes

Closing — A Dockerfile Is an Execution Contract

The items in this article look unrelated, but they share one root. A Dockerfile is not a script that gathers files, it is a contract stating who this process runs as, with what privileges, how it starts, and how it ends.

There are five lines to look at when reviewing a new Dockerfile. Is FROM pinned, is USER specified with a numeric UID, are CMD and ENTRYPOINT JSON arrays, do secrets avoid coming in through ARG or ENV, and are build tools absent from the final stage. Passing just these five makes most of the incidents you meet in production disappear.