Skip to content

Phase 2 · CONTAINERS & CI/CD

Project 1, Day 1: scope & scaffold

Day 41 of 90 ~60 min 0/25 in phase Builds on Day 40

By the end of today

  • Scope linkstash to three endpoints so the pipeline, not the code, is the focus
  • Scaffold the repo: git init, a .gitignore, and a Python venv
  • Run a FastAPI /healthz route locally with uvicorn and curl it

Scoping linkstash and scaffolding a repo you can ship

Section 1 of 5 · ~2 min

Welcome to Project 1. Over the next five days you take one small app from an empty folder to a versioned image running from a full local stack, wired by CI. The app is linkstash, a URL shortener, and it is deliberately tiny — because the point of this week is the DevOps pipeline around it (containerize → compose → CI → ship), not the application code.

What linkstash does. Three endpoints, no more:

  • POST /shorten with {"url": "..."} returns a short {"code": "..."}.
  • GET /{code} answers 307 and redirects to the original URL.
  • GET /healthz returns 200 — a machine-readable “I’m alive”.

The stack is Python 3.12 + FastAPI, served by Uvicorn, with PostgreSQL 16 arriving on Day 43. Today there is no database and no Docker: you scope the app, scaffold the repository, and get a FastAPI skeleton answering on localhost.

Real world: A URL shortener is a coat check. You hand over a long, unwieldy coat (the URL) and get a small numbered ticket (the code); later you present the ticket and get the exact coat back (the redirect). POST /shorten is checking the coat in; GET /{code} is claiming it.

                 linkstash  (FastAPI + Uvicorn)
   client ──►  POST /shorten  {"url": "..."}  ──►  {"code": "ab12"}
   client ──►  GET  /{code}                   ──►  307 redirect → original URL
   probe  ──►  GET  /healthz                   ──►  200 {"status": "ok"}

The one design decision that matters today: add /healthz from the very first commit. It feels pointless now — of course the app is up, you just started it. But every layer you add this week wants to ask the app whether it is healthy without parsing real traffic. On Day 43, Compose will gate the web container on a database healthcheck; a scheduler or load balancer does the same in production. AWS Elastic Load Balancing, for instance, polls a path you nominate every few seconds and only routes traffic to targets that return 200 — an app with no health route is one the balancer can’t trust. Building /healthz on day one means the plumbing is already there when the rest of the stack arrives.

Two habits keep the repo clean from commit one. First, a .gitignore: the virtual environment, __pycache__, compiled *.pyc, and any local .env never belong in version control — they’re machine-specific or secret. Commit them once and every teammate (and every CI runner) inherits your clutter. Second, a virtual environment. Ubuntu 24.04 marks its system Python as externally managed (PEP 668): a bare pip install is refused, on purpose, so you don’t fight apt over shared packages. The fix isn’t --break-system-packages; it’s a per-project venv — python3 -m venv .venv — an isolated Python whose dependencies live beside the code and are pinned in requirements.txt.

That pinned requirements.txt is exactly what Day 42’s Dockerfile copies and installs first, so the dependency layer caches. Scaffolding well today is what makes tomorrow’s container step read cleanly.

Hands-On Lab

Section 2 of 5 · ~3 min

Budget about 30 minutes. Open your WSL2 Ubuntu 24.04 terminal (Python 3.12 ships with the distro). You’ll create the linkstash repo, isolate a venv, install FastAPI and Uvicorn, write a skeleton with /healthz and a stub /shorten, run it, and curl both routes. Type every command and read every line of output — object hashes and commit SHAs are yours-will-differ.

# 1. Make the project folder and start a git repo on the main branch.
mkdir -p ~/linkstash && cd ~/linkstash
git init -b main
# Output:
# Initialized empty Git repository in /home/pushkar/linkstash/.git/
# 2. Ubuntu 24.04's Python is externally managed (PEP 668) — always work in a venv.
python3 -m venv .venv
source .venv/bin/activate
python --version
# Output:
# Python 3.12.3
# 3. Keep machine-specific and secret files out of version control from commit one.
cat > .gitignore <<'EOF'
.venv/
__pycache__/
*.pyc
.env
*.db
EOF
cat .gitignore
# Output:
# .venv/
# __pycache__/
# *.pyc
# .env
# *.db
# 4. Pin the two runtime dependencies. uvicorn[standard] adds the fast reload/HTTP extras.
cat > requirements.txt <<'EOF'
fastapi==0.115.0
uvicorn[standard]==0.32.0
EOF
cat requirements.txt
# Output:
# fastapi==0.115.0
# uvicorn[standard]==0.32.0
# 5. Install into the active venv — no --break-system-packages needed.
pip install -r requirements.txt
# Output (trimmed; resolved versions are yours-will-differ):
# Collecting fastapi==0.115.0 ...
# Collecting uvicorn[standard]==0.32.0 ...
# Installing collected packages: ... anyio starlette fastapi uvicorn watchfiles ...
# Successfully installed anyio-4.6.0 fastapi-0.115.0 pydantic-2.9.2 starlette-0.38.6 uvicorn-0.32.0 ...
# 6. Create the application package that will hold main.py.
mkdir app
ls
# Output:
# app  requirements.txt

Save this as app/main.py. /healthz is real; /shorten is a stub returning a fixed code — the real generator and the GET /{code} redirect land once Postgres arrives on Day 43:

# app/main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="linkstash")


class ShortenRequest(BaseModel):
    url: str


@app.get("/healthz")
def healthz():
    return {"status": "ok"}


@app.post("/shorten")
def shorten(req: ShortenRequest):
    # Stub: real code generation + Postgres storage arrive on Day 43.
    return {"code": "stub123"}
# 7. Run the app. app.main:app = the `app` object in app/main.py. Leave this running.
uvicorn app.main:app --reload --port 8000
# Output:
# INFO:     Will watch for changes in these directories: ['/home/pushkar/linkstash']
# INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
# INFO:     Started reloader process [12345] using WatchFiles
# INFO:     Started server process [12347]
# INFO:     Application startup complete.
# 8. In a SECOND WSL tab, prove the health route answers 200.
curl -s http://localhost:8000/healthz
# Output:
# {"status":"ok"}
# 9. Post a long URL to the stub and read the placeholder code back.
curl -s -X POST http://localhost:8000/shorten \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/some/really/long/path"}'
# Output:
# {"code":"stub123"}
# 10. Back in the first tab press Ctrl+C to stop uvicorn, then make the first commit.
git add .gitignore requirements.txt app/main.py
git commit -m "chore: scaffold linkstash — FastAPI skeleton, venv, gitignore"
# Output (the commit SHA is yours-will-differ):
# [main (root-commit) a1b2c3d] chore: scaffold linkstash — FastAPI skeleton, venv, gitignore
#  3 files changed, 26 insertions(+)
#  create mode 100644 .gitignore
#  create mode 100644 app/main.py
#  create mode 100644 requirements.txt
# 11. See the repo you scaffolded (the .venv and .git internals are excluded).
tree -a -I '.venv|.git'
# Output:
# .
# ├── .gitignore
# ├── app
# │   └── main.py
# └── requirements.txt
#
# 1 directory, 3 files

Read the last steps back: an empty folder became a versioned repo with an isolated venv, a skeleton FastAPI app answering 200 on /healthz, and a stubbed /shorten — three tracked files, nothing machine-specific committed. That is the foundation the next four days build on: tomorrow you wrap this exact layout in a Dockerfile.

Common Errors & Fixes

Section 3 of 5 · ~2 min

These three trip up almost everyone scaffolding a Python service on Ubuntu 24.04. Read the error text slowly — learning to parse it is the actual skill.

Common error: Running pip install fastapi before creating or activating a venv prints:

error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install ...
    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv ...

Why: PEP 668 lets a distro mark its system Python as owned by apt. Ubuntu 24.04 does exactly that, so pip refuses to install into the base interpreter and risk breaking OS tools that depend on specific versions.

Fix: Create and activate a virtual environment (python3 -m venv .venv && source .venv/bin/activate), then pip install inside it. Do not reach for --break-system-packages — it does what it says and can corrupt system utilities.

How you’d spot it in prod: A build or setup script failing at its first pip install with “externally-managed-environment” means it’s installing into a system Python instead of a venv. Fix the script to provision and use a venv (or install in a container), not to override the guard.

Common error: python3 -m venv .venv fails on a fresh Ubuntu with:

The virtual environment was not created successfully because ensurepip is not
available.  On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command:
    apt install python3.12-venv

Why: Ubuntu splits the venv/ensurepip tooling into a separate package that isn’t always present on a minimal install, so the interpreter is there but the venv builder isn’t.

Fix: sudo apt update && sudo apt install python3.12-venv, then re-run python3 -m venv .venv. On a slim base image you’d install it the same way in the Dockerfile.

How you’d spot it in prod: A CI runner or base image that can build a venv locally but fails in the pipeline usually lacks python3-venv. It’s an image/provisioning gap, not a code bug — add the package to the image, not a workaround to your script.

Common error: Starting the server with the wrong import path — uvicorn main:app from the repo root, or after forgetting the package folder — prints:

ERROR:    Error loading ASGI app. Could not import module "main".

Why: The argument is module_path:variable. main.py lives inside the app/ package, so the module path is app.main, and the FastAPI object is named app — giving app.main:app. Uvicorn imports relative to the current directory, and main alone doesn’t exist there.

Fix: Run uvicorn app.main:app --reload --port 8000 from the repo root. If you renamed the FastAPI object, match the part after the colon to that variable name.

How you’d spot it in prod: A container that crash-loops immediately with “Could not import module” almost always has a wrong module path in its CMD/entrypoint — check the app.module:variable string against the actual file layout before suspecting the code.

Project Scaffolding Interview Questions

Section 4 of 5 · ~1 min

The health-check reasoning, .gitignore hygiene and the PEP 668 venv answer below are exactly what a screening round probes when it moves from “can you write an endpoint?” to “can you set a project up properly?” — cover each answer, say your own version out loud first, then compare, because recalling before revealing is what makes it stick. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — With uvicorn running, open http://localhost:8000/docs in your browser. FastAPI generates a live Swagger UI for free; expand POST /shorten and click “Try it out” to hit the stub from the page.
  • 10 min — Read FastAPI’s First Steps tutorial and note how the app object, path operations and the --reload flag map onto the skeleton you just wrote.
  • 15 min — Skim the health-checks and configuration sections of the Docker for DevOps guide to see why the /healthz route you added today becomes load-bearing the moment Compose and CI arrive later this week.
Why give a service a dedicated health-check endpoint like /healthz? Both

It's a cheap, machine-readable way for other systems to ask 'are you alive?' without sending real traffic. A load balancer, an orchestrator like Kubernetes, or Docker Compose polls it on a schedule and only routes work to instances that answer 200. Keeping it separate from business routes matters: a basic liveness check shouldn't require auth or a database round-trip, so a momentary DB blip doesn't get the whole app killed — a deeper readiness probe that touches dependencies is a second endpoint. I add /healthz on the first commit because every later layer — Compose, CI smoke tests, the deploy target — wants it, and retrofitting it is pure friction.

What belongs in a .gitignore for a Python project, and why does it matter? Both

The virtual environment (.venv/), byte-compiled caches (__pycache__/, *.pyc), local secrets (.env), and any local database or build artifacts. None belong in version control: the venv is machine-specific and huge, caches regenerate automatically, and .env holds credentials you must never commit. The rule of thumb is that anything reproducible from source or specific to one machine stays out; only source and pinned manifests like requirements.txt go in. Committing a venv or a .env is a classic first-project mistake — it bloats the repo and can leak secrets into git history, which is painful to purge. A good .gitignore on commit one prevents both.

Ubuntu 24.04 refuses pip install with 'externally-managed-environment'. Why, and what's the fix? Both

Since PEP 668, distributions like Ubuntu 24.04 mark the system Python as externally managed — its packages are owned by apt, so pip installing into it can break OS tools that depend on specific versions. Rather than fight apt, you isolate: create a per-project virtual environment with python3 -m venv .venv, activate it, and pip install there. Inside the venv pip works normally and dependencies live beside your code, pinned in requirements.txt. The tempting shortcut, pip install --break-system-packages, does exactly what it says and is the wrong answer in an interview — it risks the base system. A venv (or pipx for CLI tools) is the correct, reproducible fix, and it's what your Dockerfile mirrors later.

How do you keep a project scoped so the infrastructure work, not the app code, stays the focus? Service

Start by writing down the smallest thing that's still a real service — for linkstash, three endpoints and one table — and refuse features that don't serve the DevOps goal. A URL shortener is ideal: everyone understands it, it needs a database so Compose and volumes matter, and it has almost no business logic to distract you. I stub the endpoints first and wire the real logic last, so the pipeline exists before the code is 'done'. Scoping like this is a real skill: on the job you constantly trade feature scope against delivery, and a tight scope is what actually lets you ship.

Mark Day 41 complete

Tomorrow you containerize linkstash: a multi-stage Dockerfile on python:3.12-slim with a non-root USER and a .dockerignore.

Stuck on today’s lab? Ask in Mission 90 Q&A