Module 7 · DevOps · Deep Dive
The skills that turn "code on my laptop" into "a repo and a running app a stranger can trust" — version control, containers, and the right way to handle secrets.
BasicIntermediateBuild
You can be a strong coder and still get filtered out at the GitHub stage. Two things change that:
.gitignore that proves you know what doesn't belong in version control.Recruiters genuinely read READMEs and skim commit messages; both tell them whether you work like a professional or a hobbyist. Treat your repo as part of the interview, because it is.
PHP bridge: if you've ever zipped a folder and FTP'd it to a server, this lesson replaces that whole habit withgit push and a container image — reproducible, versioned, and reviewable.
Git tracks the history of your project as a series of commits — snapshots you can return to. The everyday loop is small:
# Start tracking a project (once, in the project root) git init # See what's changed and what's staged git status # Stage changes, then snapshot them with a message git add . git commit -m "Add document upload endpoint"
add moves changes into the staging area; commit records them. Keep that two-step in mind — staging lets you commit some changes and hold others.
A branch is an independent line of work. Build a feature on its own branch, then merge it back when it's ready:
# Create a branch AND switch to it in one step git checkout -b feature/upload # ...commit some work on the branch... # Switch back and merge the finished feature in git checkout main git merge feature/upload
This keeps main always working while you experiment. It's also how teams collaborate — each person on their own branch, merged via a pull request.
# Send local commits up to GitHub git push # Bring down commits others pushed git pull # A compact, readable history git log --oneline
A .gitignore file lists paths Git should never track. This is non-negotiable: it keeps secrets, virtual environments, and caches out of your repo forever.
.gitignore
# Python __pycache__/ *.pyc .venv/ venv/ # Secrets & local config — NEVER commit these .env # Editor / OS noise .vscode/ .DS_StorePHP bridge: like never committing
vendor/ or your .env in a Laravel project — same instinct, same file.
Create the repo on GitHub, then connect your local project to it:
git remote add origin git@github.com:you/docchat.git git branch -M main git push -u origin main
Now the part recruiters actually read. A strong README answers four questions, in order:
| Section | What it answers |
|---|---|
| What | One sentence: what the project is. "DocChat lets you upload documents and ask questions answered by RAG." |
| Why | The problem it solves and the stack used — FastAPI, Postgres, Next.js, an LLM. |
| Run | Exact copy-paste commands to run it locally. If they can't run it, it doesn't count. |
| Screenshots | A screenshot or short GIF of the app working. Proof, instantly. |
Write the README in Markdown (README.md) — GitHub renders it on your repo's front page automatically.
Docker fixes "it works on my machine". You package your app and everything it needs into an image, and that image runs identically anywhere Docker runs.
.iso versus a booted machine.
You describe the image in a Dockerfile — a recipe Docker follows top to bottom. Here's a correct one for the DocChat FastAPI backend:
Dockerfile
FROM python:3.13-slim WORKDIR /app # Copy deps first so this layer caches when only code changes COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Now copy the rest of the app COPY . . # FastAPI's production server, reachable from outside the container CMD ["fastapi", "run", "main.py", "--port", "8000", "--host", "0.0.0.0"]
Two details that matter: copying requirements.txt before the code lets Docker cache the slow pip install layer, so it only re-runs when dependencies change. And --host 0.0.0.0 is required — bind to 127.0.0.1 and the container will refuse outside connections.
Build the image, then run a container from it:
# Build an image and tag it "docchat" docker build -t docchat . # Run a container, mapping host port 8000 → container port 8000 docker run -p 8000:8000 docchat
The -p host:container flag publishes the port — without it, the app runs but nothing on your machine can reach it.
Just as Git has .gitignore, Docker has .dockerignore — it stops bulky or sensitive files from being copied into the image:
.dockerignore
.venv/ __pycache__/ .git/ .env *.pyc
DocChat needs a database too. Running each container by hand is tedious; Docker Compose defines the whole stack in one file and starts it with one command.
compose.yaml
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://docchat:secret@db:5432/docchat
depends_on:
- db
db:
image: postgres:17
environment:
POSTGRES_USER: docchat
POSTGRES_PASSWORD: secret
POSTGRES_DB: docchat
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# Build and start the whole stack; -d runs it in the background docker compose up -d # Stop and remove the containers docker compose down
Notice the API reaches the database at host db — Compose puts both services on a private network where the service name is the hostname. The named pgdata volume keeps your data alive across restarts.
Your database password, API keys, and JWT secret must never live in code. The rule: config comes from the environment, secrets never get committed. This is the 12-factor principle of storing config in environment variables.
.env file — and .gitignore it. Commit a .env.example with the keys but no real values, so teammates know what to set..env (git-ignored)
DATABASE_URL=postgresql://docchat:secret@localhost:5432/docchat OPENAI_API_KEY=sk-... JWT_SECRET=change-me
In FastAPI, read these cleanly with pydantic-settings — it loads env vars (and .env) into a typed, validated object:
config.py
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str openai_api_key: str jwt_secret: str model_config = SettingsConfigDict(env_file=".env") settings = Settings() # settings.database_url, etc.
.env to .gitignore before your first commit, not after.
The Dockerfile in §4 ships everything the build needed — compilers, build tools, pip's cache, header files — into the final image, even though the running app never touches them. A multi-stage build splits this in two: a heavy builder stage compiles and installs, then a clean final stage copies over only the finished artefacts. Everything else is thrown away.
FROM starts a new stage with its own filesystem. The builder can be fat (it has the toolchain); the final stage starts fresh and COPY --from=builder pulls in just the result. The build tools never reach production.
Here the builder installs dependencies into a virtual environment; the final stage copies that venv and the code, nothing more:
Dockerfile
# ---- Builder: has the toolchain, builds the venv ---- FROM python:3.13-slim AS builder WORKDIR /app RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # ---- Final: slim runtime, no build tools ---- FROM python:3.13-slim WORKDIR /app # Copy ONLY the finished venv from the builder COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" # Then the application code COPY . . CMD ["fastapi", "run", "main.py", "--port", "8000", "--host", "0.0.0.0"]
The win is twofold. Size: a single-stage image that pulls in build dependencies can balloon past 1 GB; the multi-stage equivalent often lands around 200 MB. Attack surface: compilers and package managers left in an image are tools an attacker can use — shipping a final stage without them means there's simply less to exploit.
PHP bridge: like runningcomposer install --no-dev in CI and deploying only vendor/ plus your code — the dev toolchain never reaches the server.
The base image you pick decides your floor for size and security. Three common choices for Python:
| Base | Trade-off |
|---|---|
python:3.13 | Full image — huge (~1 GB), bundles tools you rarely need. Avoid for production. |
python:3.13-slim | The pragmatic 2026 default. Small, Debian-based, so normal Python wheels install cleanly. |
python:3.13-alpine | Smallest, but uses musl libc — many Python wheels have no musl build, so pip falls back to compiling from source. Slow builds and subtle bugs. |
Reach for slim unless you have a specific reason not to. Alpine's tiny size is tempting, but the musl/wheel pain costs you more time than the megabytes save.
Two more habits separate a professional image from a hobbyist one — pinning the base so builds are reproducible, and running as a non-root user so a compromised process isn't root inside the container:
Dockerfile (final stage)
# Pin to a digest so the base never silently changes under you FROM python:3.13-slim@sha256:<digest> WORKDIR /app COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY . . # Create an unprivileged user and drop to it RUN adduser --disabled-password --no-create-home app USER app CMD ["fastapi", "run", "main.py", "--port", "8000", "--host", "0.0.0.0"]
Everything after USER app runs as that user. Put it after the steps that need to write to /app (like COPY), and bind to a port above 1024 (8000 is fine) — non-root can't bind privileged ports.
python:3.13-slim instead of the full image; (3) a thorough .dockerignore so .git, the venv, and caches aren't copied into the build context; and (4) running as a non-root user — not a size win itself, but the same discipline of shipping only what you need. Mention that smaller images also pull faster and have a smaller attack surface, and you've answered the security half too.
Docker considers a container "up" the moment its process starts — but a starting process isn't a ready one. A health check teaches Docker how to tell the difference by probing the app itself.
Dockerfile
# Probe the app's own /health endpoint; unhealthy after 3 failures HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1
The real payoff is in Compose. Postgres takes a few seconds to accept connections after its container starts; a plain depends_on only waits for the container to start, so the API often races ahead and crashes on "connection refused". Give the database a healthcheck, then make the API wait for service_healthy:
compose.yaml
services:
api:
build: .
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_USER: docchat
POSTGRES_PASSWORD: secret
POSTGRES_DB: docchat
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docchat"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
Now docker compose up holds the API back until pg_isready reports the database is genuinely accepting connections — fixing the classic "API crashes on first boot, works on restart" gotcha for good.
When you run docker build, Docker first sends the whole directory — the build context — to the engine. A missing .dockerignore means your .git history, .venv, and node_modules all get shipped: slow builds, fatter images, and a real risk of copying a .env straight into the image. So .dockerignore earns its place for speed and secrets, not just tidiness.
Caching is the other half. Docker caches each layer and reuses it until something that layer depends on changes — then that layer and every layer after it rebuilds. So order from least- to most-frequently-changed: install dependencies first, copy code last.
# Deps change rarely → this layer stays cached COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Code changes constantly → only THIS layer busts on each edit COPY . .
Flip those two and every code edit re-runs pip install — minutes wasted per build. For an extra step, BuildKit (on by default in 2026) offers a cache mount that persists pip's download cache across builds, even when requirements.txt changes:
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
PHP bridge: same logic as caching Composer's ~/.composer/cache between CI runs — the downloads survive, only the install step varies.
Dev and production want different Compose settings — dev mounts your code for live reload and exposes the database port; production does neither. Rather than maintain two files, Compose automatically merges a compose.override.yaml on top of compose.yaml when you run docker compose up:
compose.override.yaml (dev only)
services:
api:
volumes:
- "./:/app" # live-reload your code
environment:
DEBUG: "true"
db:
ports:
- "5432:5432" # reach Postgres from your laptop
Commit compose.yaml (the shared base) and compose.override.yaml (dev conveniences); production uses an explicit -f compose.yaml to skip the override. Finally, commit a .env.example — the keys your teammates must set, with no real values — so a new developer knows exactly what their git-ignored .env needs:
.env.example (committed)
DATABASE_URL=postgresql://docchat:CHANGEME@localhost:5432/docchat OPENAI_API_KEY= JWT_SECRET=
Step 1 — write the Dockerfile (from §4):
Dockerfile
FROM python:3.13-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["fastapi", "run", "main.py", "--port", "8000", "--host", "0.0.0.0"]
Step 2 — write the .gitignore so secrets and junk stay out:
.gitignore
__pycache__/ *.pyc .venv/ .env .vscode/ .DS_Store
Step 3 — initialise the repo and make the first commit:
git init git add . git commit -m "Initial commit: DocChat backend + Dockerfile" # confirm .env is NOT listed — it must be ignored git status
Then push to a new GitHub repo and write the README. You now have exactly the artefact a recruiter wants to find.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
What is the difference between an image and a container?
Which command creates a new branch and switches to it?
Where should a production database password live?
Why copy requirements.txt before the rest of the app?
What does docker compose let you do in one file?
Why does a multi-stage build produce a smaller image?
Why give Postgres a healthcheck plus service_healthy?