What Is a Dockerfile and How Do You Create One?
Table of Contents
A Dockerfile is a plain-text, version-controlled recipe for building a container image: a sequence of instructions describing what base to start from, what files to copy in, what to install, and what to run.
docker build reads it top to bottom and produces an image.
A Dockerfile example using basic instructions #
The following example shows a dockerfile that creates a nodejs app:
# Starting point: an existing image
FROM node:20-slim
# Working directory for everything below
WORKDIR /app
# Copy files from the build context into the image
COPY package.json package-lock.json ./
# Run a command during the build
RUN npm ci --omit=dev
# Copy the rest of the source
COPY . .
# Set an environment variable
ENV NODE_ENV=production
# Document which port the app listens on
EXPOSE 3000
# Drop root before running the app
USER node
# The command that runs when the container starts
CMD ["node", "server.js"]
# as a comment; a # after an instruction is passed to that instruction as part of its arguments, which breaks the build.
FROM: every Dockerfile starts here. Pick as small and specific a base as you can justify (node:20-slimover plainnode:20,alpinevariants where compatible), because it affects the final image size.WORKDIR: sets the working directory for every subsequent instruction; Helps to avoid repeating absolute paths.COPY: brings files from the build context into the image. (ADDadditionally handles remote URLs and auto-extracts archives; preferCOPYunless you specifically need that)RUN: executes a command at build time, and its result is baked into a layer. EachRUNtypically creates one layer.ENV: sets an environment variable, available both at build time (for later instructions) and at container runtime.EXPOSE: purely documentation; it doesn’t actually publish the port. You still need-pondocker runorports:in Compose.USER: switches the effective user for subsequent instructions and the finalCMD. Defaulting to root and never switching is one of the most common avoidable security issues in Docker images.CMD: the default command when the container starts. Overridable atdocker runtime; useENTRYPOINTinstead if you want a command that isn’t meant to be overridden.
Layer order matters for build speed #
Docker caches each layer and reuses it if nothing that affects it has changed. Put things that change rarely (installing dependencies) before things that change often (copying application source), so a source change doesn’t invalidate the expensive dependency-install layer:
# Good: dependency install cached separately from source changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Bad: any source change invalidates npm ci too, forcing a full reinstall
COPY . .
RUN npm ci
Multi-stage builds #
Every FROM instruction starts a new stage with its own, separate filesystem. In a single-stage Dockerfile, everything you install or build ends up in the final image. With multiple stages you can do the messy work (compiling, installing build tools and dev dependencies) in one stage, and start the final stage from a clean, small base into which you copy only the result:
# Stage 1: build the application
FROM golang:1.23 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app
# Stage 2: the image that actually gets shipped
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
AS builder gives the first stage a name (without it, stages are numbered starting at 0). The only line connecting the two stages is COPY --from=builder /app /app. A normal COPY takes files from the build context, the directory you pass to docker build. With --from, the source is the filesystem of another stage instead, so this line copies /app, the binary built in the first stage, to /app in the second one. You can also point --from at an existing image instead of a stage, for example COPY --from=nginx:1.27 /etc/nginx/nginx.conf /tmp/nginx.conf.
The result of a build is the last stage, or the one you pick with docker build --target <name>. Earlier stages only exist while building. They stay in your local build cache, but nothing from them is part of the final image except what you explicitly copied over.
So why use multiple stages here? Compiling the program needs the Go toolchain and the source code, which together make up an image of several hundred megabytes. Running it needs only the compiled binary. Without stages you would either ship the whole toolchain together with your application, or compile outside of Docker and copy the binary in, which makes your builds depend on whatever happens to be installed on the build machine. With stages, one Dockerfile and one docker build give you a predictable build environment and a small final image, with much less in it that you have to keep patched or that could be misused.
The same pattern works well outside of compiled languages, for example when you build a JavaScript frontend in a Node stage and copy only the generated static files into a small web server image.
CGO_ENABLED=0 makes Go produce a statically linked binary, which is needed here because the distroless static base image doesn’t contain a C library. The :nonroot tag makes the container run as an unprivileged user by default. Neither is required for multi-stage builds in general, they are just what this particular Go and distroless combination needs.
Common ways Dockerfiles go wrong #
Not pinning versions #
FROM node (which implies the latest tag) means your “identical” build today and next month can differ, because the tag moves whenever a new version is released. Pin a specific version such as FROM node:20.11-slim, or a digest if you want full reproducibility.
Running as root unnecessarily #
If you never switch away from the default user, your application runs as root inside the container. The USER instruction described above is how you drop privileges before the final CMD.
One giant RUN doing everything #
Chaining many commands with && in a single RUN is sometimes the right call, because it keeps the number of layers down and lets you clean up temporary files in the same layer that created them. Taken too far, it makes the Dockerfile hard to read, and any change to the chain invalidates the build cache for all of it. Balance layer count against clarity.
Copying more than needed #
Without a .dockerignore file, COPY . . copies everything in the build context into the image, including node_modules, the .git directory and local build artifacts. A
.dockerignore file, which works much like .gitignore, keeps them out of the build context entirely. That makes builds faster and stops files you never meant to ship, such as a local .env file, from ending up in an image layer.
For what actually happens when you run docker build against this file (build context, caching, and the .dockerignore mechanic), see
How Do You Build a Docker Image From a Dockerfile?.