FUTURE WITH SHIVANK Β· STUDENT REVISION GUIDE

Dockerize Everything:
ML β†’ LLM β†’ Agents

Everything from the masterclass, rebuilt for revision. Three real projects, every command decoded word by word, and one tiffin-delivery analogy you'll never forget. 🍱 Read it, run it, then test yourself at the bottom.

πŸ”° No prior Docker needed πŸ’» macOS (Apple Silicon ready) 🐳 Docker Desktop + Compose βœ… Battle-tested code πŸ§ͺ 30 self-test questions
Dockerize Everything

πŸ“– 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.

Pass 1 β€” ReadSkim top to bottom without typing anything. Goal: recognise the vocabulary. Image, container, volume, compose, service name.
Pass 2 β€” BuildRebuild all three projects from scratch, copying commands from here. Tick the self-check boxes as you go.
Pass 3 β€” RecallClose this page. Try the flashcards and interview questions at the bottom from memory. Whatever you miss, that's your revision list.

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.

PORT 0

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.

Terminal β€” install & verify
brew install --cask docker

docker --version
docker compose version
docker run hello-world
Command decoder β€” what each piece means
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.

The fix when you hit a platform error
docker run --platform linux/amd64 <image-name>
Command decoder
--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

PORT 1

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:

Terminal
mkdir quickbite-eta && cd quickbite-eta
touch train.py app.py requirements.txt Dockerfile .dockerignore
Command decoder
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.
requirements.txt
scikit-learn==1.5.2
pandas==2.2.3
fastapi==0.115.6
uvicorn==0.34.0
joblib==1.4.2
In plain words: this file is your shopping list. Python doesn't come with these tools built in, so you list exactly which extra packages you need and exactly which version of each. scikit-learn trains the model, pandas handles data tables, FastAPI turns Python functions into a web API, uvicorn is the web server that actually runs FastAPI, and joblib saves/loads the trained model to a file. Pinning versions with == 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

train.py
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 βœ…")
In plain words: you invent 5,000 fake food orders (so the demo is fast and needs no real dataset). For each order you compute a "true" delivery time with a simple formula: rain adds ~9 minutes, no rider available adds ~6, every km adds ~3. Then you train a RandomForest β€” think of it as 60 junior analysts each making a guess, and you take their average β€” to learn that pattern. Finally 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

app.py
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 πŸ”"}
In plain words: this file is the counter window of your shop. An "API" is just a way for programs to talk to each other over the network β€” you send a request, you get a response. FastAPI lets you say "when someone sends order details to the address /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:

Dockerfile
# 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"]
Line-by-line decoder
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.txt means "install everything on this list". --no-cache-dir tells 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 app object from app.py. --host 0.0.0.0 means "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

.dockerignore
__pycache__/
*.pyc
venv/
.venv/
.git/
.env
*.ipynb
eta_model.pkl
data/raw/
In plain words: when Docker sees 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

Terminal β€” the big moment
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}'
Command decoder β€” slow down and read every line here
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

Terminal β€” daily-driver 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
Command decoder
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 ps hides 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." Try ls and cat app.py inside, then exit to 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:

After ANY code edit
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

PORT 2

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

Terminal
mkdir scalergpt && cd scalergpt
mkdir docs
touch app.py ingest.py requirements.txt Dockerfile docker-compose.yml .env.example .dockerignore .gitignore
requirements.txt
fastapi==0.115.6
uvicorn==0.34.0
openai==1.59.7
chromadb-client==0.6.3
python-dotenv==1.0.1
In plain words: note it's 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.

.env.example β†’ copy to .env and add your real key
# 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 .env to BOTH .dockerignore AND .gitignore. You ship a safe .env.example template instead; each person copies it to .env locally.
  • If you ever write ENV OPENAI_API_KEY=sk-... in a Dockerfile, anyone can read it back with docker 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.

app.py
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])}
In plain words: at startup the app does three checks: (1) is the API key present? β€” if not, exit with a human-readable message instead of a scary traceback; (2) set up the embedder β€” the machine that converts text to numbers (the thin client doesn't include one, so you hand it OpenAI's); (3) knock on Chroma's door politely β€” try to connect, and if refused, wait 2 seconds and knock again, up to 30 times, printing progress so you can watch it in the logs. Then /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:

  1. You run docker compose up -d. Then docker compose ps shows… only chroma. The app has vanished.
  2. Lesson 1: ps hides dead containers. docker compose ps -a reveals the app: Exited (1).
  3. Lesson 2: docker compose logs app shows the reason: "Connection refused… Could not connect to a Chroma server."
  4. Lesson 3 (the real one): the compose file says depends_on: chroma β€” so why did it fail? Because depends_on only 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.)

docker-compose.yml
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:
Line-by-line decoder
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

Terminal
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?"}'
Command decoder
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

Destroy everything. Data survives.
docker compose down
docker compose up -d
curl http://localhost:8000/
Command decoder
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_indexed is 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: -v also 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

PORT 3

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

Terminal
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
In plain words: two subfolders = two separate services, each with its OWN code, OWN requirements, OWN Dockerfile. This is the microservice idea made physically visible in the folder structure. Redis needs no folder β€” it's a ready-made image.
tools/app.py β€” two simple tools
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()}
In plain words: the tools service is just another tiny FastAPI app with two endpoints: a calculator (evaluates a math expression) and a clock. Nothing AI about it β€” it's a plain worker department. Why does an LLM need a calculator at all? Because LLMs predict text; they're famously unreliable at arithmetic. Giving them a calculator is like giving an eloquent manager an actual accountant. Its Dockerfile is the same 6 lines as always, ending in uvicorn on port 7000. ⚠️ Note the comment: 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

agent/app.py β€” the heart of it
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
In plain words: the loop is the whole magic of "agents", and it's just 10 lines. Each cycle: (1) show the LLM the full conversation plus a menu of available tools (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

docker-compose.yml
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:
What's new here
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 ports section = no street gate = the outside world cannot reach it at all. Only fellow residents of the private network (the agent) can call it at tools:7000. Try curl localhost:7000 from 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.

Terminal
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
What to watch for
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

PORT 4

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

  1. The image is a photograph, not a mirror. Edited a file? Rebuild: docker compose up -d --build.
  2. ps hides the dead. Service missing? ps -a, then logs <service>.
  3. Started β‰  ready. depends_on waits for the container, not the service inside. Retry in code or add a healthcheck.
  4. Internal ports for neighbours, published ports for visitors. Container→container uses the internal port.
  5. Secrets are PINs. .env + env_file at runtime; never ENV in a Dockerfile, never commit .env.
  6. .env changed? Recreate. Env vars load at container start: --force-recreate.
  7. No ports: = no public entrance. Internal services should stay internal.
  8. Rarely-changing layers on top. That's the whole caching game.
  9. 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:

Something is broken. β”‚ β”œβ”€ Does docker ps show my container? β”‚ β”œβ”€ NO β†’ run docker ps -a (or compose ps -a) β”‚ β”‚ β”œβ”€ Status "Exited" β†’ docker logs <name> ← the answer is in here β”‚ β”‚ β”‚ β”œβ”€ "Connection refused" β†’ started β‰  ready, or wrong port/host β”‚ β”‚ β”‚ β”œβ”€ "KeyError / missing key" β†’ .env not loaded β†’ --force-recreate β”‚ β”‚ β”‚ └─ Python traceback β†’ your code, not Docker. Fix, then --build β”‚ β”‚ └─ Not listed at all β†’ the build failed. Scroll up in the build output. β”‚ └─ YES β†’ keep going ↓ β”‚ β”œβ”€ Can I reach it from my Mac (curl localhost:PORT)? β”‚ β”œβ”€ NO β†’ check three things, in order: β”‚ β”‚ 1. Is there a -p / ports: line at all? (no ports = private, by design) β”‚ β”‚ 2. Is the LEFT number the one I'm curling? (host:container) β”‚ β”‚ 3. Does the app bind to 0.0.0.0, not 127.0.0.1? β”‚ └─ YES β†’ keep going ↓ β”‚ β”œβ”€ Can container A reach container B? β”‚ β”œβ”€ NO β†’ am I using the SERVICE NAME as host (not localhost)? β”‚ β”‚ am I using the INTERNAL port (not the published one)? β”‚ β”‚ are both services in the same compose file? β”‚ └─ YES β†’ keep going ↓ β”‚ └─ Is my code change showing up? β”œβ”€ NO β†’ you rebuilt? docker compose up -d --build β”‚ edited .env? docker compose up -d --force-recreate └─ YES β†’ it's a logic bug now. Congratulations, that's your job. πŸ™‚

Top Mac errors and their fixes

  1. "Cannot connect to the Docker daemon" β†’ Docker Desktop isn't open. Launch it, wait for the whale.
  2. "port is already allocated" β†’ find the culprit with lsof -i :8000, or map another port: -p 8080:8000.
  3. App container missing from ps β†’ it crashed: ps -a then logs app.
  4. Platform warning (arm64/amd64) β†’ add --platform linux/amd64.
  5. Build very slow / disk full β†’ docker system df, then docker system prune.
  6. Edits not showing up β†’ rebuild: up -d --build.
  7. Stuck at quote> β†’ Ctrl+C, re-run without the trailing comment.

πŸ‹οΈ Practice challenges β€” do at least two

  1. Warm-up β€” Push the QuickBite ETA image to Docker Hub (docker tag + docker push).
  2. Medium β€” Replace ScalerGPT's retry loop with a compose healthcheck on chroma + depends_on: condition: service_healthy. Write two lines on which approach you'd pick and why.
  3. Hard β€” Add a weather tool to DeskBuddy by updating ONLY the tools service β€” no agent rebuild. This proves you understood microservices.
  4. Boss level β€” Multi-stage builds on all three projects; cut image sizes by 40%. Record before/after from docker images.
  5. 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.
REVISE

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.

  1. Explain the Docker build cache and how you'd optimise a slow Dockerfile. Talk about layers, ordering, COPY requirements.txt first, --no-cache-dir, and multi-stage builds.
  2. How do you handle secrets in a containerised app? Runtime injection via env vars / secret managers, never ENV in the Dockerfile, .env in both ignore files, and docker history as the attack you're preventing.
  3. 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.
  4. How do containers discover each other? Compose/Kubernetes DNS by service name, internal ports, and why hardcoding IPs is wrong.
  5. Container vs VM β€” when would you still choose a VM? Different kernel required, strong hardware-level isolation for untrusted workloads, or legacy OS dependencies.
  6. How do you persist state in a stateless system? Volumes and external stores; containers are cattle, not pets.
  7. 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.
  8. 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.
  9. How do you debug a container that exits immediately? ps -a β†’ logs β†’ check the CMD β†’ run it interactively with an overridden entrypoint.
  10. 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:version label 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. 🐳

Self-checks: 0/0