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

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — The Things That Pass the Build and Only Break in Production
- Running as root — If Nobody Specifies, You Get UID 0
- The latest Tag and Reproducibility
- ADD and COPY — the Default Is COPY
- The Seat Called PID 1 — Why SIGTERM Never Reaches the App
- Secrets Left in the Image History
- HEALTHCHECK, Attack Surface, and File Ownership
- Closing — A Dockerfile Is an Execution Contract
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 package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
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 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 \
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 \
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 \
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 \
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 . /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
| Mistake | Symptom that appears | How to fix it |
|---|---|---|
No USER | Container runs as root, violates k8s policy | USER 10001:10001 with a numeric UID |
FROM node:22 | Yesterday's build differs from today's | Pin the digest + Renovate |
Overusing ADD | Automatic archive extraction, unverified downloads | COPY, or checksum verification in RUN |
shell form CMD | docker stop takes 10s, exit code 137 | JSON array exec form |
Running via a wrapper (npm start) | SIGTERM never reaches the app | Run the runtime binary directly |
| No init | Zombies accumulate, fork fails | --init or tini |
Passing tokens via ENV/ARG | Plaintext exposure in docker history | RUN --mount=type=secret |
RUN chown -R | Image size doubles | COPY --chown |
| Relying on HEALTHCHECK | Ignored on k8s, does nothing | liveness/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.