A Dockerfile is a script of instructions for building an image. Each instruction produces a layer, and understanding that one fact explains most of what makes a Dockerfile fast or slow, large or small.
FROM node:22-alpine
WORKDIR /app
# Dependencies first. This layer is cached until package files change
COPY package*.json ./
RUN npm ci --omit=dev
# Source last. Changes here do not invalidate the install above
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]docker build -t myapp:1.0 .
docker run -d -p 3000:3000 myapp:1.0That trailing . is the build context. The directory sent to the Docker daemon before the build starts. It is not the Dockerfile location, which is why a large context slows builds even when nothing is copied.
| Instruction | Purpose |
|---|---|
FROM | The base image. Always first (after any ARG) |
WORKDIR | Sets and creates the working directory |
COPY | Copies files from the build context into the image |
ADD | Like COPY, plus URL fetching and auto-extraction |
RUN | Executes a command at build time, creating a layer |
CMD | Default command at run time |
ENTRYPOINT | Fixed executable at run time |
ENV | Environment variable, persisted into the image |
ARG | Build-time variable, not present at runtime |
EXPOSE | Documents a port. Publishes nothing |
USER | Switches the user for subsequent instructions and runtime |
VOLUME | Declares a mount point |
HEALTHCHECK | How Docker tests container health |
FROM node:22-alpine
FROM node:22-alpine AS build # named stage, for multi-stage
FROM node@sha256:abc123... # pinned by digest, fully reproduciblePin a specific version. FROM node means node:latest, which changes without warning and makes your build non-reproducible.
Use COPY. It does exactly one thing: copy files from the context.
ADD additionally fetches URLs and auto-extracts local tar archives. Both behaviours are surprising. The auto-extraction in particular has caused real bugs when a .tar.gz was meant to be copied verbatim. Use COPY, and RUN curl when you genuinely need to download something.
COPY package*.json ./ # explicit and predictable
COPY --chown=node:node . . # set ownership while copying
COPY --from=build /app/dist ./dist # from another stageEach RUN is a layer, and layers are additive. A file created in one and deleted in a later one still ships in the image:
# Wrong. The apt cache is in the image forever
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Right. One layer, cleaned up inside it
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*--no-install-recommends alone often removes tens of megabytes.
ARG NODE_VERSION=22 # build time only, gone at runtime
ENV NODE_ENV=production # persists into the running containerARG values are passed with --build-arg and are not secret. They appear in docker history. Never pass a token or password as a build arg. Use BuildKit secrets instead:
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm cidocker build --secret id=npmtoken,src=./token.txt -t myapp .EXPOSE 3000This is documentation. It records which port the application listens on for humans and tooling; it does not open or publish anything. Publishing happens at run time with -p 3000:3000.
USER nodeContainers run as root by default, which is unnecessary for almost every application and a real risk if the container is compromised. Most official images provide a non-root user. Place USER after any instruction needing root, such as package installation.
Docker reuses cached layers up to the first instruction whose inputs changed, then rebuilds everything after it. Order instructions from least to most frequently changing:
FROM node:22-alpine # changes rarely
WORKDIR /app
COPY package*.json ./ # changes when dependencies change
RUN npm ci --omit=dev # expensive. Keep it cached
COPY . . # changes on every commit
CMD ["node", "server.js"]Putting COPY . . before RUN npm ci means every source edit reinstalls every dependency. It is the most common Dockerfile mistake and costs minutes on each build.
For RUN instructions, the cache key is the command string, not what it produces. RUN apt-get update stays cached indefinitely even as upstream packages change, which is why it must be chained with the install in the same instruction.
Without one, COPY . . pulls in node_modules, .git, local .env files and build output:
node_modules
.git
.env
dist
*.log
Dockerfile
.dockerignore
This reduces image size, speeds up the build by shrinking the context, and, importantly, stops local secrets being baked into the image.
Build in one stage, ship from another. Tooling never reaches the final image:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]The compiler, dev dependencies and source are all absent from what ships. For compiled languages the effect is dramatic. A Go binary in a scratch image is a few megabytes against a 900 MB build image.
docker build --target build -t myapp:debug . # stop at a named stageHEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1docker ps then shows healthy or unhealthy. --start-period is the one that matters, without enough of it, a slow-starting application is marked unhealthy while it is still booting.
| Mistake | Consequence |
|---|---|
COPY . . before dependency install | Every build reinstalls everything |
Cleaning up in a separate RUN | Deleted files still ship in the earlier layer |
Using ADD for local files | Surprising auto-extraction |
No .dockerignore | Bloated context, secrets in the image |
Secrets in ARG | Visible in docker history |
No USER | Container runs as root |
FROM node with no tag | Non-reproducible builds |
Shell-form CMD | Application never receives SIGTERM |
| Task | Command |
|---|---|
| Build | docker build -t <name>:<tag> . |
| Build a specific stage | docker build --target <stage> -t <name> . |
| Build with an argument | docker build --build-arg KEY=value . |
| Build with a secret | docker build --secret id=<n>,src=<file> . |
| Different Dockerfile | docker build -f path/to/Dockerfile . |
| No cache | docker build --no-cache . |
| Inspect layers | docker history <image> |
RUN; a later deletion does not shrink the image.COPY, not ADD: auto-extraction and URL fetching are surprising.EXPOSE documents a port and publishes nothing. Use -p at run time.ARG values appear in docker history: never pass secrets that way.USER. Containers run as root by default.CMD.<MCQExercise exercise={{ id: 'docker-df-cache-order', title: 'A build that is slow every time', description: 'A Dockerfile has COPY . . before RUN npm ci. Changing one line of source reinstalls every dependency. Why?', options: [ { id: 'a', text: 'Layer caching invalidates every instruction after the first changed one, so copying source first invalidates the install' }, { id: 'b', text: 'npm ci deliberately bypasses the Docker build cache' }, { id: 'c', text: 'Caching only applies when building with BuildKit enabled' }, { id: 'd', text: 'The build needs --cache-from for local caching to work' }, ], correctId: 'a', explanation: 'Docker walks instructions in order, reusing cached layers until it finds one whose inputs changed, then rebuilds everything after it. Copying the whole source tree before installing means any file change invalidates that COPY layer and therefore the install layer too. Copying only package.json and the lockfile first, installing, then copying the source keeps the expensive step cached until dependencies genuinely change.', wrongExplanations: { b: 'npm has no awareness of Docker. It runs again because its layer was invalidated, not by choice.', c: 'Layer caching predates BuildKit and works with the legacy builder too. BuildKit improves it but is not required.', d: '--cache-from imports cache from a registry image for CI. Local builds cache automatically.', }, }} />
<MCQExercise exercise={{ id: 'docker-df-expose', title: 'A port that is not reachable', description: 'A Dockerfile has EXPOSE 3000 and the app listens on 3000, but curl to localhost:3000 on the host fails after docker run. Why?', options: [ { id: 'a', text: 'EXPOSE only documents the port. Publishing requires -p 3000:3000 on docker run' }, { id: 'b', text: 'EXPOSE requires the port to also be listed in a VOLUME instruction' }, { id: 'c', text: 'The container needs --network host for EXPOSE to take effect' }, { id: 'd', text: 'EXPOSE only works when the image is built with BuildKit' }, ], correctId: 'a', explanation: 'EXPOSE is metadata. It records the port for humans and for tooling such as docker run -P, but it neither opens a port nor maps one to the host. Publishing is a runtime decision made with -p hostPort:containerPort, which is why the same image can be run on different host ports without rebuilding.', wrongExplanations: { b: 'VOLUME declares filesystem mount points and has no relationship to networking.', c: '--network host would make it reachable by bypassing network isolation entirely, but that is a workaround rather than how port publishing is meant to work.', d: 'EXPOSE behaves identically under both builders. It has always been documentation only.', }, }} />
Almost certainly instruction order. Docker caches layers up to the first instruction whose inputs changed and rebuilds everything after it, so COPY . . placed before your dependency install means every source edit reinstalls all dependencies. Copy the package manifest and lockfile first, run the install, then copy the source. A missing .dockerignore compounds it by sending node_modules and .git into the build context on every build.
Only documentation. It records which port the containerised application listens on, for people reading the Dockerfile and for tooling such as docker run -P, which publishes all exposed ports to random host ports. It does not open a port, publish one, or affect networking in any way. Reaching the application from the host requires -p 3000:3000 at run time, and the container is reachable by other containers on the same network whether or not EXPOSE is present.
ARG defines a build-time variable available only while the image is being built, and it is gone at runtime. ENV sets an environment variable baked into the image and present in every container started from it. A critical caveat: ARG values appear in docker history, so they must never carry secrets. Use BuildKit secret mounts, which make a value available during a single RUN without recording it in any layer.
Almost always, for anything with a build step. Compilers, development dependencies and source code stay in the build stage and never reach the shipped image, which usually cuts size dramatically. A Go binary in a scratch image is a few megabytes against a 900 MB build environment. It also reduces attack surface, since a compiler in a production image is a tool an attacker can use. The cost is a few extra lines.
Because that is the default when no USER instruction is given, and most base images do not set one. It is unnecessary for nearly every application and increases the damage a compromise can do, particularly combined with a mounted host path. Add USER node, or whichever non-root user your base image provides, after any instructions that need root, such as package installation. On Kubernetes you can additionally enforce it with runAsNonRoot: true, which turns a root image into a visible startup failure rather than a silent risk.
Learn what WORKDIR, EXPOSE, USER, LABEL and HEALTHCHECK do in a Dockerfile, why EXPOSE publishes nothing, and when VOLUME causes problems in builds.
Write safer, smaller Dockerfiles. Covers exec form for SIGTERM, non-root users, secrets, layer order, multi-stage builds, pinned bases, scanning and hadolint.
Copy production-ready Dockerfile examples for Node.js, Python FastAPI, Go, React with Nginx, Spring Boot and Django, and see the pattern they all share.