Module 7 · DevOps · Deep Dive

Git, Docker & Environments

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

Why this matters A recruiter screening you for a UAE full-stack role will open your GitHub before they open your CV. They read your README, scroll your commit history, and check whether the app actually runs. A clean repo plus a containerised, deployable DocChat is a hiring signal that no bullet point can fake. This lesson gives you the three tools that produce that signal: Git, Docker, and disciplined environment config.
In this lesson
  1. Why this is a hiring signal
  2. Git essentials
  3. A GitHub repo & a strong README
  4. Docker: images vs containers
  5. docker compose: app + Postgres
  6. Environments & secrets
  7. Multi-stage builds
  8. Smaller, safer images
  9. Health checks & readiness
  10. Build context & caching
  11. Compose overrides & .env.example
  12. Build: containerise DocChat
  13. Check yourself

1 · Why this is a hiring signal

You can be a strong coder and still get filtered out at the GitHub stage. Two things change that:

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 with git push and a container image — reproducible, versioned, and reviewable.

2 · Git essentials

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.

branches work without breaking main

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.

remote push, pull, and history

# Send local commits up to GitHub
git push

# Bring down commits others pushed
git pull

# A compact, readable history
git log --oneline
Writing good commit messages (interviewers notice) Use the imperative mood, like an instruction: "Add JWT auth", not "added auth" or "stuff". Keep the summary under ~50 characters; add a body only when the why isn't obvious. A history of clear messages reads like a changelog — that's the impression you want.

.gitignore keep junk out of history

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_Store
PHP bridge: like never committing vendor/ or your .env in a Laravel project — same instinct, same file.

3 · A GitHub repo & a strong README

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:

SectionWhat it answers
WhatOne sentence: what the project is. "DocChat lets you upload documents and ask questions answered by RAG."
WhyThe problem it solves and the stack used — FastAPI, Postgres, Next.js, an LLM.
RunExact copy-paste commands to run it locally. If they can't run it, it doesn't count.
ScreenshotsA 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.

4 · Docker: images vs containers

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.

image vs container — an image is the blueprint: a frozen, read-only snapshot of your app and its dependencies. A container is a running instance of that image. One image, many containers — like a class versus its objects, or a .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

5 · docker compose: app + Postgres

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.

6 · Environments & secrets

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  (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.
The mistake that gets you rejected Committing a real API key or password to GitHub — even once, even if you delete it later — is a visible red flag (and a security incident). It stays in your history forever. Add .env to .gitignore before your first commit, not after.

7 · Multi-stage builds

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.

builder vs final stage — each 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 running composer install --no-dev in CI and deploying only vendor/ plus your code — the dev toolchain never reaches the server.

8 · Smaller, safer images

The base image you pick decides your floor for size and security. Three common choices for Python:

BaseTrade-off
python:3.13Full image — huge (~1 GB), bundles tools you rarely need. Avoid for production.
python:3.13-slimThe pragmatic 2026 default. Small, Debian-based, so normal Python wheels install cleanly.
python:3.13-alpineSmallest, 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.

Interview answer: "How do you make a Docker image smaller?" Four levers, in order of impact: (1) a multi-stage build so build tools and caches never reach the final image; (2) a slim base like 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.

9 · Health checks & readiness

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.

10 · Build context & caching

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.

11 · Compose overrides & .env.example

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=

12 · Build it

Your tangible win Take the DocChat backend and make it a real, version-controlled, containerised project: write its Dockerfile and .gitignore, then initialise the Git repo with a clean first commit. After this, the next lesson just deploys it.

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.

13 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

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?

Primary source ⭐ Docker — Get Started. The authoritative, current walkthrough of images, containers, and Compose. For Git, the free and definitive book is Pro Git — read chapters 2 and 3.