Containers in Plain English (and a First Dockerfile)

Your project plus its setup instructions, in a box

Advanced 14 min

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.

Example · Dockerfile
# 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"]
A complete Dockerfile for the package from the packaging lesson.

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")

Building and running it

Two commands cover almost everything you will do at this level.

Example
$ 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
Build, run, then rebuild: note the 38 seconds becoming 4.

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

Why it happens:

Copying everything first reads as the simpler order.

How to fix it:

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

Why it happens:

Both look like commands in a list of commands.

How to fix it:

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

Why it happens:

It behaves like a normal filesystem right up until the container stops.

How to fix it:

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

Why it happens:

Latest sounds like the safe, current choice.

How to fix it:

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

Why it happens:

It is the default and nothing complains.

How to fix it:

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?

Why copy pyproject.toml and install before copying the source code?

What does a container add over a virtual environment?

Where should an API key live for a containerised app?

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")

What to learn next

You can read a Dockerfile now, and write a reasonable one: an image is the recipe and a container is one running instance, FROM pinned to an exact version, COPY ordered so a code edit does not rebuild every dependency, a non-root USER, and CMD for what runs on start rather than on build. You also have an honest list of what containers do not fix.

Your project is tested, packaged, checked and portable. The remaining question is how anyone finds out it exists. Your Portfolio, and How to Ask a Question That Gets Answered covers both directions of that.