Dockerfile Basics
How to write a Dockerfile. Every instruction explained, layer caching and instruction order, .dockerignore, multi-stage builds, 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.
A complete example
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.0
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.
The instructions
| 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
FROM node:22-alpine
FROM node:22-alpine AS build # named stage, for multi-stage
FROM node@sha256:abc123... # pinned by digest, fully reproducible
Pin a specific version. FROM node means node:latest, which changes without warning and makes your build non-reproducible.
COPY vs ADD
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 stage
RUN and the layer problem
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:
# 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.
ENV vs ARG
ARG NODE_VERSION=22 # build time only, gone at runtime
ENV NODE_ENV=production # persists into the running container
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:
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
docker build --secret id=npmtoken,src=./token.txt -t myapp .
EXPOSE publishes nothing
EXPOSE 3000
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.
USER
USER node
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.
Layer caching is the whole game
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.
.dockerignore
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.
Multi-stage builds
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 stage
HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
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.
Common mistakes
| 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 |
Quick reference
| 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> |
Key takeaways
- Every instruction is a layer, and order determines what stays cached.
- Copy dependency manifests before source code: the single biggest build-speed win.
- Clean up inside the same
RUN; a later deletion does not shrink the image. - Use
COPY, notADD: auto-extraction and URL fetching are surprising. EXPOSEdocuments a port and publishes nothing. Use-pat run time.ARGvalues appear indocker history: never pass secrets that way.- Set
USER. Containers run as root by default. - Always pin the base image tag, and prefer exec-form
CMD.
Check your understanding
A build that is slow every time
A Dockerfile has COPY . . before RUN npm ci. Changing one line of source reinstalls every dependency. Why?
A port that is not reachable
A Dockerfile has EXPOSE 3000 and the app listens on 3000, but curl to localhost:3000 on the host fails after docker run. Why?
Frequently Asked Questions
What is the difference between COPY and ADD in a Dockerfile?
COPY copies files and directories from the build context into the image, and does nothing else. ADD does the same but additionally fetches remote URLs and automatically extracts local tar archives. Both extras are surprising in practice. A .tar.gz you meant to copy verbatim gets unpacked instead. Use COPY for local files and RUN curl when you genuinely need to download something, which also lets you verify a checksum in the same layer.
Why is my Docker build slow every time?
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.
What does EXPOSE actually do?
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.
What is the difference between ENV and ARG?
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.
Should I use a multi-stage build?
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.
Why does my container run as root and how do I change it?
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.