π How to use this guide
This is a revision document, not a video transcript. It works best if you go through it three times, with your terminal open beside it.
Every command block has a Copy button. Every command block is followed by a decoder that explains each flag β don't skip those, they're where the marks are.
The Dabbawala Analogy + Mac Setup
What you'll get: a rock-solid mental model of Docker before you write a single command,
plus a working setup on your Mac. By the end of Port 0 you should have run hello-world and be able
to explain what actually happened.
Why Docker exists at all
You've said it, or you will: "But it works on my machine!" The code crashes on your teammate's laptop, crashes on staging, and let's not talk about production.
Mumbai's dabbawalas deliver 200,000 lunch boxes every single day with Six Sigma accuracy β without an app. How? They standardised the packaging and the delivery system. It doesn't matter where a tiffin comes from or where it's going; the system is identical. Docker is the dabbawala system for software.
In this guide you'll dockerize three real projects β a classic ML model, an LLM-powered RAG app, and a multi-agent system. Same skill, three difficulty levels.
The Master Analogy β The Tiffin System π±
Come back to this table every time a new term confuses you. If you memorise one thing from the whole session, memorise this:
| Docker Concept | Tiffin World | One-line meaning |
|---|---|---|
| Dockerfile | Recipe card π | Step-by-step written instructions for how to prepare the meal. |
| Image | Master tiffin (sealed, ready) π± | The packed box produced from the recipe β frozen in time, ready to ship. |
| Container | One delivered tiffin π | A running copy of the image. One image can produce a hundred tiffins. |
| Docker Hub | Central kitchen / warehouse π | Where ready-made boxes for every recipe live β Python's, Ubuntu's, Redis's. |
| Port mapping | The building's gate number πͺ | The box lives in flat 8000 inside the building, but deliveries come through gate 8000 β
-p 8000:8000.
|
| Volume | The steel box that comes back β»οΈ | Delete the container, the data survives β like the reusable steel dabba. |
| docker compose | Ordering a full thali π½οΈ | One order gets you dal, rice, sabzi, roti β all containers together. |
β The most important rule in this entire guide
"An image is a photograph, not a mirror." When you build an image, Docker takes a photo of your code at that moment. If you edit your code afterwards, the photo does NOT update by itself β you must take a new photo (rebuild). Forgetting this causes 90% of beginner confusion, so it's repeated throughout.
πΌοΈ VM vs Docker β the one diagram to hold in your head
Picture two kinds of housing:
- Virtual Machine = a standalone bungalow. Every app gets an entire house: its own kitchen, bathroom, and security guard (a full operating system). Heavy, slow to start, expensive.
- Docker = apartment flats. One building (the host operating system's core) is shared, but every flat (container) has its own lock, its own belongings, its own privacy. Lightweight, starts in seconds.
The punchline: a VM takes minutes to boot; a container takes milliseconds. That's why at Swiggy/Zomato scale you don't run VMs β you run containers. Traffic spike? Open 50 new flats in seconds.
π» Mac setup β do this first
Open Terminal (press Cmd + Space, type "Terminal", press Enter). If the terminal is new
to you: it's just a way to talk to your computer with text instead of clicks. You type a command, press Enter,
the computer replies.
brew install --cask docker docker --version docker compose version docker run hello-world
- brew
- Homebrew β the "app store for your terminal" on Mac. It downloads and installs software for you. No Homebrew? Download the .dmg from docker.com/products/docker-desktop instead.
- --cask
- Tells brew this is a full desktop application (with an icon), not just a command-line tool.
- docker --version
- Asks Docker "which version are you?" If it answers, Docker is installed correctly.
- docker compose version
- Checks the second tool you'll use β Compose β which manages multiple containers at once. It comes bundled with Docker Desktop.
- docker run hello-world
- "Run a container from the image called hello-world." Docker looks for it on your Mac, doesn't find it, downloads it from Docker Hub, and runs it. Your first tiffin, delivered from the central kitchen.
β οΈ The step most people miss: after installing, open the Docker Desktop app once (Cmd+Space β "Docker" β Enter). A whale π³ icon appears in the menu bar at the top of the screen. Docker commands only work while that whale is there β the app runs the Docker "engine" in the background. No whale = every command fails with "Cannot connect to the Docker daemon". ("Daemon" is just an old Unix word for a background program.)
When hello-world prints "Hello from Docker!" β you have just pulled an image from Docker
Hub and turned it into a running container. First tiffin delivered. π
Two Mac traps to know before they hit you
Trap 1: Apple Silicon chips speak a different language
M-series Macs (M1/M2/M3/M4) use the ARM64 chip architecture; most cloud servers use AMD64 (x86). Think of it as two languages: an image "written in AMD64" may not run on an ARM Mac. Sometimes an image downloads fine but refuses to start, or prints a platform warning.
docker run --platform linux/amd64 <image-name>
- --platform linux/amd64
- "Pretend to be an AMD64 machine." Your Mac translates on the fly (via Rosetta). Like watching a dubbed movie β slightly slower, but it works.
Trap 2: zsh and the stuck quote> prompt
The Mac terminal uses a shell called zsh. If you paste a command that has a # comment at
the end AND that comment contains an apostrophe (like you'll), zsh gets confused and shows
quote>, waiting forever. Fix: press Ctrl+C and re-type the command without the comment.
Remember this one β it saves ten minutes the first time it happens.
β Self-check β Port 0
Dockerize an ML Project β "QuickBite ETA" π΅
What you'll build: a sklearn model (a food-delivery ETA predictor) served via FastAPI, packed into a container. This is where the core fundamentals live: Dockerfile anatomy, layers, caching, port mapping, .dockerignore.
The situation you're solving
Imagine you're an ML engineer at a Zomato-style startup. You've built a model that predicts how many minutes until an order arrives β distance, restaurant prep time, rider availability, rain as inputs; ETA as output.
The model runs beautifully on your laptop. Then DevOps says: "Ship it to the server." The server has Python 3.9; you have 3.12. Different sklearn version. Don't even ask about NumPy. Welcome to dependency hell π₯ β and Docker is the air conditioning.
π Project structure
Build the skeleton first:
mkdir quickbite-eta && cd quickbite-eta touch train.py app.py requirements.txt Dockerfile .dockerignore
- mkdir
- "Make directory" β creates a new folder.
- &&
- "Then" β run the next command only if the first one succeeded.
- cd
- "Change directory" β step inside that folder.
- touch
- Creates empty files with these names. You'll fill them in next.
scikit-learn==1.5.2 pandas==2.2.3 fastapi==0.115.6 uvicorn==0.34.0 joblib==1.4.2
== is like writing "1 cup of rice" in a recipe
instead of "some rice" β you get the same dish every time.
π€ train.py β a 60-second model
import pandas as pd, numpy as np, joblib from sklearn.ensemble import RandomForestRegressor np.random.seed(42) n = 5000 df = pd.DataFrame({ "distance_km": np.random.uniform(0.5, 12, n), "prep_time_min": np.random.uniform(5, 30, n), "rider_available": np.random.randint(0, 2, n), "is_raining": np.random.randint(0, 2, n), }) # ETA = base + distance*3 + prep + rain penalty + rider penalty + noise df["eta_min"] = (8 + df.distance_km*3 + df.prep_time_min*0.7 + df.is_raining*9 + (1-df.rider_available)*6 + np.random.normal(0, 2, n)) X, y = df.drop(columns=["eta_min"]), df["eta_min"] model = RandomForestRegressor(n_estimators=60, random_state=42).fit(X, y) joblib.dump(model, "eta_model.pkl") print("Model saved: eta_model.pkl β ")
joblib.dump saves the trained brain into a single file, eta_model.pkl, so the API
can load it later without retraining. The seed(42) line means "use the same randomness every
time" so your results match everyone else's.
π app.py β FastAPI serving
from fastapi import FastAPI from pydantic import BaseModel import joblib, pandas as pd app = FastAPI(title="QuickBite ETA") model = joblib.load("eta_model.pkl") class Order(BaseModel): distance_km: float prep_time_min: float rider_available: int is_raining: int @app.get("/") def health(): return {"status": "QuickBite ETA is live π΅"} @app.post("/predict") def predict(order: Order): X = pd.DataFrame([order.model_dump()]) eta = round(float(model.predict(X)[0]), 1) return {"eta_minutes": eta, "message": f"Your food arrives in {eta} min π"}
/predict, run this Python
function." The Order class is the order form: it declares exactly which fields a request must
contain and their types β send text where a number belongs and FastAPI politely rejects it for free. At
startup you load the saved model brain from eta_model.pkl once; then every request is: read form
β ask model β return the answer as JSON (the universal "key: value" text format APIs speak). The
/ route is a health check β a doorbell to confirm the shop is open.
Dockerfile anatomy β learning to read the recipe
This is the most important block in the guide. A Dockerfile is a plain text file (no extension!) with instructions Docker executes top to bottom to produce an image. Map every line to the tiffin analogy:
# 1. Base image = rent a ready-made kitchen (from Docker Hub) FROM python:3.12-slim # 2. Set up your counter inside that kitchen WORKDIR /app # 3. Copy ONLY the shopping list first (caching trick β explained below) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 4. NOW copy the rest of your code COPY . . # 5. Train the model INSIDE the image (baked in at build time) RUN python train.py # 6. Declare which window the food comes out of (documentation) EXPOSE 8000 # 7. What runs the moment the tiffin is opened CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
- FROM python:3.12-slim
- Don't start from an empty computer β start from a ready-made image that already has Linux + Python 3.12 installed. "slim" = the lightweight version. Docker downloads it from Docker Hub automatically.
- WORKDIR /app
- "From now on, work inside the folder /app" (inside the container). Creates it if missing. Like choosing which counter you'll cook on.
- COPY requirements.txt .
- Copy one file from your Mac into the image. The
.means "into the current folder" (which is /app because of WORKDIR). - RUN pip install ...
- RUN executes a command while building the image. pip is Python's package installer;
-r requirements.txtmeans "install everything on this list".--no-cache-dirtells pip not to keep downloaded files around β smaller image. - COPY . .
- "Copy everything in the current folder on my Mac β into /app in the image." (Everything except what .dockerignore excludes.)
- RUN python train.py
- Trains the model during the build, so the finished image already contains eta_model.pkl. The container never needs to train anything.
- EXPOSE 8000
- A label saying "this app listens on port 8000". It's documentation β it doesn't open anything by itself
(that's
-p's job later). - CMD [...]
- The one command that runs when a container starts. Here: start the uvicorn web server, serving the
appobject fromapp.py.--host 0.0.0.0means "accept connections from outside the container, not just from inside" β without it, port mapping silently fails. A classic gotcha.
π The caching trick β why requirements.txt goes first
Picture a stack of layers (like a stack of parathas π«). Docker turns each instruction into a layer and caches it. When you rebuild, Docker checks each layer top-down: "did anything this layer depends on change?" If not, it reuses the cached layer instantly. But the moment one layer changes, every layer below it must rebuild too.
Now the logic clicks: if you did COPY . . first and installed dependencies after, then every
tiny code edit would invalidate the copy layer β and force the slow 2-minute pip install to re-run below
it. By copying only requirements.txt first, the pip layer only rebuilds when the shopping list
itself changes.
The rule to remember: if the shopping list hasn't changed, why go back to the store? Code changes daily; dependencies change monthly. Rarely-changing things at the top, frequently-changing things at the bottom. This one trick makes builds 10x faster.
π .dockerignore
__pycache__/ *.pyc venv/ .venv/ .git/ .env *.ipynb eta_model.pkl data/raw/
COPY . ., it grabs everything in
the folder β unless it's listed here. Same idea as .gitignore. You exclude: Python's junk cache files, virtual
environments (can be 500MB!), git history, secrets (.env), and notebooks. You also exclude
eta_model.pkl β if you ever trained locally, you do NOT want that stale local file copied in; the
image trains its own fresh copy in step 5. "Only the food goes into the tiffin β not your diary and house
keys."
Milestone #1 β Build, run, predict
docker build -t quickbite-eta:v1 . docker images docker run -d -p 8000:8000 --name eta-service quickbite-eta:v1 curl -X POST http://localhost:8000/predict \ -H "Content-Type: application/json" \ -d '{"distance_km": 4.5, "prep_time_min": 15, "rider_available": 1, "is_raining": 1}'
- docker build
- "Follow the Dockerfile recipe and produce an image."
- -t quickbite-eta:v1
- "Tag" β give the image a name and a version label (name:version). Without it you get an unmemorable ID like a7f3c9.
- .
- The lonely dot means "the recipe and files are in THIS folder." Forgetting the dot is the #1 first-day error.
- docker images
- List all images (master tiffins) stored on your machine.
- docker run
- "Create and start a container from this image."
- -d
- "Detached" β run in the background and give my terminal back. Without -d, the logs take over your terminal until you press Ctrl+C (which also stops the container!).
- -p 8000:8000
- Port mapping, format host:container β "connect gate 8000 of my Mac to flat 8000 inside the container." Traffic to localhost:8000 gets forwarded inside.
- --name eta-service
- A friendly name so you can say "eta-service" in later commands instead of a random ID.
- curl
- A terminal tool for sending web requests β a browser without the window.
- -X POST
- The request type. GET = "give me something", POST = "here's data, process it."
- -H "Content-Type..."
- A header telling the server "the data I'm sending is JSON."
- -d '{...}'
- The data itself β one order, as JSON. The backslash
\at line ends just means "command continues on the next line."
The response comes back as {"eta_minutes": 41.2, ...}. Now open
http://localhost:8000/docs in your browser: FastAPI auto-generates a clickable "Swagger" page where you
can test the API with buttons instead of curl. Flip is_raining to 1 and watch the ETA climb. π§οΈ
Notice what you did NOT do on your Mac: no Python environment, no pip install, no version checking. Everything lives inside the box. And this exact box will run on AWS, on a Windows laptop, anywhere β identically.
π οΈ Peek inside the container + your everyday commands
docker ps docker ps -a docker logs -f eta-service docker exec -it eta-service bash docker stop eta-service docker rm eta-service
- docker ps
- List running containers only. Comes from "process status".
- docker ps -a
- List ALL containers, including dead/exited ones. β οΈ Memorise this: if a container crashed, plain
pshides it and you'll think it never existed.-a= "all". - docker logs -f eta-service
- Show everything the container has printed.
-f= "follow" β keep streaming new lines live (Ctrl+C to stop watching; the container keeps running). - docker exec -it ... bash
- "Execute a command inside a running container." The command here is
bashβ a shell β so you get a terminal INSIDE the box.-it= interactive + terminal, i.e. "let me type." Trylsandcat app.pyinside, thenexitto come back out. - docker stop / rm
- stop = pause the delivery (container still exists, restartable). rm = remove the stopped container entirely. The image is untouched β you can always run a fresh one.
docker exec is the moment it clicks: you are standing inside a tiny, separate Linux world living
inside your Mac.
The photograph rule in action β try this deliberately
Edit the message string in app.py, then restart the container. Nothing changes. Why? The
container runs the image β the photograph β and the photo was taken before your edit. The fix is always:
docker build -t quickbite-eta:v1 . docker rm -f eta-service docker run -d -p 8000:8000 --name eta-service quickbite-eta:v1
Rebuild β remove old container (rm -f = force-remove even if running) β run fresh. Losing ten
minutes to a stale image is a rite of passage. Do it once here on purpose and you'll never lose those ten
minutes again.
β Self-check β Port 1
Dockerize an LLM Project β "ScalerGPT" RAG bot π
What you'll build: a RAG chatbot (FastAPI + OpenAI API + ChromaDB). New concepts: secrets/env vars, docker compose, volumes, multi-container networking, startup readiness. This code is battle-tested β it includes fixes for two real bugs that show up every time.
What changes at level 2
This time the model isn't yours β it's OpenAI's. LLM projects bring three problems classic ML didn't have:
1οΈβ£ Secrets β bake your API key into the image and you've written your PIN on your ATM card.
2οΈβ£ Multiple services β an app plus a vector database. Two boxes, one order.
3οΈβ£ State β the database's data must survive even after its container dies.
The three solutions you'll learn here: .env files, docker compose, and volumes.
RAG in 60 seconds
RAG = Retrieval Augmented Generation β an open-book exam for the LLM. Instead of answering from memory (where it hallucinates), the model is handed the relevant pages from your notes and told "answer using only this."
- Embedding: turning text into a list of numbers such that similar meanings get similar numbers. It's how the machine "feels" that "container" and "Docker box" are related even though they share no words.
- Vector database (Chroma): a library that stores those numbers and can instantly find "the 3 most similar passages to this question."
- The pipeline: question β find relevant chunks (retrieve) β paste them into the prompt (augment) β let the LLM write the answer (generate).
π Project structure
mkdir scalergpt && cd scalergpt mkdir docs touch app.py ingest.py requirements.txt Dockerfile docker-compose.yml .env.example .dockerignore .gitignore
fastapi==0.115.6 uvicorn==0.34.0 openai==1.59.7 chromadb-client==0.6.3 python-dotenv==1.0.1
chromadb-client β the thin client β not
the full chromadb package. The full package IS the database (heavy); the client just talks
to a database running elsewhere. Since Chroma will live in its own container, your app only needs the phone,
not the whole telephone exchange. This keeps the app image small β the #1 problem with LLM images is size.
Drop some .txt or .md notes into docs/ β they become the bot's
knowledge.Secrets 101 β the ATM PIN rule
The golden rule: "Image = ATM card (safe to share). Env var = PIN (inject at runtime, never write it on the card)."
An environment variable is a named value the operating system hands to a program when it starts β like a sticky note passed to the chef as they walk in, rather than printed in the recipe book everyone can read. A .env file is simply a text file of such notes, one per line.
# Copy this file: cp .env.example .env β then paste your real key.
OPENAI_API_KEY=sk-paste-your-real-key-here
- No quotes, no spaces around the
=. Get a key at platform.openai.com/api-keys. The whole demo costs less than one US cent. - Add
.envto BOTH.dockerignoreAND.gitignore. You ship a safe.env.exampletemplate instead; each person copies it to.envlocally. - If you ever write
ENV OPENAI_API_KEY=sk-...in a Dockerfile, anyone can read it back withdocker history. This is a favourite interview question.
π§ app.py β RAG with a retry loop β fixed version
This is the corrected code. The naive version crashes at startup β the next card explains exactly why, because that bug is the best lesson in the whole guide.
import os, sys, time import chromadb from chromadb.utils import embedding_functions from fastapi import FastAPI, HTTPException from openai import OpenAI from pydantic import BaseModel app = FastAPI(title="ScalerGPT") # Fail LOUDLY and clearly if the key is missing - not with a cryptic traceback API_KEY = os.getenv("OPENAI_API_KEY", "").strip() if not API_KEY or API_KEY.startswith("sk-paste"): sys.exit("[ScalerGPT] OPENAI_API_KEY missing. Put a real key in .env") llm = OpenAI(api_key=API_KEY) # The thin client has no built-in embedder - we must supply one explicitly openai_ef = embedding_functions.OpenAIEmbeddingFunction( api_key=API_KEY, model_name="text-embedding-3-small") CHROMA_HOST = os.getenv("CHROMA_HOST", "localhost") CHROMA_PORT = int(os.getenv("CHROMA_PORT", "8000")) def connect_to_chroma(retries=30, delay=2): # Chroma takes a few seconds to boot. depends_on only waits for its # container to START, not to be READY - so we knock politely and retry. for attempt in range(1, retries + 1): try: client = chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT) client.heartbeat() print(f"[ScalerGPT] Connected to chroma at {CHROMA_HOST}:{CHROMA_PORT}", flush=True) return client except Exception as e: print(f"[ScalerGPT] Waiting for chroma ({attempt}/{retries}): {type(e).__name__}", flush=True) time.sleep(delay) sys.exit(f"[ScalerGPT] Could not reach chroma at {CHROMA_HOST}:{CHROMA_PORT}") chroma = connect_to_chroma() collection = chroma.get_or_create_collection(name="notes", embedding_function=openai_ef) class Question(BaseModel): query: str @app.get("/") def health(): return {"status": "ScalerGPT is live π", "docs_indexed": collection.count(), "chroma_host": CHROMA_HOST, "chroma_port": CHROMA_PORT} @app.post("/ask") def ask(q: Question): if collection.count() == 0: raise HTTPException(status_code=400, detail="No documents indexed. Run: docker compose exec app python ingest.py") # 1. RETRIEVE - find the 3 most relevant chunks hits = collection.query(query_texts=[q.query], n_results=3) documents = hits.get("documents") or [[]] context = "\n\n---\n\n".join(documents[0]) # 2. AUGMENT - paste those chunks into the prompt system_prompt = ("You are ScalerGPT, a helpful teaching assistant. " "Answer using ONLY the context below. If it does not contain the answer, " f"say you don't know.\n\nCONTEXT:\n{context}") # 3. GENERATE - let the LLM write the final answer resp = llm.chat.completions.create(model="gpt-4o-mini", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": q.query}]) return {"question": q.query, "answer": resp.choices[0].message.content, "sources_used": len(documents[0])}
/ask is the three-step RAG
dance: find the 3 most relevant passages, paste them into the instructions, let GPT write the answer. The
if count() == 0 guard gives a helpful "you forgot to ingest" message instead of a confusing empty
answer.
You'll also need ingest.py β it reads every file in docs/, splits them into
paragraph chunks, and loads them into Chroma, using the same retry pattern.
The bug that WILL bite you: started β ready
Here's the exact sequence, because you will hit it:
- You run
docker compose up -d. Thendocker compose psshows⦠only chroma. The app has vanished. - Lesson 1:
pshides dead containers.docker compose ps -areveals the app: Exited (1). - Lesson 2:
docker compose logs appshows the reason: "Connection refused⦠Could not connect to a Chroma server." - Lesson 3 (the real one): the compose file says
depends_on: chromaβ so why did it fail? Becausedepends_ononly waits for chroma's container to START, not for the database inside it to be READY. Chroma needs a few seconds to boot. The app knocked immediately, got no answer, and gave up.
Analogy: the restaurant unlocked its door (container started) but the chef hasn't tied his apron yet
(service not ready). If you shout your order at the locked kitchen and storm out, that's a crash. The retry
loop in app.py is the polite customer who waits and knocks again. (The alternative fix β a compose
healthcheck + depends_on: condition: service_healthy β is in the practice
challenges.)
docker-compose.yml β the thali order system β fixed version
So far you ordered tiffins one at a time β docker run this, docker run that.
Compose says: write one menu, then just say "serve the thali." The file format is YAML β a way of
writing structured settings where indentation shows what belongs to what, like a neatly indented
shopping list. (Careful: YAML is picky β use spaces, never tabs.)
services: app: build: . container_name: scalergpt-app ports: - "8000:8000" env_file: .env environment: - CHROMA_HOST=chroma - CHROMA_PORT=8000 # INTERNAL port - see the port trap below! depends_on: - chroma restart: unless-stopped chroma: image: chromadb/chroma:0.6.3 container_name: scalergpt-chroma ports: - "8001:8000" volumes: - chroma_data:/chroma/chroma restart: unless-stopped volumes: chroma_data:
- services:
- The menu. Each entry below is one container you want.
- build: .
- "Build this service's image from the Dockerfile in this folder" (the app's Dockerfile is the same 6-liner as Port 1, minus the training step).
- image: chromadb/chroma:0.6.3
- Don't build β download this ready-made image from Docker Hub. You never write a Dockerfile for Chroma; someone already packed that tiffin.
- env_file: .env
- "At startup, hand this container all the sticky notes from .env" β the PIN gets injected at runtime, never baked into the image.
- environment:
- More sticky notes, written directly here (fine for non-secrets like hostnames).
- CHROMA_HOST=chroma
- Not an IP address β the service name! Compose creates a private network where every service is reachable by its name, like flats on an intercom. The app dials "chroma" and Docker connects the call.
- depends_on:
- "Start chroma before me." β οΈ Start β not ready. That's why app.py retries.
- restart: unless-stopped
- "If I crash, bring me back automatically" β unless a human explicitly stopped me. A free safety net.
- volumes: (on chroma)
- "Mount the storage box named chroma_data at the path /chroma/chroma inside the container" β that's where Chroma keeps its data, so the data now lives OUTSIDE the disposable container.
- volumes: (bottom)
- Declares the storage box itself so Docker creates and tracks it.
The port trap: published vs internal
Look at chroma's line: "8001:8000". Two different numbers β this is where everyone gets burned:
- Chroma listens on port 8000 inside its own container (the flat number).
- You publish it as 8001 on the Mac (the street gate) β only so YOU can poke it from outside for debugging, and because the app already took the Mac's 8000.
- The app container is already inside the building β it's a neighbour, not a street visitor.
Neighbours use flat numbers. So the app must use
CHROMA_PORT=8000.
Setting CHROMA_PORT=8001 is the single most common bug in this setup β it produces
"connection refused" and an exited app container. Say it twice: containers talking to containers use
INTERNAL ports. Only your Mac uses the published port.
Milestone #2 β Serve the thali
cp .env.example .env # then put your REAL key inside .env docker compose up -d --build docker compose ps -a docker compose logs app docker compose exec app python ingest.py curl -X POST http://localhost:8000/ask \ -H "Content-Type: application/json" \ -d '{"query": "What is Docker?"}'
- docker compose up
- "Read docker-compose.yml and make reality match it" β create the network, the volume, and all containers, in dependency order.
- -d
- Detached β background, same as before.
- --build
- "Rebuild my images first if code changed." β οΈ Make this a habit: after ANY file edit,
up -d --build. Without it, Compose happily reuses the old photograph and your edit never arrives. This single mistake costs most people 15 minutes. - docker compose ps -a
- List this project's containers including dead ones. Expect both "Up". If app says "Exited", read its logs.
- docker compose logs app
- The app's diary. You want to see: [ScalerGPT] Connected to chroma at chroma:8000 β you may first see a few "Waiting for chroma (1/30)" lines. That's the retry loop doing its job!
- docker compose exec app python ingest.py
- "Inside the already-running app container, execute
python ingest.py." This is how you run one-off jobs (migrations, imports) in production β you don't start a new container, you step into the live one.
Then open http://localhost:8000/docs and ask questions from the Swagger UI. The test worth doing: ask "What is the capital of France?" β ScalerGPT says it doesn't know, because the answer isn't in your docs. That's proof the answers are grounded in YOUR documents, not the model's memory.
Milestone #2.5 β The volume magic trick
docker compose down docker compose up -d curl http://localhost:8000/
- docker compose down
- The opposite of up: stop and DELETE all containers and the network. The thali is cleared. But volumes survive by default.
- docker compose up -d
- Brand-new containers from scratch.
- curl http://localhost:8000/
docs_indexedis STILL > 0 β you never re-ingested, yet the data is there. It lives in the volume, not the container. β»οΈ- docker compose down -v
- Know it, don't run it casually:
-valso deletes volumes. THIS is how you actually lose the data. The steel dabba goes to the scrapyard.
The takeaway: containers die all the time β deployments, crashes, scaling. Volumes are the steel box that comes back after every delivery. Container = disposable. Volume = permanent.
β Self-check β Port 2
Dockerize an Agentic Project β "DeskBuddy" π€
What you'll build: a 3-container agentic system β an agent service (LLM + tool-calling loop), a tools service (separate microservice), and Redis (conversation memory). New concepts: microservice separation, private networking (no published port!), and one compose file running it all.
What an agent actually is
An agent is an LLM that doesn't just answer β it does things. It thinks, picks up a tool, checks the result, and keeps going.
Think of the architecture as an office: the agent = the manager (decides what needs doing), tools = the departments (they do the actual work), and Redis = the office register (remembers who said what).
Why three separate boxes? Because in production, the tools team is different from the agent team. You update the tools without touching the agent. That's microservices β and without Docker, microservices are just a slide in a deck.
What is Redis, in 30 seconds?
Redis is a super-fast "sticky-note board" database: you store values under names (key β value) and
read them back in microseconds. Here it remembers each conversation: key = the session ID, value = the message
history. Why not a Python variable? Because containers die and restart β a variable dies with them. Redis in
its own container (with a volume) means the agent can crash, restart, and still remember you. Someone already
packed the Redis tiffin: you just write image: redis:7-alpine.
π Structure + the tools service
mkdir deskbuddy && cd deskbuddy mkdir agent tools touch docker-compose.yml .env.example touch agent/app.py agent/requirements.txt agent/Dockerfile touch tools/app.py tools/requirements.txt tools/Dockerfile
from fastapi import FastAPI from pydantic import BaseModel import datetime app = FastAPI(title="DeskBuddy Tools") class Calc(BaseModel): expression: str @app.post("/calculator") def calculator(c: Calc): try: # demo only - never use eval in production! return {"result": eval(c.expression, {"__builtins__": {}})} except Exception as e: return {"error": str(e)} @app.get("/datetime") def now(): return {"now": datetime.datetime.now().isoformat()}
eval
is fine for a classroom demo and dangerous in production β it can execute arbitrary code. In a real service
you'd use a safe expression parser instead.π§ The agent β the think β act β observe loop
r = redis.Redis(host=os.getenv("REDIS_HOST", "redis"), port=6379, decode_responses=True) TOOLS_URL = os.getenv("TOOLS_URL", "http://tools:7000") @app.post("/chat") def chat(req: Chat): key = f"history:{req.session_id}" history = [json.loads(m) for m in r.lrange(key, 0, -1)] # load memory history.append({"role": "user", "content": req.message}) for _ in range(5): # the agent loop resp = llm.chat.completions.create( model="gpt-4o-mini", messages=history, tools=TOOL_DEFS) msg = resp.choices[0].message if not msg.tool_calls: # no tool needed? break # then we're done history.append(msg.model_dump(exclude_none=True)) for tc in msg.tool_calls: # run each requested tool result = call_tool(tc.function.name, json.loads(tc.function.arguments)) history.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result)}) # save memory back to redis, return final answer
TOOL_DEFS describes
each tool's name and inputs β the menu card); (2) the LLM either answers in words β done, break β or replies
"please run calculator with 23*47 for me"; (3) you actually call the tools service over HTTP, paste the result
back into the conversation, and go around again so the LLM can see what happened. Max 5 laps so a confused
model can't loop forever (a "safety fuse"). Memory: before the loop you load this session's history from
Redis; after, you save it back β that's how a follow-up like "now double it" works.Look at the two addresses: http://tools:7000 and host="redis". Again β
service names, not IPs. By project 3 this should feel natural.
The full thali β 3-service compose
services: agent: build: ./agent ports: - "9000:9000" env_file: .env environment: - TOOLS_URL=http://tools:7000 - REDIS_HOST=redis depends_on: [tools, redis] restart: unless-stopped tools: build: ./tools restart: unless-stopped # NOTE: no ports! Explained below - this is the security gem redis: image: redis:7-alpine volumes: - agent_memory:/data restart: unless-stopped volumes: agent_memory:
- build: ./agent
- Each service builds from its own subfolder's Dockerfile. One compose file, two custom images, one ready-made.
- tools: (no ports!)
- The security gem. No
portssection = no street gate = the outside world cannot reach it at all. Only fellow residents of the private network (the agent) can call it attools:7000. Trycurl localhost:7000from your Mac β connection refused, by design. "Never give internal departments a public entrance." One tiny omission; gold in interviews and in production. - redis:7-alpine
- "alpine" = built on a tiny 5MB Linux. Whole Redis image β 40MB.
- agent_memory volume
- Same trick as Chroma: conversation memory survives container death.
Note what you did not need here: the retry-loop lesson doesn't bite, because the Redis client only
connects when first used (lazily), and Redis boots in under a second. restart: unless-stopped is
the seatbelt anyway.
Milestone #3 β The agent in action + memory proof
Split your terminal in two. Left: docker compose logs -f. Right: the curls. You'll watch
requests ripple across three containers live.
cp .env.example .env # real key inside, same as before docker compose up -d --build docker compose ps -a curl -X POST http://localhost:9000/chat \ -H "Content-Type: application/json" \ -d '{"session_id": "demo", "message": "What is 23*47, and what time is it right now?"}' curl -X POST http://localhost:9000/chat \ -H "Content-Type: application/json" \ -d '{"session_id": "demo", "message": "Now double that multiplication result"}' docker compose logs -f
- First curl
- A two-tool task on purpose: the agent must call BOTH calculator and clock, then compose one answer. Watch the loop go around twice in the logs.
- Second curl
- Same session_id = same Redis key = the agent remembers 1081 from a minute ago and answers 2162. Change session_id to "other" and ask again β no memory. That's isolation per user, for free.
- docker compose logs -f
- All three services' diaries interleaved, colour-coded by name. This is how you debug multi-service systems.
The point: three services, one command, real memory, proper isolation. And this exact compose file works unchanged on an EC2 instance β same commands, same result. That bridge from laptop to production is Docker's superpower.
β Self-check β Port 3
Cheatsheet, Golden Rules & Debugging
This is the section to keep open on a second screen while you work. Search the cheatsheet, follow the debug tree when something breaks, and re-read the golden rules before any interview.
π The cheatsheet β search it
| Command | What it does | Tiffin translation |
|---|---|---|
docker build -t name:tag . |
Build an image from the Dockerfile here | Pack the master box from the recipe |
docker run -d -p 8000:8000 img |
Start a container, background, map ports | Deliver the tiffin, set the gate |
docker ps / docker ps -a |
Running containers / ALL incl. dead ones | Today's deliveries / the full register |
docker logs -f name |
Stream a container's output live | The box's diary |
docker exec -it name bash |
Open a shell inside a running container | Step inside the box |
docker images |
List all images on your machine | The shelf of master tiffins |
docker rm -f name |
Force-remove a container, even if running | Recall the delivery immediately |
docker compose up -d --build |
Rebuild if needed + start all services | Serve the thali (fresh) |
docker compose ps -a |
This project's containers, incl. exited | Which dishes made it, which didn't |
docker compose exec app CMD |
Run a one-off command in a live service | Ask the chef mid-service |
docker compose down |
Stop + delete containers (volumes safe) | Clear the thali, keep the steel boxes |
docker compose down -v |
β¦and delete volumes too β οΈ data gone | Scrap the steel boxes |
docker compose logs -f |
All services' logs together | CCTV over the whole kitchen |
docker compose up -d --force-recreate |
Recreate containers (e.g. after .env edits) | Fresh boxes, same recipe |
docker volume ls |
List volumes on your machine | Count the steel dabbas in stock |
docker system df |
How much disk Docker is eating | Check the pantry weight |
docker system prune -a |
Delete unused images/containers (careful!) | Diwali deep-clean π§Ή |
docker run --platform linux/amd64 |
The Mac ARM fix | Dubbing for another audience |
docker history image |
Show every layer + the command that made it | Read the recipe backwards (and find leaked secrets) |
lsof -i :8000 |
Find which process is holding a port | Who's blocking the gate? |
The golden rules β read these before any interview
- The image is a photograph, not a mirror. Edited a file? Rebuild:
docker compose up -d --build. pshides the dead. Service missing?ps -a, thenlogs <service>.- Started β ready.
depends_onwaits for the container, not the service inside. Retry in code or add a healthcheck. - Internal ports for neighbours, published ports for visitors. Containerβcontainer uses the internal port.
- Secrets are PINs. .env + env_file at runtime; never ENV in a Dockerfile, never commit .env.
- .env changed? Recreate. Env vars load at container start:
--force-recreate. - No
ports:= no public entrance. Internal services should stay internal. - Rarely-changing layers on top. That's the whole caching game.
- See
quote>in zsh? You pasted a # comment. Ctrl+C, re-run without it.
π§ The debug decision tree β follow it top to bottom
When something breaks, don't guess. Walk this:
Top Mac errors and their fixes
- "Cannot connect to the Docker daemon" β Docker Desktop isn't open. Launch it, wait for the whale.
- "port is already allocated" β find the culprit with
lsof -i :8000, or map another port:-p 8080:8000. - App container missing from
psβ it crashed:ps -athenlogs app. - Platform warning (arm64/amd64) β add
--platform linux/amd64. - Build very slow / disk full β
docker system df, thendocker system prune. - Edits not showing up β rebuild:
up -d --build. - Stuck at
quote>β Ctrl+C, re-run without the trailing comment.
ποΈ Practice challenges β do at least two
- Warm-up β Push the QuickBite ETA image to Docker Hub (
docker tag+docker push). - Medium β Replace ScalerGPT's retry loop with a compose
healthcheckon chroma +depends_on: condition: service_healthy. Write two lines on which approach you'd pick and why. - Hard β Add a weather tool to DeskBuddy by updating ONLY the tools service β no agent rebuild. This proves you understood microservices.
- Boss level β Multi-stage builds on all three projects; cut image sizes by 40%. Record
before/after from
docker images. - Bonus β Break something on purpose (wrong port, missing .env, edit without rebuild), then fix it using only the debug tree above. This is the fastest way to make the knowledge stick.
Self-test β 30 flashcards
Click a card to reveal the answer. Try to say your answer out loud before you click β recall is what builds memory, re-reading isn't. Anything you get wrong, go back to that Port.
Interview questions you can now answer
These come up in real ML/AI engineering interviews. If you can answer eight of these cleanly, this session did its job.
- Explain the Docker build cache and how you'd optimise a slow Dockerfile. Talk about layers,
ordering,
COPY requirements.txtfirst,--no-cache-dir, and multi-stage builds. - How do you handle secrets in a containerised app? Runtime injection via env vars / secret managers,
never
ENVin the Dockerfile,.envin both ignore files, anddocker historyas the attack you're preventing. - Your service depends on a database that takes 20s to boot. How do you handle startup ordering?
depends_on isn't enough; use healthchecks with
condition: service_healthy, or application-level retry with backoff. Mention that retry-in-code is more portable across orchestrators. - How do containers discover each other? Compose/Kubernetes DNS by service name, internal ports, and why hardcoding IPs is wrong.
- Container vs VM β when would you still choose a VM? Different kernel required, strong hardware-level isolation for untrusted workloads, or legacy OS dependencies.
- How do you persist state in a stateless system? Volumes and external stores; containers are cattle, not pets.
- How would you make this compose stack production-ready? Healthchecks, resource limits, non-root user, pinned image digests, logging driver, secrets manager, and moving to an orchestrator.
- What's in your image that shouldn't be? Build tools, .git, credentials, test data, dev dependencies β and how multi-stage builds and .dockerignore fix it.
- How do you debug a container that exits immediately?
ps -aβlogsβ check the CMD β run it interactively with an overridden entrypoint. - Why doesn't your internal service publish a port? Attack surface. Only the edge service is reachable; everything else is on the private network.
π Glossary β the vocabulary you're now expected to use
- Image
- A read-only, layered package of your app plus everything it needs to run.
- Container
- A running instance of an image, isolated from the host and from other containers.
- Layer
- The filesystem diff produced by one Dockerfile instruction. Cached and reused across builds.
- Build context
- The folder you pass to
docker build(the lonely.). Everything in it, minus .dockerignore, is sent to the Docker engine. - Registry / Docker Hub
- The remote store where images are pushed and pulled from.
- Tag
- The human-readable
name:versionlabel on an image. - Daemon
- The background engine that actually does the work. Docker Desktop starts it; the whale icon means it's alive.
- Port publishing
- Mapping a host port to a container port so the outside world can reach in
(
-p host:container). - Volume
- Docker-managed storage that outlives containers. For anything you can't afford to lose.
- Bind mount
- Mapping a host folder straight into a container. Great for live-reload in development, avoided in production.
- Compose
- A tool that reads one YAML file and runs a whole multi-container application.
- Service
- One entry in a compose file β and also the DNS name other containers use to reach it.
- Healthcheck
- A command Docker runs periodically to decide whether a container is actually ready, not just started.
- Multi-stage build
- Using one image to build and a second, smaller one to run β the standard way to shrink production images.
- Orchestrator
- The system that runs containers across many machines (Kubernetes, ECS). Compose is the single-machine version of the same idea.
π¬ The one line to remember
"Writing code is half the job. Making it run anywhere in the world β that's engineering. Docker is the box that carries your work to the world."
Where to go next: Kubernetes β for when you have 10,000 boxes to manage instead of three. Everything you learned here (images, ports, volumes, service names, readiness) maps directly onto it. You've already done the hard part. π³