Containers in Plain English (and a First Dockerfile)
Your project plus its setup instructions, in a box
In this lesson
A virtual environment pins your Python packages. It does nothing about the Python version, the operating system, the system libraries, or the environment variables, and those are where the remaining “works on my machine” problems live.
A container packages all of it. This lesson is a first look, aimed at understanding what people mean and reading a Dockerfile without alarm, not at running production infrastructure.
Explain it like I’m 5
A container is your program plus everything it needs to run, sealed in a box that behaves the same wherever you open it.
Image, container, and what each word means
Two words do all the work, and mixing them up makes every explanation confusing.
An image is the recipe, built once and never changed. It contains a stripped-down operating system, a Python installation, your dependencies, and your code. Images are shared and reused.
A container is one running instance of an image. Start the same image five times and you have five containers that cannot see each other. Stop one and anything it wrote is gone, unless you deliberately mounted a folder from outside.
The comparison worth holding on to: an image is to a container what a class is to an object. One definition, many instances.
And the useful hierarchy against what you already know:
requirements.txt— which Python packages.pyproject.toml— the above, plus your code as an installable package.- Container — all of the above, plus the Python version, the operating system, and the system libraries.
A Dockerfile, line by line
A Dockerfile is the recipe for an image: a short list of instructions, each producing a layer that Docker caches.
Read it as a series of commands run inside a fresh machine, top to bottom.
# Start from an official image: Debian with Python 3.12 already installed.
FROM python:3.12-slim
# Everything after this runs in /app inside the image.
WORKDIR /app
# Copy ONLY the dependency file first - see the note below on caching.
COPY pyproject.toml ./
RUN pip install --no-cache-dir .
# Now the code, which changes far more often.
COPY src/ ./src/
RUN pip install --no-cache-dir --no-deps .
# Do not run as root.
RUN useradd --create-home runner
USER runner
# What to run when a container starts from this image.
CMD ["tidyup", "/data"]
Why the COPY order decides your build time
Each instruction produces a cached layer. On a rebuild, Docker reuses every layer up to the first one whose inputs changed, then redoes everything after it.
That single rule explains the odd-looking split above. Dependencies are copied and installed before the source code, because your code changes fifty times a day and your dependency list changes monthly. Put COPY . . at the top instead and every one-character edit re-downloads every package.
It is the difference between a three-second rebuild and a three-minute one, on every single edit.
Work out the cost of getting that order wrong. Given a list of layers and which file each depends on, find the first layer whose input changed and rebuild everything from there. Then compare the good order against the bad one.
# (name, the file this layer depends on, seconds to build)
GOOD = [
("FROM python:3.12-slim", None, 0),
("COPY pyproject.toml", "pyproject.toml", 1),
("RUN pip install .", "pyproject.toml", 45),
("COPY src/", "src", 1),
("RUN pip install --no-deps .", "src", 3),
]
BAD = [
("FROM python:3.12-slim", None, 0),
("COPY . .", "src", 1),
("RUN pip install .", "src", 45),
]
def rebuild(layers, changed):
"""Return (rebuilt layer names, seconds) after `changed` was edited."""
# TODO: everything from the first layer depending on `changed` onwards
return [], 0
for label, layers in [("good", GOOD), ("bad", BAD)]:
names, seconds = rebuild(layers, "src")
print(f"{label}: {len(names)} layers, {seconds}s")
print("cached:", rebuild(GOOD, "nothing-changed")[1], "s")
Walk the layers with enumerate until depends_on == changed. Everything from that index onwards is rebuilt, so slice with layers[index:] and sum the third element of each. If no layer matches, nothing rebuilds: return an empty list and 0.
# (name, the file this layer depends on, seconds to build)
GOOD = [
("FROM python:3.12-slim", None, 0),
("COPY pyproject.toml", "pyproject.toml", 1),
("RUN pip install .", "pyproject.toml", 45),
("COPY src/", "src", 1),
("RUN pip install --no-deps .", "src", 3),
]
BAD = [
("FROM python:3.12-slim", None, 0),
("COPY . .", "src", 1),
("RUN pip install .", "src", 45),
]
def rebuild(layers, changed):
"""Return (rebuilt layer names, seconds) after `changed` was edited."""
for index, (name, depends_on, seconds) in enumerate(layers):
if depends_on == changed:
rebuilt = layers[index:]
return ([layer[0] for layer in rebuilt],
sum(layer[2] for layer in rebuilt))
return [], 0
for label, layers in [("good", GOOD), ("bad", BAD)]:
names, seconds = rebuild(layers, "src")
print(f"{label}: {len(names)} layers, {seconds}s")
print("cached:", rebuild(GOOD, "nothing-changed")[1], "s")
good: 2 layers, 4s
bad: 2 layers, 46s
cached: 0 s
Building and running it
Two commands cover almost everything you will do at this level.
$ docker build -t tidyup:0.1.0 .
[+] Building 38.2s (12/12) FINISHED
=> => naming to docker.io/library/tidyup:0.1.0
$ docker run --rm -v ~/Downloads:/data tidyup:0.1.0
sorted 84 files into 6 dated folders
$ docker build -t tidyup:0.1.1 . # after editing one line of src/
[+] Building 4.1s (12/12) FINISHED
=> CACHED [3/7] RUN pip install --no-cache-dir .
$ docker images tidyup
REPOSITORY TAG SIZE
tidyup 0.1.1 148MB
What Docker does not solve
Containers are genuinely useful and routinely oversold. An honest list of what stays your problem:
- Your bugs. Broken code is now reproducibly broken everywhere.
- Storing data. A container's filesystem disappears when it stops. Anything you want to keep goes in a mounted folder or a real database.
- Secrets. Bake an API key into an image and it is in the image forever, readable by anyone who pulls it. Pass secrets in at run time.
- Development comfort. Rebuilding to test a one-line change is slower than a virtual environment. Most people develop locally and containerise for deployment.
- Being lightweight in absolute terms. A 150 MB image is small for a container and enormous for a script.
The honest rule: containerise when your project must run somewhere that is not your laptop, or when the setup needs more than pip install. For a script you run yourself, a virtual environment is still the right answer.
Common mistake: COPY . . before installing dependencies
Copying everything first reads as the simpler order.
It invalidates the cache on every code edit, so pip reinstalls everything each build. Copy the dependency file and install, then copy the source.
Common mistake: Confusing RUN with CMD
Both look like commands in a list of commands.
RUN executes while building the image; CMD is what runs when a container starts. Putting your program in RUN runs it once at build time and never again.
Common mistake: Expecting files written inside a container to persist
It behaves like a normal filesystem right up until the container stops.
Everything not in a mounted volume is discarded. Mount a folder with -v for anything that must outlive the run.
Common mistake: Using python:latest as the base image
Latest sounds like the safe, current choice.
It changes without warning, so a build that worked yesterday can break today. Pin an exact version, such as python:3.12-slim.
Common mistake: Running everything as root inside the container
It is the default and nothing complains.
Add a user with useradd and switch to it with USER. Two lines, and a compromised process is no longer root.
What is the difference between an image and a container?
One image, many containers — the same relationship a class has to its objects.
Why copy pyproject.toml and install before copying the source code?
Docker reuses layers up to the first change. Putting the rarely-changing step first keeps rebuilds to seconds.
What does a container add over a virtual environment?
A virtual environment isolates Python packages only. A container captures the whole environment underneath them.
Where should an API key live for a containerised app?
Anything in the image is in the image permanently and is readable by anyone who can pull it.
Mini exercise (hard)
Write the review you would give a colleague's Dockerfile. review(lines) returns a sorted list of warnings for the four problems this lesson named: a FROM line using :latest or with no tag at all, a COPY . . appearing before any RUN pip install, no USER instruction anywhere, and no CMD. A clean file returns an empty list.
Your turn. Fill in the code below and press Run to test it right here, nothing to install.
BAD = """FROM python:latest
WORKDIR /app
COPY . .
RUN pip install .
"""
GOOD = """FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
RUN pip install .
COPY src/ ./src/
RUN useradd --create-home runner
USER runner
CMD ["tidyup", "/data"]
"""
def review(lines):
"""Sorted warnings for a Dockerfile given as a list of lines."""
warnings = []
# TODO: FROM with :latest or with no tag at all
# TODO: a "COPY . ." appearing before the first "RUN pip install"
# TODO: no USER instruction anywhere
# TODO: no CMD instruction anywhere
return sorted(warnings)
for problem in review(BAD.strip().splitlines()):
print("bad: ", problem)
print("good:", review(GOOD.strip().splitlines()) or "no warnings")
One pass to record the index of the first COPY . . and the first RUN pip install, plus whether USER and CMD appeared at all. For the FROM check, split the image name on ":". One piece means no tag was given, and a second piece of "latest" is the other half of the same problem.
BAD = """FROM python:latest
WORKDIR /app
COPY . .
RUN pip install .
"""
GOOD = """FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
RUN pip install .
COPY src/ ./src/
RUN useradd --create-home runner
USER runner
CMD ["tidyup", "/data"]
"""
def review(lines):
"""Sorted warnings for a Dockerfile given as a list of lines."""
warnings = []
copy_all = install = None
has_user = has_cmd = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("FROM "):
image = stripped[5:].strip()
if ":" not in image:
warnings.append("FROM has no tag: pin an exact version")
elif image.split(":", 1)[1] == "latest":
warnings.append("FROM uses :latest: pin an exact version")
elif stripped == "COPY . ." and copy_all is None:
copy_all = index
elif stripped.startswith("RUN pip install") and install is None:
install = index
elif stripped.startswith("USER "):
has_user = True
elif stripped.startswith("CMD "):
has_cmd = True
if copy_all is not None and install is not None and copy_all < install:
warnings.append("COPY . . before pip install: every code edit rebuilds the deps")
if not has_user:
warnings.append("no USER: the container runs as root")
if not has_cmd:
warnings.append("no CMD: nothing runs when the container starts")
return sorted(warnings)
for problem in review(BAD.strip().splitlines()):
print("bad: ", problem)
print("good:", review(GOOD.strip().splitlines()) or "no warnings")
bad: COPY . . before pip install: every code edit rebuilds the deps
bad: FROM uses :latest: pin an exact version
bad: no CMD: nothing runs when the container starts
bad: no USER: the container runs as root
good: no warnings
assert review(GOOD.strip().splitlines()) == [], "the good Dockerfile should raise nothing"
assert len(review(BAD.strip().splitlines())) == 4, "the bad one breaks all four rules"
assert any("no tag" in w for w in review(["FROM python", "USER x", "CMD [\"x\"]"])), "an image with no tag is as bad as :latest"
assert review(["FROM python:3.12-slim", "RUN pip install .", "COPY . .", "USER x", "CMD [\"x\"]"]) == [], "COPY . . AFTER the install is the correct order"
assert any("USER" in w for w in review(["FROM python:3.12-slim", "CMD [\"x\"]"])), "a missing USER must be reported"
print("✓ Looks good!")