Writing Your First Dockerfile
A Dockerfile is a text file that tells Docker how to build an image. It matters because it turns application setup into repeatable instructions: anyone with the same files and a Docker daemon can build the same starting point for a container.
Your first Dockerfile should be small, explicit, and boring. The goal is not to memorize every instruction, but to understand how each line becomes image metadata or a filesystem layer that future builds can reuse.
Overview: How a Dockerfile Works
A Dockerfile is read by the Docker builder from top to bottom. Each instruction describes either a change to the image filesystem, such as COPY or RUN, or metadata for future containers, such as CMD, EXPOSE, ENV, and WORKDIR. When you run docker build, the Docker client sends a build request to the Docker daemon or BuildKit builder, along with the build context: the files in the directory you build from, minus anything excluded by .dockerignore.
An image is not one big archive in the simple sense. It is a stack of read-only layers plus a small configuration document. A RUN instruction usually creates a new filesystem layer containing the changes made by that command. A COPY instruction creates a layer containing copied files. Metadata instructions may update the image configuration without adding much filesystem data. When a container starts from the image, Docker mounts those read-only layers and adds a thin writable container layer on top.
The first instruction is normally FROM. It chooses a base image, such as nginx:1.27-alpine or node:20-alpine. The base image already contains a minimal operating system userland, libraries, and sometimes a runtime. Your Dockerfile adds your application and declares the default command. Use pinned, specific tags in examples and production, because latest is a moving pointer, not a promise of stability.
Build caching is central to writing good Dockerfiles. Docker can reuse the result of an instruction when the instruction text and its relevant inputs have not changed. If one layer changes, that layer and every later layer must be rebuilt. This is why dependency files are commonly copied before source files: changing server.js should not force npm ci to run again if package.json and package-lock.json stayed the same.
Syntax
A basic Dockerfile follows this shape:
FROM image:tag
WORKDIR /path/in/image
COPY source destination
RUN command
EXPOSE port
CMD ["executable", "arg"]
| Instruction | Purpose |
|---|---|
FROM image:tag |
Starts a build stage from an existing image. In a normal Dockerfile this must appear before other build instructions. |
WORKDIR /path |
Sets the working directory for later RUN, COPY, CMD, and ENTRYPOINT instructions. Docker creates it if needed. |
COPY source destination |
Copies files from the build context into the image. Prefer COPY for local files because it is direct and predictable. |
RUN command |
Runs a command during the build and stores the filesystem changes in a new layer. |
EXPOSE port |
Documents the port the containerized application listens on. It does not publish the port to the host. |
CMD [...] |
Declares the default command for containers started from the image. JSON-array form avoids shell parsing surprises. |
To build and run an image from a Dockerfile in the current directory, the common command form is docker build -t name:tag ., then docker run image:tag. The final dot is the build context path, not punctuation.
Examples
Example 1: A static web page with nginx
Suppose the current directory contains a simple index.html. This Dockerfile starts from a pinned nginx image and copies your page into nginx’s default web root:
FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
Build and run it like this:
docker build -t first-nginx:1.0 .
docker run --rm -p 8080:80 first-nginx:1.0
Output:
[+] Building 2.4s (7/7) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load metadata for docker.io/library/nginx:1.27-alpine
=> [1/2] FROM docker.io/library/nginx:1.27-alpine
=> [2/2] COPY index.html /usr/share/nginx/html/index.html
=> exporting to image
=> naming to docker.io/library/first-nginx:1.0
The image contains nginx plus your file. EXPOSE 80 records that the app expects port 80 inside the container, but the port becomes reachable from your host only because docker run used -p 8080:80.
Example 2: A small Node.js service
For a Node.js app with package.json, package-lock.json, and server.js, use a Dockerfile that separates dependency installation from source copying:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
Build and run it:
docker build -t hello-api:1.0 .
docker run --rm -p 3000:3000 hello-api:1.0
Output:
Builds and runs the Node.js API image, publishing host port 3000 to the container process listening on port 3000.
The important detail is the order. Docker copies only the package files before npm ci. If you edit server.js later, Docker can usually reuse the dependency layer and rebuild only the later COPY server.js ./ layer.
Example 3: A multi-stage production image
Some applications need compilers or build tools that should not ship in the final image. A multi-stage Dockerfile solves this by naming one stage builder and copying only the build artifact into the final stage:
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN go build -o /out/hello ./cmd/hello
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /out/hello /app/hello
USER 10001
CMD ["/app/hello"]
Output:
Compiles a Go program in a builder stage, then copies only the binary into a smaller final image.
The final image does not contain the Go compiler, module cache, or source tree unless you explicitly copy them. This keeps the runtime image smaller and reduces the number of tools available inside a compromised container.
How It Works Step By Step
docker build -t hello-api:1.0 .tells the Docker client to build the current directory as the context and tag the result.- The builder reads
Dockerfile, resolves theFROMimage, and pulls missing base-image layers from a registry such as Docker Hub. - For each instruction, BuildKit checks whether a matching cached result exists. The cache key includes the instruction and, for
COPY, the relevant file contents. RUN npm ci --omit=devstarts a temporary build container from the previous layer, runs the command, captures filesystem changes, and stores those changes as a new read-only layer.CMDwrites default startup metadata into the image configuration. It does not run during the build.- When you later run the image, Docker creates a container: read-only image layers, a writable container layer, network settings, environment, and the configured command as process ID 1 inside the container.
Common Mistakes
Using latest, copying too much, and baking secrets
This Dockerfile shows several problems at once:
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
ENV API_KEY=<YOUR_API_KEY>
CMD ["node", "server.js"]
node:latest can change underneath you. COPY . . before installing dependencies means any source edit can invalidate the dependency install cache. npm install may update dependency choices instead of reproducing the lockfile exactly. The ENV line is worse even with a placeholder value: real secrets placed in a Dockerfile are baked into image history and layers. Removing the file or variable later does not reliably erase it from previous layers.
A better version pins the base, copies dependency metadata first, uses a reproducible install, and runs as a non-root user supplied by the official Node image:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Thinking EXPOSE publishes a port
EXPOSE 3000 is useful documentation, and some tools can read it, but it does not bind host port 3000. To reach the container from the host, run with -p 3000:3000 or use a Compose ports: entry.
Ignoring the build context
The final dot in docker build -t app:1.0 . sends the current directory as the build context. If that directory includes node_modules, logs, test reports, local database files, or a .git directory, builds become slower and images may accidentally receive private files. Add a .dockerignore early in real projects.
Best Practices
- Pin base image tags, such as
node:20-alpine, instead of relying onlatest. - Order instructions from least frequently changing to most frequently changing to preserve the build cache.
- Copy dependency manifests, install dependencies, then copy application source.
- Keep secrets out of Dockerfiles. Pass them at runtime through your orchestrator, Docker secrets, mounted files, or environment variables supplied outside the image.
- Use
.dockerignoreto keep build contexts small and avoid copying private or irrelevant files. - Prefer JSON-array form for
CMD, such asCMD ["node", "server.js"]. - Run as a non-root user when the base image supports it, or create a dedicated user for production images.
- Use multi-stage builds when compiling code or building frontend assets.
- Remember that an image is read-only layers; container data written at runtime disappears with the container unless you use a volume or bind mount.
Practice Exercises
- Create a Dockerfile for a static site using
nginx:1.27-alpine. The final image should serve your localindex.html, and the container should be reachable on host port 8080 when you run it. - Reorder a Node.js Dockerfile so dependency installation is cached when only application source changes. Hint: copy
package*.jsonbefore copying the rest of the source. - Write a two-stage Dockerfile for a compiled app. The first stage should build the artifact, and the final stage should contain only the runtime files needed to start it.
Summary
- A Dockerfile is a repeatable recipe for building a Docker image.
FROMchooses the base image;RUNchanges the image during build;COPYbrings files from the build context into the image.CMDsets the default container command, whileEXPOSEonly documents an internal port.- Docker images are read-only layer stacks, and containers add a thin writable layer at runtime.
- Instruction order affects cache reuse, build speed, and image size.
- Good first Dockerfiles use pinned tags, small contexts, no baked secrets, and a non-root runtime user where practical.
