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 withFROM.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:
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 replacedENTRYPOINT ["python3", "app.py"]docker run my-app --verbose
# runs: python3 app.py --verbose — ENTRYPOINT stayed fixed, --verbose was appendedUse 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=infoPYTHON_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 5000This 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.
What does FROM python:3.12-slim do?