Learn how to write a Dockerfile: FROM, COPY, RUN and CMD, layer caching, .dockerignore, multi-stage builds, HEALTHCHECK and the mistakes that bloat images.
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.
That 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.
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"]Pin 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.
Each RUN is a layer, and layers are additive. A file created in one and deleted in a later one still ships in the image:
--no-install-recommends alone often removes tens of megabytes.
ARG 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:
This 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.
Containers 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.
FROM node:22-alpine
FROM node:22-alpine AS build # named stage, for multi-stage
FROM node@sha256:abc123... # pinned by digest, fully reproducibleDocker reuses cached layers up to the first instruction whose inputs changed, then rebuilds everything after it. Order instructions from least to most frequently changing:
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.
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"]Without one, COPY . . pulls in node_modules, .git, local .env files and build output:
This reduces image size, speeds up the build by shrinking the context, and, importantly, stops local secrets being baked into the image.
node_modules
.git
.env
dist
*.log
Dockerfile
.dockerignoreBuild in one stage, ship from another. Tooling never reaches the final image:
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.
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"]docker 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.
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1A Dockerfile has COPY . . before RUN npm ci. Changing one line of source reinstalls every dependency. Why?
A Dockerfile has EXPOSE 3000 and the app listens on 3000, but curl to localhost:3000 on the host fails after docker run. Why?
Choose the right Dockerfile FROM base image: alpine vs slim vs full, pinning tags and digests, multi-stage FROM, platform targeting and the scratch image.
Learn when to use COPY vs ADD in a Dockerfile. Covers build context, --chown and --chmod, COPY --from, the dependency-manifest pattern and .dockerignore.
Learn how the Dockerfile RUN instruction works, including shell vs exec form, apt-get best practices, same-layer cleanup, cache mounts, secrets and heredocs.