Docker Basics #2: Writing Your First Dockerfile — FROM, RUN, COPY, CMD
In #1 What is a container, we pulled and ran someone else’s images (hello-world, ubuntu:24.04). From this post on, we build our own. The tool for that is the Dockerfile.
This post in the Docker Basics series:
- #1 What is a container — VM vs. Docker ecosystem
- #2 Writing your first Dockerfile — FROM, RUN, COPY, CMD ← this post
- #3 Images and containers — build, run, ps, logs, exec
- #4 Volumes and networks
- #5 Registries — Docker Hub, GHCR, push/pull
- #6
.dockerignoreand the build context
What is a Dockerfile #
A Dockerfile is a text file describing “how to build this image.” Each line is an instruction; they execute top-down, and the image is built up one layer at a time.
The simplest valid Dockerfile is one line:
FROM ubuntu:24.04Building this gives you a copy of the Ubuntu image — not very useful, but a valid Dockerfile in form. In practice you’d add “install the tools you need, drop in code, define how to run it” on top.
The four instructions in this post are enough to containerize most small apps:
| Instruction | What it does |
|---|---|
FROM | Which image to start from — the first line of every Dockerfile |
RUN | Execute a command at build time — install packages, compile, etc. |
COPY | Copy files from the host into the image |
CMD | The default command to run when the container starts |
Add WORKDIR, ENV, and EXPOSE and you have a full cycle.
A small Python app #
It’s easier to follow with a real app than with explanations alone. Make a directory:
mkdir hello-docker && cd hello-dockerA tiny Flask app:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello from a container!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)And a dependency manifest:
flask==3.0.3That’s a normal Python project. The goal of this post is to ship it as a container.
FROM — pick a base image #
Every Dockerfile starts with FROM. Instead of installing the OS and runtime from scratch, you start from an image someone has already built.
FROM python:3.14-slimBreaking down python:3.14-slim:
python— Docker Hub’s official Python image (hub.docker.com/_/python)3.14— the tag (usually a version). You can pick3.14,3.14.0,3.13, etc.-slim— a stripped-down variant of the same version. Debug tools and docs are dropped, so it’s smaller.
Common variants after the version tag:
| Variant | Size (rough) | When |
|---|---|---|
3.14 (full) | ~1 GB | Nothing missing. Easy when you’re learning. |
3.14-slim | ~150 MB | Recommended for most cases — small and safe |
3.14-alpine | ~50 MB | Smallest. musl-based, so packages with C extensions (numpy, etc.) often hit build trouble. |
The size differences matter. Pulling the full image on every CI run can stretch builds by minutes. Start with slim.
Alpine is tempting, but it bites. It uses musl libc instead of glibc, so packages with C extensions (
pandas,numpy,psycopg2) often have no compatible wheel and fall back to source builds. Builds get slow and you have to bring in a full compiler toolchain. When in doubt, go with slim.
RUN — execute commands at build time #
With only FROM, you have an empty Python environment — Flask isn’t installed yet. RUN installs it.
FROM python:3.14-slim
RUN pip install --no-cache-dir flask==3.0.3RUN executes a command at build time, and the resulting filesystem state is baked into the next layer. So Flask is already installed inside the image — not at container start.
--no-cache-dir tells pip not to keep its download cache. The image doesn’t need it; it just inflates size. Almost idiomatic in Docker builds.
If you have a requirements.txt, the typical pattern is:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt(Why this order matters for caching is covered in detail in #6 build context.)
shell form vs exec form #
RUN has two forms:
RUN apt-get update && apt-get install -y curlRUN ["apt-get", "update"]Shell form runs the command through /bin/sh -c, so shell features like &&, |, > work as expected. Exec form runs the command directly, no shell. RUN is almost always fine in shell form; exec form matters more for CMD / ENTRYPOINT below.
COPY — host files into the image #
Now we drop our code into the image.
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .Two new things here:
WORKDIR /app— sets/appas the working directory for subsequent instructions, creating it if needed. Effectivelycd /appplusmkdir -p /app.COPY <src> <dst>— copiesrequirements.txtfrom the build context on the host intoWORKDIRinside the image.<src>is relative to the build context (usually the directory holding the Dockerfile).
There’s a similar instruction ADD that also handles URL downloads and automatic extraction. That magic occasionally surprises people, so the modern recommendation is to use plain COPY. If you need URL downloads, an explicit RUN curl ... reads better.
CMD — the command to run when the container starts #
Finally, we declare what to run when the container boots.
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]CMD also has two forms:
CMD ["python", "app.py"]CMD python app.pyUse the exec form for CMD. Shell form turns into /bin/sh -c "python app.py" internally — the shell becomes the main process and Python becomes its child. The SIGTERM docker stop sends gets stuck at the shell and never reaches Python, so graceful shutdown breaks. With exec form, Python is PID 1 and receives signals directly.
EXPOSE 8000 is documentation — “this container listens on 8000.” Actually opening a port on the host is docker run -p from #3. EXPOSE itself doesn’t change network behavior, but it’s a sign other tools and people read, so it’s worth declaring.
CMD vs ENTRYPOINT — briefly #
A common point of confusion, so one paragraph:
CMD— the default command. Pass arguments todocker run myapp echo hiand they override it.ENTRYPOINT— always executed. Arguments after it become arguments toENTRYPOINT.
ENTRYPOINT ["python"]
CMD ["app.py"]docker run myapp becomes python app.py; docker run myapp other.py becomes python other.py. Useful when you want the image to behave like a single binary, but plain CMD is fine to start.
Build — docker build
#
In the directory with the Dockerfile:
docker build -t hello-docker .Flag breakdown:
-t hello-docker— name (tag) for the image being built. Skip it and you only get an ID, which is hard to use..— the build context path, usually the current directory. (The main topic of #6.)
The first build pulls python:3.14-slim, so it takes a moment. Output looks like:
[+] Building 12.3s (10/10) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [internal] load metadata for docker.io/library/python:3.14-slim
=> [1/5] FROM docker.io/library/python:3.14-slim
=> [internal] load build context
=> [2/5] WORKDIR /app
=> [3/5] COPY requirements.txt .
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt
=> [5/5] COPY app.py .
=> exporting to image
=> => writing image sha256:abc123...
=> => naming to docker.io/library/hello-dockerEach instruction becomes one layer. From the second build on, unchanged layers are reused from cache and the build is much faster. (Cache behavior in depth: #6.)
Run — docker run
#
docker run --rm -p 8000:8000 hello-docker--rm— auto-delete the container when it exits. Handy for one-shot runs.-p 8000:8000— map host port 8000 to container port 8000. Host first, container second.
Open http://localhost:8000 in your browser and you’ll see Hello from a container!. Your first hand-built container.
Ctrl+C in the terminal stops it; --rm cleans up automatically.
Helper instruction — ENV #
For environment variables, use ENV.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1These two are almost idiomatic for Python in containers:
PYTHONUNBUFFERED=1— flushprintoutput immediately. Required to see container logs in real time.PYTHONDONTWRITEBYTECODE=1— don’t write.pyccache files. Containers are ephemeral; the cache is pointless.
To inject env vars at runtime, pass docker run -e KEY=value. (Don’t bake DB passwords or other secrets into ENV — use -e or a secret manager.)
The Dockerfile, all together #
Combining everything we’ve seen:
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]Nine lines to ship a small Flask app in a container. The same skeleton works for FastAPI / Django / Express — only the base image and run command change.
Common pitfalls #
A few points where first-time Dockerfile authors stumble:
- Putting
COPY . .beforeRUN pip install. A one-line code change forces a full reinstall of dependencies. Order it as dependency manifest → install → code copy. (The cache story in full: #6.) apt-get installwithout cleanup. Package indexes stay around and bloat the image. Idiomatic pattern:RUN apt-get update && apt-get install -y --no-install-recommends X && rm -rf /var/lib/apt/lists/*.CMDin shell form. SIGTERM fromdocker stopdoesn’t reach the app, so it’s always SIGKILLed. Use exec form (["python", "app.py"]).- Running as root. Default images run as root. In production you drop to an unprivileged user with
USER(covered in Docker Advanced).
Wrap-up #
The flow from this post:
- A Dockerfile is a text file describing how to build an image — each line becomes a layer, top to bottom.
FROMpicks the base image; variants (slim,alpine) trade size against compatibility.RUNruns commands at build time and bakes the result into the image.COPYmoves host files into the image;WORKDIRsets the working directory.CMDis the default command at container start — write it in exec form.docker build -t name .to bake the image;docker run -p host:container nameto start it.
In the next post (#3 Images and containers — build, run, ps, logs, exec) we go one level deeper into the Docker CLI: docker build options, common docker run flags (-d, --name, -e, --rm), and operational commands like docker logs and docker exec for managing the container lifecycle.