Dockerfile Basics

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

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

InstructionPurpose
FROMThe base image. Always first (after any ARG)
WORKDIRSets and creates the working directory
COPYCopies files from the build context into the image
ADDLike COPY, plus URL fetching and auto-extraction
RUNExecutes a command at build time, creating a layer
CMDDefault command at run time
ENTRYPOINTFixed executable at run time
ENVEnvironment variable, persisted into the image
ARGBuild-time variable, not present at runtime
EXPOSEDocuments a port. Publishes nothing
USERSwitches the user for subsequent instructions and runtime
VOLUMEDeclares a mount point
HEALTHCHECKHow 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

MistakeConsequence
COPY . . before dependency installEvery build reinstalls everything
Cleaning up in a separate RUNDeleted files still ship in the earlier layer
Using ADD for local filesSurprising auto-extraction
No .dockerignoreBloated context, secrets in the image
Secrets in ARGVisible in docker history
No USERContainer runs as root
FROM node with no tagNon-reproducible builds
Shell-form CMDApplication never receives SIGTERM

Quick reference

TaskCommand
Builddocker build -t <name>:<tag> .
Build a specific stagedocker build --target <stage> -t <name> .
Build with an argumentdocker build --build-arg KEY=value .
Build with a secretdocker build --secret id=<n>,src=<file> .
Different Dockerfiledocker build -f path/to/Dockerfile .
No cachedocker build --no-cache .
Inspect layersdocker 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, 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.
  • Set USER. Containers run as root by default.
  • Always pin the base image tag, and prefer exec-form CMD.

Check your understanding

Exercise

A build that is slow every time

A Dockerfile has COPY . . before RUN npm ci. Changing one line of source reinstalls every dependency. Why?

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources