Docker Basics #2: Writing Your First Dockerfile — FROM, RUN, COPY, CMD

9 min read

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 .dockerignore and 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:

Dockerfile (smallest form)
FROM ubuntu:24.04

Building 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:

InstructionWhat it does
FROMWhich image to start from — the first line of every Dockerfile
RUNExecute a command at build time — install packages, compile, etc.
COPYCopy files from the host into the image
CMDThe 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:

Project setup
mkdir hello-docker && cd hello-docker

A tiny Flask app:

app.py
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:

requirements.txt
flask==3.0.3

That’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.

Dockerfile
FROM python:3.14-slim

Breaking 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 pick 3.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:

VariantSize (rough)When
3.14 (full)~1 GBNothing missing. Easy when you’re learning.
3.14-slim~150 MBRecommended for most cases — small and safe
3.14-alpine~50 MBSmallest. 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.

Dockerfile (with RUN)
FROM python:3.14-slim

RUN pip install --no-cache-dir flask==3.0.3

RUN 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:

With requirements.txt
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:

shell form (common)
RUN apt-get update && apt-get install -y curl
exec form (array)
RUN ["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.

Dockerfile (with COPY)
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 /app as the working directory for subsequent instructions, creating it if needed. Effectively cd /app plus mkdir -p /app.
  • COPY <src> <dst> — copies requirements.txt from the build context on the host into WORKDIR inside 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.

Dockerfile (complete)
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:

exec form — recommended
CMD ["python", "app.py"]
shell form
CMD python app.py

Use 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 to docker run myapp echo hi and they override it.
  • ENTRYPOINT — always executed. Arguments after it become arguments to ENTRYPOINT.
Pattern using both
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:

Build the image
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:

Build output (abridged)
[+] 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-docker

Each 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 #

Run the container
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 example
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

These two are almost idiomatic for Python in containers:

  • PYTHONUNBUFFERED=1 — flush print output immediately. Required to see container logs in real time.
  • PYTHONDONTWRITEBYTECODE=1 — don’t write .pyc cache 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:

Dockerfile (final)
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 . . before RUN 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 install without 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/*.
  • CMD in shell form. SIGTERM from docker stop doesn’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.
  • FROM picks the base image; variants (slim, alpine) trade size against compatibility.
  • RUN runs commands at build time and bakes the result into the image.
  • COPY moves host files into the image; WORKDIR sets the working directory.
  • CMD is the default command at container start — write it in exec form.
  • docker build -t name . to bake the image; docker run -p host:container name to 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.

X