How to Write a Dockerfile (Step by Step, with Examples)

A Dockerfile is a text file named Dockerfile with one instruction per line: start FROM a base image, set a WORKDIR, COPY your files in, RUN the install steps, and finish with the CMD that starts your app. Build it with docker build -t myapp . and run it with docker run myapp.

Here is a complete Dockerfile for a small Python web app. The rest of this article explains each line and the choices behind it.

# syntax=docker/dockerfile:1
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["python", "app.py"]

A Dockerfile, line by line

FROM: pick a base image

FROM python:3.12-slim starts from the official Python image. As of October 2026, Docker Hub lists 3.12-slim as an alias for the current 3.12 patch release on Debian (3.12-slim-trixie); a 3.12-slim-bookworm variant also exists if you need the older Debian. slim variants leave out compilers and extra packages, so they are much smaller than the full image.

Use a specific tag, never just python or python:latest, or your image changes whenever the tag moves. For fully reproducible builds Docker's best-practice guide goes further and recommends pinning to a digest: FROM python:3.12-slim@sha256:....

The first line, # syntax=docker/dockerfile:1, is a parser directive. It tells BuildKit to use the latest stable version 1 Dockerfile syntax. It is optional but harmless.

WORKDIR: set the working directory

WORKDIR /app makes /app the directory for every following instruction and for the running container. The reference notes that if it doesn't exist, "it will be created". Use it instead of RUN cd /app, which only lasts for that one RUN line.

COPY and RUN: dependencies first, code second

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

This order is the most important performance choice in the file. Each instruction creates a layer, and Docker reuses a layer from its cache if nothing it depends on has changed. The build cache docs put it bluntly: "If a layer changes, all other layers that come after it are also affected."

Copying only requirements.txt first means the slow pip install layer is reused on every build where your dependencies didn't change. Editing app.py only invalidates the final COPY . .. If you wrote COPY . . before the install, every one-line code change would reinstall every package. Docker's cache guide shows the same pattern for Node.js (copy package.json and the lock file, run the install, then copy the project), and the Python image's own example on Docker Hub does it too.

--no-cache-dir stops pip keeping its download cache inside the image, where it would only add size.

USER: don't run as root

Containers run as root unless you say otherwise. Docker's guide: "If a service can run without privileges, use USER to change to a non-root user." useradd creates the user on Debian-based images such as python:3.12-slim. Put USER after the steps that need root, such as installing system packages.

EXPOSE and CMD: document the port, start the app

EXPOSE 8000 is documentation. The reference states that it "doesn't actually publish the port". You still need -p at run time.

CMD ["python", "app.py"] is the default command, written in exec form: a JSON array, which per the reference means "you must use double-quotes". Exec form runs the program directly instead of through /bin/sh -c. The reference warns that with the shell form the program is not PID 1 and does not receive the SIGTERM that docker stop sends, so it can't shut down cleanly. Only the last CMD in a file takes effect.

Build and run the image

docker build -t myapp .
docker run --rm -p 8000:8000 myapp

-t myapp names the image. The final . is the build context: the folder whose files the build can see. COPY can't reach anything outside it, so COPY ../secrets.txt . fails. In -p 8000:8000, the first number is the port on your machine and the second is the port inside the container. --rm deletes the container when it stops.

Your app must listen on 0.0.0.0, not 127.0.0.1, inside the container. An app bound to localhost only accepts connections from inside the container itself, so the published port appears dead.

Add a .dockerignore file

Without one, COPY . . copies everything in the folder: .git, virtual environments, local .env files with secrets. Create .dockerignore next to the Dockerfile:

.git
.venv
__pycache__
*.pyc
.env
node_modules
Dockerfile
.dockerignore

The syntax is like a .gitignore, with one difference to know about. Per Docker's docs, *.md matches only at the top of the context; use **/*.md to match at any depth. A line starting with ! re-includes files, and the last matching line wins.

Multi-stage builds: smaller final images

When building needs tools that running doesn't (a compiler, a Node toolchain for front-end assets), use two stages and copy only the result into the second. Docker's guide says multi-stage builds "let you reduce the size of your final image". This is the named-stage example from Docker's multi-stage documentation, a Go "hello, world" (the COPY <<EOF lines write a file inline):

# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

Everything in the build stage, including the Go toolchain, is left behind. scratch is an empty image, so the final image holds just the one compiled binary. In a real project you would COPY your source in place of the inline file; the same pattern copies a built dist/ folder from a Node stage into a web-server image.

Installing system packages

On Debian-based images, combine update, install and cleanup in one RUN:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

Docker's guide: "Always combine RUN apt-get update with apt-get install in the same RUN statement." Split across two lines, the cached apt-get update layer goes stale and later installs fail or pull old versions. Deleting /var/lib/apt/lists in the same RUN keeps the package index out of the image; deleting it in a later RUN saves nothing, because the earlier layer still contains it.

Common Dockerfile mistakes

MistakeEffectFix
COPY . . before installing dependenciesEvery code change reinstalls everythingCopy the dependency manifest first
No .dockerignoreSlow builds, secrets and .git in the imageAdd one
FROM image:latestBuilds change without warningPin a version tag or digest
CMD python app.py (shell form)Runs via /bin/sh -c; the app doesn't get the stop signalUse exec form: CMD ["python", "app.py"]
Secrets in ENV or ARGENV values persist in the image; build args show up in docker historyPass at run time, or use build secrets
App listens on 127.0.0.1Port published but unreachableBind to 0.0.0.0

If the build fails

Read the failing step number in the build output; each step maps to one line of the Dockerfile. A "not found" error on a COPY line means the file is outside the build context or excluded by .dockerignore. A failing RUN step shows the command's own error, so run the same command inside the base image to debug it: docker run --rm -it python:3.12-slim bash. To force a clean rebuild that ignores the cache, add --no-cache. For when a container is the right tool at all, see Docker vs a virtual machine.

Frequently asked questions

What is the difference between CMD and ENTRYPOINT?
CMD sets the default command, and anything you type after the image name in docker run replaces it. ENTRYPOINT sets a command that always runs; arguments from docker run are appended to it instead. A common pattern is ENTRYPOINT for the program and CMD for its default arguments. If a Dockerfile has several CMD lines, only the last one takes effect.
Should I use COPY or ADD?
Use COPY by default. ADD can also download a remote URL or Git repository and automatically extracts local tar archives, which is useful when you need it and surprising when you don't. Docker's best-practice guide recommends COPY for ordinary files and ADD when you need a remote artifact.
Does EXPOSE publish a port?
No. The Dockerfile reference says EXPOSE doesn't actually publish the port. It documents which port the application listens on. To reach it from the host you still publish it at run time, for example docker run -p 8000:8000 myapp.
What does the dot at the end of docker build mean?
It is the build context: the folder whose files the build can see. COPY and ADD can only read files inside it. docker build -t myapp . uses the current directory; a .dockerignore file in that folder keeps files out of the context.

Sources