Developer tooling · practical guide

Docker for CS coursework: reproducible environments without falling down the rabbit hole

Most of the time a student loses to "it works on my machine" is not spent on the assignment. A container is a way to stop paying that tax — if you keep it small.

A recurring pattern in tutoring: a student's code is correct and their environment is not. The submission fails on the grader's machine, or the group project runs for two people and not the third, or a library that worked in September stops working in November because something else upgraded underneath it.

Containers solve a specific slice of that problem well. They also invite an enormous detour into orchestration, multi-stage builds, and image-size golf that has nothing to do with finishing an assignment. This is the small version: what is worth learning as a student, and where to stop.

What a container actually gives you

A container is a process on your machine running against a packaged filesystem and a fixed set of installed software. It is not a virtual machine — it shares your kernel, so it starts in under a second rather than a minute.

What you get that matters for coursework:

  • A pinned toolchain. Python 3.11 stays 3.11, whatever your laptop's system Python does.
  • An install that cannot break other projects. No shared global site-packages to corrupt.
  • A written-down environment. The Dockerfile is the setup documentation, and unlike a README it fails loudly when it is wrong.
  • A disposable machine. Deleting a container is free, so you can experiment destructively.
Where a container is not the answer: a single-file Python script with no third-party imports, anything requiring GPU access you have not already set up, and GUI applications. For a pure-Python project with a handful of dependencies, a virtual environment is lighter and entirely sufficient. Reach for Docker when a system-level dependency is involved — a database, a specific compiler version, a native library that needs apt.

A minimal Python setup

Three files. That is the whole thing.

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Copy the dependency list first, on its own. Docker caches each layer,
# so this install is only re-run when requirements.txt itself changes,
# not every time you edit a source file.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "main.py"]

The ordering in that file is the one non-obvious thing, and it is worth understanding rather than copying. Docker builds an image as a stack of layers, and it reuses a cached layer whenever that step's inputs have not changed. If you write COPY . . before the pip install, then editing any source file invalidates the copy layer and every layer after it — meaning you reinstall every dependency on every single edit. Copying requirements.txt by itself first means the expensive install layer survives your edits.

Second file, .dockerignore, which does more work than it looks like:

.git
__pycache__/
*.pyc
.venv/
venv/
node_modules/
data/raw/
.env

Without this, COPY . . pulls your entire .git history and any local virtual environment into the image. That is slow, it bloats the image, and — the part that actually bites — copying a .venv built on your host OS into a Linux container produces broken binaries and a confusing error much later.

Third, run it with your source directory mounted in:

docker build -t coursework .
docker run --rm -it -v "$(pwd)":/app coursework

-v "$(pwd)":/app is a bind mount: it maps your working directory over /app inside the container. Edit a file in your normal editor and the change is visible to the container immediately, with no rebuild. Without this you rebuild the image after every keystroke, which is the single most common reason people try Docker once and conclude it is unusably slow.

--rm deletes the container when it exits. Without it you accumulate hundreds of stopped containers, which is harmless but eventually confusing when you run docker ps -a.

A C++ variant

For a course that pins a compiler version, the same shape applies:

FROM gcc:13

WORKDIR /app
COPY . .

RUN g++ -std=c++20 -Wall -Wextra -g -o program main.cpp

CMD ["./program"]

This is where containers earn their place in coursework more clearly than in Python. "Compiles with the exact compiler and flags the grader uses" is a real source of lost marks, and it is entirely mechanical to eliminate. If your course specifies GCC 13 and -std=c++20, encoding that in a Dockerfile means you cannot accidentally develop against something else.

Adding a database without installing one

The point at which Docker stops being a convenience and starts being clearly worth it is the first assignment that needs Postgres. Installing and configuring a database system-wide, for one class, is a genuine time sink and leaves a service running on your laptop forever.

# docker-compose.yml
services:
  app:
    build: .
    volumes:
      - .:/app
    depends_on:
      - db
    environment:
      DATABASE_URL: postgresql://student:devpassword@db:5432/coursework

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: student
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: coursework
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

docker compose up starts both. Two details worth knowing:

The hostname is the service name. From inside the app container, the database is reachable at db, not localhost — Compose puts both on a network where service names resolve. Using localhost here is the most common first error, and the resulting "connection refused" is unhelpfully generic.

The named volume pgdata is what makes your data survive docker compose down. Without it, every restart gives you an empty database, which is fine until it is three hours before a deadline and you have lost your seeded test data.

That password is a throwaway for a local development database. Real credentials belong in environment variables loaded from a file that is in .gitignore — never committed, and never baked into an image layer, where they remain readable in the image history even if a later layer deletes the file. I have written more on that failure mode in the context of keeping private client data out of hosted services.

The mistakes that cost an evening

  • Rebuilding instead of bind-mounting. If you run docker build after every edit, something is wrong with your setup, not with Docker.
  • Editing files inside the container. Changes made in a container that is not bind-mounted vanish when it exits. Edit on the host; let the mount carry it in.
  • Using latest tags. FROM python:latest means your environment silently changes over the semester, which defeats the entire purpose. Pin the minor version.
  • Ignoring build context size. If docker build prints a "sending build context" figure in the hundreds of megabytes, your .dockerignore is missing something.
  • Root-owned output files. On Linux, files a container writes into a bind mount are owned by root, and you cannot delete them without sudo. Pass --user "$(id -u):$(id -g)" to docker run to avoid it.
  • Treating a container as persistent. Containers are meant to be thrown away. Anything you need to keep belongs in a mounted directory or a named volume.

Where to stop

For coursework, you need build, run, -v, --rm, exec, and possibly a six-line Compose file. That is the whole useful surface.

Multi-stage builds, image-size optimisation, health checks, and orchestration are real skills with real payoffs in production, and none of them help you submit an assignment. Learn them when you have a deployment that needs them. Until then, the correct amount of Docker knowledge is the amount that makes the environment stop being a variable — and then back to the actual problem, which is the code.

If you are working through a systematic approach to debugging more generally, I wrote up the method I use with students in the CS tutoring guide.