A good Dockerfile is not just about making the build pass. It decides how large the image becomes, how much of the build cache you keep, how fast CI runs, how hard security reviews are, and whether a future admin can understand what you shipped. I have inherited enough ugly images to care about this more than I used to.
The worst patterns are familiar: copy the whole repository before installing dependencies, bake secrets into layers, run everything as root, base production images on whatever happened to work during development, and ship a 2 GB runtime image because nobody split the build stage from the final stage.
The fix is not fancy. Most of the time it is discipline: order the steps for cache efficiency, separate build tools from runtime, keep the context small, and be very clear about what belongs in the image versus what should arrive at runtime.
Layer caching: order matters more than people expect
Docker builds images in layers. If a layer changes, every layer after it needs rebuilding. That makes instruction order one of the highest-value optimizations you can do.
A bad pattern for a Node.js app looks like this:
FROM node:22-bookworm
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["npm", "start"]
Any source code change invalidates the COPY . . layer, which forces npm ci to run again even if dependencies did not change.
A better pattern:
FROM node:22-bookworm
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]
Now dependency installation stays cached until package.json or package-lock.json changes. That difference is huge in CI and on developer machines.
I apply the same thinking to Python, Go, Java, and almost everything else: copy dependency manifests first, install dependencies, then copy the fast-changing application source.
If you build full stacks with Compose, this interacts directly with rebuild speed. The Docker Compose guide covers the operational side, while the Dockerfile is where the build economics begin.
Multi-stage builds are the default for production
If you are compiling code, installing build dependencies, or generating static assets, multi-stage builds should be your default posture.
A common Go example:
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/app
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /out/app /usr/local/bin/app
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/app"]
The final image contains the binary and runtime dependencies, not the whole compiler toolchain.
A Node.js example is similar: build assets in one stage, copy only the production output to the final stage. I do this even when the image size savings are moderate, because it also reduces the amount of tooling and package metadata present in production.
The trade-off is complexity. Multi-stage builds are slightly harder to read at first. In exchange, they are usually smaller, cleaner, and easier to defend during security review. I think that is worth it.
Choosing base images: Alpine is not automatically better
People see “small image” and reach for Alpine automatically. Sometimes that is fine. Sometimes it creates more work than it saves.
Three common choices:
- Alpine — very small, musl libc, different package ecosystem
- Debian slim — larger than Alpine, glibc-based, broadly compatible
- Distroless or minimal runtime images — even smaller attack surface, fewer debugging tools
I use Alpine when I know the application and dependencies behave well with musl and I do not need debugging conveniences. I use Debian slim when I want a middle ground: smaller than full Debian, easier compatibility than Alpine.
For compiled apps with a single static binary, I may use a very minimal runtime image. But I only do that when the operational team is comfortable debugging a sparse environment.
The hidden Alpine trade-off is compatibility. Native modules, DNS behavior, libc assumptions, and troubleshooting convenience can all differ. A 60 MB size win is not automatically a good trade if the image becomes harder to support.
Run as a non-root user unless you have a good reason not to
This is one of the easiest hardening wins in a Dockerfile.
Example:
FROM python:3.13-slim
WORKDIR /app
RUN useradd --system --create-home --uid 10001 appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER appuser
CMD ["python", "app.py"]
Running as non-root limits damage if the application is compromised and reduces the chance of awkward file ownership issues on mounted paths.
What I do not do is switch to a non-root user without checking permissions on everything the process needs:
- application directory
- runtime temp directories if used
- mounted volumes
- log or upload paths
Validate inside the container:
docker run --rm --entrypoint id myimage
And if you use a bind mount in Compose:
docker compose exec app id
Running as non-root does not replace broader hardening, but it is a solid baseline. The Linux server hardening checklist complements this from the host side.
Never store secrets in image layers
This deserves blunt wording: if you COPY .env into an image, that secret is in the layer history even if you delete the file later in another layer.
Bad:
COPY .env /app/.env
RUN rm -f /app/.env
The secret is still in the image history.
The same applies to embedding tokens in RUN commands, downloading private artifacts with credentials in shell history, or using build arguments carelessly.
Safer patterns:
- provide secrets at runtime through environment injection or secret mounts
- use Docker BuildKit secrets for build-time credentials when needed
- keep
.env, SSH keys, and certificate material out of the build context entirely
Example with BuildKit secret mount:
#syntax=docker/dockerfile:1.7
FROM alpine:3.22
RUN --mount=type=secret,id=npm_token sh -c 'echo using secret safely >/dev/null'
Build it:
docker build --secret id=npm_token,src=$HOME/.npm-token -t myimage .
That keeps the secret out of the final image layers.
.dockerignore is a security and performance tool
I have seen .git, node_modules, test artifacts, and private keys end up in build contexts because nobody bothered to create .dockerignore.
A practical example:
.git
node_modules
.env
.env.*
coverage
dist
build
*.log
*.pem
*.key
That file helps in two ways:
- it reduces the context sent to the Docker daemon, which speeds builds
- it prevents accidental leakage of files that should never enter the image
If you want proof, compare build context sizes:
docker build --progress=plain -t myimage .
The output tells you how much context was transferred. I check this when builds feel inexplicably slow.
COPY versus ADD
I use COPY by default.
COPY src/ /app/src/
ADD has extra behaviors: it can unpack local tar archives and fetch remote URLs. Those features are exactly why I avoid it unless I explicitly need one of them.
ADD archive.tar.gz /app/
Hidden behavior makes Dockerfiles harder to reason about. If all you need is moving files into the image, COPY is clearer and safer.
Using ADD for remote downloads is especially outdated. I would rather fetch artifacts explicitly with curl or wget inside a controlled RUN step if I truly need them, and even then I verify checksums.
CMD versus ENTRYPOINT
This is another area where people memorize slogans instead of understanding the runtime behavior.
CMDprovides default command argumentsENTRYPOINTdefines the executable that always runs
Typical app container:
CMD ["python", "app.py"]
Typical utility-style image where I want a fixed executable and optional arguments:
ENTRYPOINT ["/usr/local/bin/mytool"]
CMD ["--help"]
That combination is useful because docker run image --version then becomes:
/usr/local/bin/mytool --version
I avoid shell-form commands unless I need shell behavior. JSON-array form is more predictable for signal handling and argument parsing.
Add a HEALTHCHECK when the runtime actually needs it
A health check is not mandatory for every image, but for long-running services it often helps orchestration and operations.
Example:
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
That requires curl in the runtime image, so sometimes I prefer a smaller check using a tool already present or application-native health behavior.
I do not add fake health checks just to look complete. A bad health check creates noise, restart loops, or false confidence. It should reflect something meaningful about whether the service can do its job.
Labels are cheap and useful
I like labeling images with at least source and version metadata.
LABEL org.opencontainers.image.title="myapp"
LABEL org.opencontainers.image.source="https://github.com/example/myapp"
LABEL org.opencontainers.image.version="1.4.2"
OCI labels help trace images back to repositories, versions, and build pipelines. That pays off later when someone asks what exactly is running.
Inspect labels:
docker image inspect myimage --format '{{json .Config.Labels}}'
Fewer layers, but not at the cost of readability
You will often see advice to minimize layers by chaining everything into one giant RUN. There is some truth to that, but I do not optimize for layer count alone.
A sensible package install step:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
This keeps package metadata from bloating the image and avoids leaving cache behind. That is good optimization.
What I avoid is monstrous unreadable chains that combine unrelated tasks just to save two layers. The best Dockerfiles are still maintainable.
Environment variables are for runtime configuration, not secrets
Setting defaults in a Dockerfile can be useful:
ENV APP_PORT=8080
ENV APP_ENV=production
But I use ENV for non-secret defaults, not passwords, tokens, or certificates. Runtime configuration belongs at runtime, especially when the same image moves through dev, staging, and production.
If I need to override values:
docker run -e APP_ENV=staging -p 8080:8080 myimage
Or through Compose:
environment:
APP_ENV: staging
Pin base image versions deliberately
Using latest is convenient until it breaks builds or changes behavior underneath you.
I prefer explicit tags:
FROM python:3.13.5-slim-bookworm
Better yet, for very strict environments, pin digests:
FROM python:3.13.5-slim-bookworm@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Digest pinning improves reproducibility, but it adds maintenance overhead. I use it where supply-chain control matters more than convenience.
At a minimum, avoid ambiguous rolling tags in production Dockerfiles.
Scan images for vulnerabilities, but interpret the results
I scan images, but I do not pretend scanners remove the need for judgment.
Docker Scout example:
docker scout quickview myimage
Trivy example:
trivy image myimage
Both are useful. Both will also report issues you cannot fix immediately or that live in packages you do not even use directly. The goal is not a perfect zero forever; the goal is understanding the risk and reducing it sensibly.
This is another reason multi-stage builds matter. The fewer build tools and unused packages in the runtime image, the smaller the vulnerability surface tends to be.
If you plan to distribute internally at scale, combine clean Dockerfiles with a secured registry. The private registry with TLS guide fits naturally after this.
Inspect the result, not just the Dockerfile source
A Dockerfile can look tidy and still produce a poor image. After a build, I inspect what actually came out. These are the commands I use most:
docker image ls myimage
docker history myimage
docker image inspect myimage --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}}'
docker history is especially useful when I suspect a secret, a package cache, or a giant copied directory ended up in the wrong layer. If the image is much bigger than expected, the history output usually shows where the weight lives.
I also pay attention to what packages remain in the runtime stage. If a final image still contains compilers, header packages, or build tools, that is usually a sign that the build and runtime responsibilities were not separated cleanly. Smaller images are not just about download time; they usually mean less surface area to patch and explain later.
Package manager hygiene inside images
Each ecosystem has its own habits, but the principle is the same: install only what you need, and do not leave caches behind when they add no runtime value. For Debian-based images, this is the pattern I stick to:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
For Python builds, I usually add --no-cache-dir to pip install unless I have a specific reason not to. For Node.js, I prefer deterministic installs such as npm ci over looser installs. Those choices do not just save space; they make rebuilds and security reviews more predictable.
A practical example that pulls the pieces together
Here is a realistic production-oriented pattern for a small Node.js service:
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim
WORKDIR /app
RUN useradd --system --create-home --uid 10001 appuser
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
This is not the only good pattern, but it gets the major things right: dependency caching, build separation, non-root runtime, and a smaller final image.
Common mistakes I keep seeing
A few problems come up repeatedly:
- Copying the whole repo too early. Cache efficiency disappears.
- Using
latest. Reproducibility disappears. - Running as root by default. Hardening disappears.
- Forgetting
.dockerignore. Secrets and junk enter the context. - Publishing debug tools in production images. Attack surface grows.
- Leaving package caches behind. Image size grows for no benefit.
- Embedding config that should be runtime-specific. Promotions between environments get messy.
Conclusion
Most Dockerfile optimization is really about clarity and intent. I want layers that cache well, final images that contain only what production needs, a runtime user that is not root, and no secrets trapped in history forever. If I get those basics right, the image is usually smaller, faster to build, easier to scan, and easier to trust.
That is the pattern I return to whether I am building a tiny internal service or a public-facing application. Fancy tooling helps, but a disciplined Dockerfile still does most of the work.