Writing a Dockerfile

The core instructions

A Dockerfile is a plain text file — a recipe describing how to build an image, read and executed top to bottom, similar in spirit to Python’s own top-to-bottom execution model. A minimal one for a small Python script:

  • FROM python:3.12-slim — start from an existing base image (here, a small, official image that already has Python 3.12 installed) rather than building an OS up from nothing. Every Dockerfile starts with FROM.
  • WORKDIR /app — set the working directory inside the image; every instruction after this runs relative to /app.
  • COPY requirements.txt . — copy a file from your own machine (the “build context”) into the image.
  • RUN pip install -r requirements.txt — execute a command during the build, baking its result into the image itself.
  • COPY . . — copy the rest of the project’s files in.
  • CMD ["python3", "app.py"] — the command that runs when a container is actually started from this image, not during the build.

Click Build to see what actually happens when this Dockerfile runs — one Step N/M line per instruction above, in the same order:

Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python3", "app.py"]
click "Build" to see the build log

Three pairs worth knowing apart

A few instructions come in look-alike pairs, where the difference matters and is easy to get wrong.

COPY vs. ADD. ADD does everything COPY does, plus two extra behaviors: it auto-extracts a local .tar/.tar.gz archive into the destination, and it can fetch a URL directly. Those extras sound convenient, but they’re also surprising if you didn’t want them:

ADD backup.tar.gz /app/

This doesn’t copy backup.tar.gz as a file — it silently extracts its contents into /app/, which is rarely what someone reaching for ADD out of habit actually expects. The standard guidance: default to COPY for plain files, and reach for ADD only when you specifically want one of those two extra behaviors, deliberately.

CMD vs. ENTRYPOINT. Both specify what runs when a container starts — the difference is what happens when docker run includes extra arguments. CMD is fully replaced by anything passed at docker run time; ENTRYPOINT’s arguments are appended to, not replaced by, whatever follows it:

CMD ["python3", "app.py"]
docker run my-app echo hello
# runs: echo hello  — CMD was entirely replaced
ENTRYPOINT ["python3", "app.py"]
docker run my-app --verbose
# runs: python3 app.py --verbose  — ENTRYPOINT stayed fixed, --verbose was appended

Use CMD for a default that a user might reasonably want to override entirely; use ENTRYPOINT when the container should always run one specific program, with any extra arguments just tacked onto it.

ENV vs. ARG. Both define a named value, but at different times and with different lifetimes. ARG only exists during the build — it’s gone by the time a container actually runs. ENV persists into the running container, readable by the app itself:

ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim

ENV LOG_LEVEL=info

PYTHON_VERSION is only usable to parameterize the build itself (here, picking which base image to use) — trying to read it from inside the running app would find nothing, since ARG values never make it into the container’s actual runtime environment. LOG_LEVEL, defined with ENV, is available to the running app via a normal environment variable lookup, exactly the way os.environ-style reads work anywhere else.

USER — not running as root by default

Without a USER instruction, a container runs as root by default — worth pausing on, given the isolation motivation from earlier in this lesson: even inside a restricted container, running as root gives whatever’s running full permissions within that container’s boundary, which is more access than most apps actually need. Adding a non-root user is straightforward:

FROM python:3.12-slim
RUN useradd --create-home appuser
USER appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appuser . .
CMD ["python3", "app.py"]

USER appuser switches every instruction after it — and the container’s actual runtime process — to that user instead of root. This is a small addition with a real payoff: it limits what a compromised or misbehaving process running inside the container could actually do, even within the container’s own filesystem.

EXPOSE — documentation, not a port mapping

EXPOSE is worth naming specifically because it’s commonly misunderstood:

EXPOSE 5000

This does not actually make port 5000 reachable from outside the container — it’s purely documentation, a note to anyone reading the Dockerfile (or using tooling that reads it) about which port the app inside is expected to listen on. Actually publishing a port to the host machine is -p’s job, covered next in this lesson — EXPOSE alone, with no -p at run time, leaves the container just as unreachable from outside as if it weren’t there at all.

Check your understanding
1/8

What does FROM python:3.12-slim do?