In this guide
"It works on my machine" is the sentence that ends more project demos than any bug. Your online code editor with live preview runs perfectly on your laptop — Node 20, a specific Postgres version, environment variables you set six months ago and forgot — and then the evaluator opens it on their PC and gets a blank page and a stack trace. Docker exists to kill this failure mode: you package your application with its exact environment into an image, and it runs identically everywhere that Docker runs.
This guide takes you from zero to a containerized student project: what images and containers actually are, writing a Dockerfile that is not bloated, persisting data with volumes, orchestrating app-plus-database with Compose, and the submission checklist that makes an evaluator's life easy. Every example is tested against the Docker behaviour you will actually meet; the only prerequisites are a project that runs and a willingness to read error messages.
The mental model: images, containers, and why they fix "works on my machine"
- Image — a read-only template: your code plus the OS libraries, runtime, and dependencies it needs, stacked in layers. Think of it as a sealed box with everything installed.
- Container — a running instance of an image. The box, opened and running. You can run many containers from one image, and deleting a container does not delete the image.
- Dockerfile — the recipe that builds the image: a text file of instructions (start FROM this base, COPY these files, RUN these installs, CMD this command).
- Volume — storage that survives container deletion. Containers are ephemeral by design; without a volume, your database evaporates when the container stops.
- Registry — where images live (Docker Hub by default).
docker pulldownloads;docker pushuploads.
The evaluator story: instead of "install Node 20.11, then Postgres 15, then set these twelve environment variables, then run these migrations in this order" (a README nobody follows correctly), you hand over a docker-compose.yml and say "run docker compose up". One command, identical environment, demo saved.
Your first Dockerfile, explained line by line
A minimal Dockerfile for a Python Flask project:
# Start from the official Python image, slim variant (smaller, fewer surprises)
FROM python:3.12-slim
# Set the working directory inside the image; all paths below are relative to it
WORKDIR /app
# Copy dependency list FIRST, then install — this exploits layer caching
# (explained below): rebuilding after a code change skips the slow pip install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Now copy the application code
COPY . .
# Document which port the app listens on (informational; you still publish it at run time)
EXPOSE 5000
# The command that runs when the container starts
CMD ["python", "app.py"]
Build and run:
docker build -t myproject:latest .
docker run -d --name myproject -p 8080:5000 myproject:latest
What just happened: docker build executed each Dockerfile instruction, stacking read-only layers into the image myproject:latest. docker run started a container from it, detached (-d), named myproject, publishing the container's port 5000 on your machine's port 8080 (-p 8080:5000 — host:container). Open http://localhost:8080 and your app is there.
Layer caching — the thing that makes Docker fast or slow: each Dockerfile instruction creates a layer, and Docker reuses cached layers when nothing above them changed. That is why requirements.txt is copied and installed before the code: editing app.py only invalidates the layers from COPY . . downward, so rebuilds take seconds instead of re-running pip install. Put the things that change least (base image, system packages, dependencies) earliest, and the things that change most (your code) last. This single habit separates pleasant Docker workflows from miserable ones.
.dockerignore: don't ship your junk
A .dockerignore file (same syntax as .gitignore) keeps build context lean — faster builds, smaller images, no leaked secrets:
__pycache__/
*.pyc
.git/
.env
node_modules/
*.md
The .env line matters: environment files with API keys and database passwords must never be baked into an image, especially one you push to a registry. Secrets go in at run time (see the Compose section), never in the image.
Volumes and bind mounts: where data lives
Containers are ephemeral: docker rm deletes everything the container wrote outside a volume. Two persistence mechanisms:
- Named volume — Docker-managed storage, ideal for databases:
docker volume create pgdata, then-v pgdata:/var/lib/postgresql/data. Survives container replacement; the right choice for Postgres/MySQL data. - Bind mount — maps a host directory into the container:
-v ./uploads:/app/uploads. Ideal during development (edit code on the host, container sees it immediately) and for getting files in and out.
The classic student data-loss story: developing an online examination system with Postgres in a container, no volume, then running docker compose down (which removes containers) and discovering the entire question bank is gone. The fix is one line in the Compose file — but only if it is there before the data matters.
Docker Compose: app plus database in one file
Real student projects are rarely one container — a web app plus Postgres, or an API plus Redis. Compose orchestrates them from one YAML file. Here is a complete, working example for a Flask + Postgres project:
services:
web:
build: .
ports:
- "8080:5000"
environment:
- DATABASE_URL=postgresql://appuser:YOUR_DB_PASSWORD@db:5432/appdb
- FLASK_ENV=production
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:15
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=apppass
- POSTGRES_DB=appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Run it with docker compose up --build (builds the web image, starts Postgres, waits for it to be healthy, then starts the app). Stop with docker compose down; stop without deleting the database with docker compose stop — the distinction that saves question banks.
Three things to notice:
- Service names are hostnames. The app reaches Postgres at
db:5432— Docker's internal DNS resolves the service name. Never hardcodelocalhostfor inter-container communication;localhostinside the web container means the web container itself. depends_onwithservice_healthyis the correct way to order startup. Plaindepends_ononly waits for the container to exist, not for Postgres to accept connections — the classic race that makes first boots flaky. The healthcheck closes it properly.- Secrets via
environmenthere are demonstration-grade. For a submitted student project this is acceptable and standard; for anything real, use an env file (env_file: .env) that is gitignored, never committed.
Multi-stage builds: stop shipping 1 GB images
A naive Node or Python image easily exceeds 1 GB because it contains compilers, build tools, and dev dependencies the running app never needs. Multi-stage builds compile in one stage and ship only the result:
# ---- build stage: full toolchain ----
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---- runtime stage: only what's needed ----
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
The final image contains the installed packages but not pip's cache, not the build tools, not the intermediate layers. For Node projects the same pattern separates npm ci (with devDependencies for building) from a runtime stage that copies only node_modules production output. Smaller images push/pull faster — which matters when the evaluator's internet is a conference-hall Wi-Fi.
Base image discipline: prefer official -slim (Python) or -alpine variants, and pin versions (python:3.12-slim, not python:latest). latest is a moving target: your build works today and breaks in six months when latest moves. Pin everything you can — base image tags, package versions in requirements — because reproducibility is the entire point.
Debugging: the errors you will definitely meet
| Error | What it means | Fix |
|---|---|---|
port is already allocated |
Something on your machine already uses that host port | Change the host side (-p 8081:5000), or stop the other process/container |
cannot connect to the Docker daemon |
The Docker engine is not running | Start Docker Desktop (Windows/Mac) or sudo systemctl start docker (Linux) |
permission denied on a mounted file |
Host file owned by a different UID than the container user | Match UIDs, or run the container with --user $(id -u):$(id -g) |
COPY failed: file not found |
Build context does not contain the file (wrong directory or .dockerignore) | Run docker build from the directory containing the Dockerfile; check .dockerignore |
App can't reach db:5432 / connection refused |
Container tried localhost, or raced Postgres startup |
Use the service name as hostname; add the healthcheck-gated depends_on |
exec format error |
Image built for a different CPU architecture (ARM Mac vs x86 evaluator PC) | Build multi-arch (docker buildx build --platform linux/amd64,linux/arm64) or target the evaluator's arch |
Container exits immediately (Exited (1)) |
The CMD crashed — read the logs | docker logs <name> first, always; add --rm during iteration so dead containers don't pile up |
no space left on device during build |
Docker's disk usage grew (old images, build cache) | docker system prune (careful: deletes unused data); docker builder prune for build cache |
The debugging order that never fails:
docker logs→docker ps -a(is it even running?) →docker exec -it <name> sh(get inside and look around) → reproduce the failing command manually inside the container. Nine out of ten Docker problems are application problems wearing a container costume — the logs say so if you read them.
Networking: the minimum you need
- Inside Compose, services talk by name (
http://api:8000,db:5432). No IPs, no discovery config. - From your machine, use published ports (
-p 8080:5000→http://localhost:8080). - From one Compose project to another, use a shared external network — rare in student work; skip until you need it.
- Container → host (e.g. reaching a database running on your laptop, not in Docker):
host.docker.internalon Docker Desktop.
The single most common networking bug in student submissions: the app binds to 127.0.0.1 inside the container. Inside a container, 127.0.0.1 is the container itself — nothing outside can reach it, including Docker's port publishing. Bind to 0.0.0.0 in containerized apps (Flask: app.run(host="0.0.0.0"); Node: listen on 0.0.0.0). This one line has rescued more demos than any other.
A complete worked example: containerizing a project
Take a typical final-year web project — say a website uptime monitor with an alert dashboard: a Python backend that polls URLs, stores results in Postgres, and serves a dashboard. The containerization checklist:
- Dockerfile for the backend (the Python pattern above), binding to
0.0.0.0, readingDATABASE_URLfrom the environment. - Compose file with
web+dbservices, named volume for Postgres, healthcheck-gated startup. .dockerignoreexcluding.git,.env,__pycache__, docs.- README section — the only documentation the evaluator reads:
- Prerequisites: "Install Docker Desktop"
- One command:
docker compose up --build - "Open http://localhost:8080"
- How to stop:
docker compose stop; how to reset demo data:docker compose down -v(with a warning that-vdeletes the database volume)
- Smoke test on a clean machine — or as close as you can get:
docker compose down -v, delete the built image, rebuild from scratch, and follow your own README verbatim. If you cannot follow it, neither can the evaluator.
Verify the running stack with curl from the host:
Check the two endpoints from your host machine's browser or with curl: /api/health should return status ok, and /api/monitors should return the monitor list as JSON.
If those return sane JSON, the containerized project is demonstrably working — screenshot the terminal for the report.
Pushing to Docker Hub (for submission or portfolio)
docker login
docker tag myproject:latest mydockerhubuser/myproject:v1
docker push mydockerhubuser/myproject:v1
The evaluator then needs only the Compose file pointing at your image (image: mydockerhubuser/myproject:v1 instead of build: .) — no build step, no source needed. Tag versions (v1, not just latest) so the demo image is frozen: if you keep pushing to latest, the image the evaluator pulls next month may not be the one you demoed.
Docker vs virtual machines vs "just install it"
| Approach | Isolation | Size | Startup | Evaluator effort |
|---|---|---|---|---|
| Native install (README instructions) | None — conflicts with their setup | — | — | High: version mismatches guaranteed |
| Virtual machine | Full OS | GBs (whole guest OS) | Minutes | Medium: import appliance, still heavy |
| Docker containers | Process-level, shared kernel | MBs–low GBs | Seconds | Low: one command with Compose |
Docker is the sweet spot for project submission: lighter than a VM, infinitely more reliable than a README of install steps. The one case for a VM: your project needs a full GUI desktop or kernel modules Docker cannot provide — rare for web/backend student projects.
Resource limits and hygiene
- Limit what a runaway container can eat:
deploy.resources.limitsin Compose (or--memory=512m --cpus=1ondocker run) stops a memory-leaking app from freezing the evaluator's laptop. - Name your containers and volumes (
--name, named volumes) sodocker psoutput is readable during the demo. - Clean up regularly:
docker system dfshows disk usage;docker system prunereclaims it. Stale images are the usual culprit when Docker "suddenly" fills a disk. - Never
docker compose down -von data you need. The-vflag deletes named volumes. It is the single most destructive routine command in the student Docker vocabulary — type it only when you mean it.
Submission checklist: the evaluator-proof package
-
Dockerfilebuilds cleanly from scratch (docker build --no-cachesucceeds). -
docker-compose.ymlbrings the whole stack up with one command. - App binds to
0.0.0.0, not127.0.0.1. - Database uses a named volume; seed/demo data documented.
- No secrets baked into the image (
.dockerignorecovers.env; no API keys in the Dockerfile). - Base images and key dependencies pinned (no bare
latest). - README has a Docker section: prerequisite, one run command, URL to open, how to stop/reset.
- Full cycle tested:
down -v→ rebuild → follow README verbatim → curl health check passes. - Image tagged and pushed if the submission expects it (
v1tag, not justlatest). -
.dockerignorepresent; build context small (check withdocker buildoutput — it prints the context size).
Putting it together
Docker turns "it works on my machine" from a punchline into a non-issue: the image is the machine, sealed and shippable. Learn the five concepts (image, container, Dockerfile, volume, Compose), respect layer caching, persist your data, bind to 0.0.0.0, and test the full rebuild cycle before submission. Do that and your project runs on the evaluator's PC exactly as it runs on yours — which, on demo day, is worth more than any feature. For keeping the project itself organized alongside its containers, the Git and GitHub guide covers version control the way teams actually use it.
The Node.js variant: same ideas, different runtime
Most student web projects are Node/Express or Python/Flask; the Docker patterns transfer directly. A production-sensible Node Dockerfile:
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Notes for Node specifically: npm ci (not npm install) for reproducible builds from the lockfile — npm install can resolve different versions on different days, which is exactly the non-determinism Docker is supposed to eliminate. The --omit=dev flag keeps devDependencies (testing frameworks, bundlers) out of the runtime image. And the same 0.0.0.0 binding rule applies: Express listens on all interfaces only if you tell it to (app.listen(3000, "0.0.0.0")).
Healthchecks for your own app, not just the database
The Compose example earlier health-checked Postgres. Your app deserves one too — it lets orchestrators (and evaluators running docker ps) see at a glance whether the app is actually serving:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
This requires a /api/health endpoint in your app — a trivial route returning {"status": "ok"} and, if you are thorough, a database connectivity check. The start_period gives the app time to boot before failures count. A health endpoint is also the first thing you curl when the demo misbehaves: if /api/health answers, the problem is in your routes or frontend; if it does not, the problem is the container or the database. Either way you have cut the diagnosis space in half in five seconds.
One Compose file, two environments: dev vs demo overrides
During development you want live code reloading and debug ports; for the demo you want a sealed, production-like stack. Compose override files give you both without duplicating the whole file:
docker-compose.yml (base — the demo configuration):
The base file defines a web service built from your Dockerfile, mapping host port 8080 to container port 5000, reading the database URL from the environment, and waiting for the db service to pass its healthcheck before starting.
docker-compose.override.yml (dev-only tweaks — Compose merges it automatically):
The override file adds a bind mount (your ./src directory mapped into the container for live reloading), sets Flask to development mode with the debugger enabled, and exposes the debugger port — all merged automatically when you run docker compose up.
Run docker compose up for development (override applied automatically) and docker compose -f docker-compose.yml up for the clean demo configuration. The bind mount in the override means code edits on your laptop reflect instantly inside the container — no rebuild per change — while the base file stays rebuild-clean for evaluation. Keep dev-only ports (debuggers, profilers) out of the base file; an evaluator probing your demo should see exactly the ports the README documents, nothing more.
One final habit separates teams that enjoy Docker from teams that fight it: rebuild from scratch regularly during development, not just before submission. Stale layers hide broken installs — a pip install that worked three weeks ago against a package version that has since been yanked will fail on the evaluator's machine exactly when you cannot fix it. Rebuilding from a clean slate every week catches these problems early, while you still have time to lock down the exact versions.