The Dockerfile everyone writes first:
FROM node:20
COPY . .
RUN npm install
CMD npm start
It builds. It runs. Ship it and you have an image carrying your entire source tree and git history, running as root, that rebuilds every dependency when you fix a typo and ignores the shutdown signal its orchestrator sends it.
None of that fails loudly. Here is what the rest of the file is for.
Why does every small change rebuild everything?
Because layers are cached in order, and one invalidated layer invalidates every layer after it.
COPY . . before RUN npm install means any change to any file, including a README, changes that layer. The install below it is now a cache miss, so it runs again, every time.
Copy the dependency manifests first, install, then copy the source:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Now editing source invalidates only the last layer. The install layer stays cached until the lock file actually changes.
Two details that make this work properly:
npm ci, not npm install. It installs exactly what the lock file says and fails if the lock file and manifest disagree. install may quietly resolve differently, which means your build is not reproducible and you find out weeks later.
The same principle in every ecosystem. requirements.txt before the source, go.mod and go.sum before the source, Gemfile and Gemfile.lock before the source. The language changes; the ordering does not.
What is actually in your image?
More than you think, and COPY . . is why.
Without a .dockerignore, that line copies .git with its full history, node_modules from your laptop with the wrong platform binaries, .env files, test fixtures, editor config, and whatever else is in the directory.
.git
node_modules
.env
.env.*
*.log
coverage
dist
Dockerfile
.dockerignore
Two reasons this matters beyond size. .git can contain credentials in old commits even after they were removed from the working tree. And a local node_modules copied into the image shadows the one the build installed, which produces failures that make no sense until you find it.
Write the .dockerignore before the first build, not after the image is too big.
Why does a multi-stage build matter?
Because the tools that build your application are not the tools that run it, and shipping both means shipping an attack surface you do not use.
# Build
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Run
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
The final image has no compiler, no dev dependencies, no test framework and no source. Smaller, and there is materially less in it that a vulnerability scanner can find or an attacker can use.
For compiled languages the effect is dramatic: a Go binary in a minimal base is single digit megabytes against hundreds for the build image.
Why should the container not run as root?
Because by default it does, and a process that escapes the container is then root on whatever it reached.
RUN useradd --create-home --uid 10001 appuser
USER appuser
Two things people hit immediately.
Ownership. Files copied before the USER line belong to root, so a process that needs to write to them cannot. Use COPY --chown=appuser:appuser or set ownership explicitly.
Ports below 1024. A non-root process cannot bind them. Listen on 8080 rather than 80 and let the service or ingress map it. This trips up people carrying a habit from bare metal.
A numeric UID matters more than the name. Kubernetes runAsNonRoot checks the numeric UID, and a user that exists only in the image is not something the orchestrator can verify.
Why does the container ignore SIGTERM?
This one is subtle and it costs you a slow, mysterious deploy.
CMD npm start
That is the shell form. Docker runs it as /bin/sh -c "npm start", so PID 1 is the shell and your application is its child. When the orchestrator sends SIGTERM to stop the container, it goes to the shell. The shell does not forward it. Your application never learns it is being shut down, keeps serving, and gets SIGKILL when the grace period expires.
The symptom is a deploy that takes exactly the grace period per pod, plus dropped in-flight requests, and nothing in the logs explaining either.
Use the exec form:
CMD ["node", "dist/server.js"]
Now your process is PID 1 and receives the signal. Then handle it: stop accepting new connections, finish what is in flight, exit.
One catch. As PID 1 your process also inherits the duty of reaping zombie child processes. If it spawns subprocesses and does not reap them, use --init at run time or a small init like tini as the entrypoint. If it spawns nothing, plain exec form is fine.
How should the base image be pinned?
FROM node:20 moves. It is a floating tag that points at whatever the current 20.x build is, so the same Dockerfile produces different images on different days. That is the opposite of what an image is for.
FROM node:20.11.1-slim@sha256:1234abcd...
The digest is the actual guarantee. The tag is there so a human can read it.
The obvious objection is that a pinned base never gets security updates. That is correct, and it is why the update should be a visible commit rather than something that happens invisibly on a Tuesday. Dependabot and Renovate both open pull requests for base image digests. That gives you reproducible builds and patching, which the floating tag gives you neither of.
On slim and alpine. Slim variants drop most of the extra packages and keep glibc. Alpine is smaller still but uses musl, which occasionally changes behaviour in ways that are painful to debug, particularly around DNS and native modules. Start with slim; move to alpine when you have measured that the size difference matters.
Are build arguments safe for secrets?
No, and this is worth being blunt about because the mistake is common and permanent.
ARG NPM_TOKEN
RUN npm ci
That value is recorded in the image metadata. docker history will show it. Anyone who can pull the image can read it. Deleting the layer does not help, because the history travels with the image.
Use BuildKit secret mounts instead:
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
The value is available during that one command and is never written into a layer.
If a token has already been through a build argument, treat it as leaked and rotate it. It is in every image built since.
What else is worth having?
Short list, all cheap.
A HEALTHCHECK, or the orchestrator's equivalent. Something that fails when the application is unhealthy rather than merely running. On Kubernetes this is the readiness and liveness probes rather than the Dockerfile instruction, but the thinking is the same: a process being alive is not the same as it being able to serve.
WORKDIR, not RUN cd. cd in a RUN applies to that layer and is forgotten by the next one.
Labels. Source repository, commit, build date. When you find a mystery image running in six months, this is what tells you where it came from.
One process per container. If you find yourself writing a supervisor to run two things, that is two containers.
The full shape
FROM node:20.11.1-slim@sha256:... AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20.11.1-slim@sha256:...
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
COPY --chown=appuser:appuser package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build --chown=appuser:appuser /app/dist ./dist
USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]
Plus a .dockerignore. Roughly fifteen lines instead of four, and every one of them exists because the version without it fails in a way that produces no error message.
The short version
Dependencies before source, or your cache never hits. Multi-stage, or you ship your compiler. A non-root numeric UID, or an escape is root. Exec form, or shutdown does not work. Digest pinned, or the build is not reproducible. Secrets through BuildKit, or they are in the image forever.
Write it once properly and copy it. Most teams have one Dockerfile shape and twenty services, and the shape is worth getting right on the first one.
DevLift generates Dockerfiles and deployment configuration from a service definition, applying this shape by default, and opens a pull request rather than applying. Book a walkthrough, or read Kustomize or Helm for a platform team.