The hour-one setup nobody writes down: a terminal you can drive, git that isn't scary, Python isolated per project, Postgres in a container, secrets in a file you never commit — and how to read an error message instead of pasting it somewhere.
This is a build track, not a reading track. Every module ends with something running on your machine that did not exist that morning.
Five rules. They matter more than any individual page here.
What this track deliberately does not do — it does not teach you every feature of any framework, it does not make your code elegant, and it does not cover frontend yet. The frontend half is on the track index as a published roadmap. What it does do is get you to the point where you can read a backend codebase you have never seen and follow what happens.
You do not need to be fast at this. You need to stop being afraid of it. A terminal cannot break your computer in normal use, and every command below only reads or creates — none of them delete anything.
pwd # print working directory - "where am I?"
ls # list files here
ls -la # ... including hidden ones (dotfiles), with details
cd projects # move into the projects folder
cd .. # move up one level
cd ~ # go to your home folder
mkdir city-guide # make a new folder
cat README.md # print a whole file
less README.md # page through a long file (press q to quit)
grep -rn "database" . # find "database" in every file below here, with line numbers
curl http://localhost:8000/health # make an HTTP request from the terminal
which python3 # where does this command actually live?
Two symbols do most of the heavy lifting. The pipe | feeds one command's output into the next, and > writes output into a file instead of the screen:
ls -la | grep ".py" # only the Python files
cat app.log | grep "ERROR" # only the error lines
cat app.log | grep "ERROR" | wc -l # ... and count them
echo "SECRET=abc" > .env # write into a file (overwrites!)
echo "DEBUG=1" >> .env # append to a file (safer)
cd city then press Tab. Half of terminal speed is just Tab.pwd before you panic.git init # start tracking this folder
git status # what has changed? RUN THIS CONSTANTLY
git add . # stage everything changed
git add app/main.py # ... or stage one file
git commit -m "add places endpoint" # save a snapshot with a message
git log --oneline # list past snapshots, newest first
git diff # what did I change since the last commit?
Staging is the part that confuses everyone. Git has three places a change can live: your working folder (you edited a file), the staging area (you ran git add), and history (you ran git commit). The staging area exists so you can commit some of your changes and not others.
Before your first commit, create a .gitignore file. This tells git which files to never record — and it is the single most important file in this module, because it is what stops you committing a password:
# .gitignore
.env # secrets - NEVER commit this
__pycache__/ # Python's compiled cache
*.pyc
.venv/ # your virtual environment, rebuildable from requirements.txt
.DS_Store # macOS folder metadata
*.db # local database files
.gitignore comes before your first commit, not after..venv folder next to your code, never committed to git.python3 -m venv .venv, then activate it, then install into it.Why this exists. Project A needs version 1 of a library. Project B needs version 3. If both install into your system Python, one of them breaks — and the breakage looks like a mysterious error in code you did not touch. A virtual environment gives each project its own isolated shelf.
python3 --version # need 3.11 or newer for this track
mkdir city-guide && cd city-guide
python3 -m venv .venv # create the environment
source .venv/bin/activate # activate it (macOS / Linux / WSL)
# your prompt now shows (.venv)
pip install fastapi uvicorn # installs INTO .venv, not your system
pip freeze > requirements.txt # record exact versions so others can rebuild it
deactivate # leave the environment
The (.venv) prefix on your prompt is how you know it is active. If it is missing, pip install is polluting your system Python and python is running the wrong interpreter — which produces the single most common beginner error message in the world:
ModuleNotFoundError: No module named 'fastapi'
You installed it. It is genuinely there. You are just not standing in the environment where it lives. Run source .venv/bin/activate and try again.
.venv/. It is hundreds of megabytes and machine-specific. requirements.txt is the thing you commit — it is the recipe, not the meal.pip freeze writes fastapi==0.115.0, not fastapi. A project that installs "whatever is newest" breaks on a Tuesday for no reason.node_modules/ folder and package.json, which is why it needs no activation step. Ruby uses Bundler, Java uses Maven or Gradle. Same idea everywhere: dependencies belong to a project, not to a computer.
docker-compose.yml, run docker compose up -d.You could install Postgres directly. Do not. Native installs differ per operating system, leave background services running you forget about, and are painful to reset when you inevitably corrupt your practice data. A container is disposable, which is exactly what you want while learning.
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: cityguide
POSTGRES_PASSWORD: localdevpassword
POSTGRES_DB: cityguide
ports:
- "5432:5432" # host port : container port
volumes:
- pgdata:/var/lib/postgresql/data # keep data between restarts
volumes:
pgdata:
docker compose up -d # start it in the background
docker compose ps # is it running?
docker compose logs db # what did it print?
docker compose down # stop it (data survives, it is in the volume)
docker compose down -v # stop AND delete the data - your reset button
# open a SQL prompt inside the running container
docker compose exec db psql -U cityguide -d cityguide
Read that ports line carefully, because it is the source of half of all container confusion. "5432:5432" means "traffic arriving at port 5432 on my laptop should be forwarded to port 5432 inside the container". The container is a separate little machine with its own network. Nothing inside it is reachable unless you publish a port.
-d means detached — it runs in the background and gives you your prompt back. Without it, the logs stream into your terminal and Ctrl+C stops the database.pgdata. That is also why down -v is the real reset — it removes the volume too."5433:5432" and connect to 5433 instead.localdevpassword lives on your laptop, on a port only your machine can reach. The moment anything is reachable from outside your machine, this becomes a real credential and belongs in the environment, not in a committed file. Module 10 covers doing this properly.
.env file locally (git-ignored), real environment variables everywhere else..env.example listing every key with fake values, and an ignored .env holding the real ones.# .env <- git-ignored, real values, never committed
DATABASE_URL=postgresql://cityguide:localdevpassword@localhost:5432/cityguide
JWT_SECRET=dev-only-not-a-real-secret
LOG_LEVEL=debug
# .env.example <- committed, documents what is needed
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=
LOG_LEVEL=debug
The pair matters. .env.example is how the next person — including you on a new laptop — knows which values the project needs without ever seeing a real one. It is documentation that cannot go stale, because the app fails immediately if a key is missing.
git status before every commit in your first weeks. If .env appears in that list, your .gitignore is wrong. Fix it before committing, not after.PORT=8000 arrives in your program as the text "8000". Converting and validating them is real work, which is why Module 10 does it with a settings object rather than scattered os.getenv calls.Traceback (most recent call last):
File "/app/.venv/lib/python3.12/site-packages/uvicorn/protocols/http.py", line 435, in run_asgi
result = await app(self.scope, self.receive, self.send)
File "/app/main.py", line 42, in get_place
return places[place_id]["name"]
~~~~~~^^^^^^^^^^
KeyError: 99
Read it in this order. Last line: KeyError: 99 — the kind of failure and the offending value. Then walk up to the deepest frame in a file you wrote: /app/main.py, line 42. Everything above that is library code doing its job; it is context, not cause. So: something asked for place 99, and 99 is not in the dictionary.
That is the whole method, and it works on every traceback you will ever see. The frames from site-packages are almost never where your bug is — they are the path the request took to reach your mistake.
KeyError: 99, TypeError: unsupported operand type(s) for +: 'int' and 'str', ConnectionRefusedError — each names both the category and the specific value. Most of the answer is already there.KeyError: 99 finds nothing useful. Searching "python KeyError dict missing key" finds the concept.print(place_id, type(place_id), places.keys()) above the failing line answers more than twenty minutes of staring. Module 10 replaces prints with real logging; until then, print freely.ConnectionRefusedError almost always means the other thing is not running. Database, server, container. Check docker compose ps first.city-guide that contains a git repository with at least two commits, an active virtual environment with FastAPI installed, a Postgres container you can open a SQL prompt against, and a .env file that git refuses to track. Nothing runs yet — this is the ground the next twelve modules are built on.
git init, and write .gitignore before anything else..venv, install fastapi and uvicorn, write requirements.txt.docker-compose.yml and bring Postgres up. Open psql and run SELECT version();..env and .env.example. Confirm git status does not list .env.docker compose down, try to connect, and read the error you get.Run each command. If the output does not match, the module is not finished — and that is useful information, not a failure.
$ pwd
/…/city-guide # you are in the project folder
$ git status --short
# empty output = everything committed
$ git check-ignore -v .env
.gitignore:1:.env .env # proof .env is ignored. No output = BROKEN
$ source .venv/bin/activate && python -c "import fastapi; print(fastapi.__version__)"
0.128.8 # any version number is fine - the point is that it prints
# one. ModuleNotFoundError means the venv is not active.
$ docker compose ps
NAME IMAGE ... STATUS PORTS
city-guide-db-1 postgres:16 ... Up 2 minutes 0.0.0.0:5432->5432/tcp
# columns trimmed here to fit; STATUS must say Up
$ docker compose exec db psql -U cityguide -d cityguide -c "SELECT 1 AS ok;"
ok
----
1
(1 row) # the database answered
$ docker compose down && docker compose exec db psql -U cityguide -c "SELECT 1;"
service "db" is not running # the deliberate break. Now bring it back up.
psql is normal. If you see connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory, the container is up but Postgres inside it has not finished starting. Wait a few seconds and run it again, or wait for it properly with docker compose exec db pg_isready -U cityguide. This gap between "container started" and "database ready" is the same one that causes a whole class of bug in Module 11.
git check-ignore prints nothing, stop and fix it now. That silence means .env is trackable, and the first time you put a real key in it you will commit it. This is the one check in this module with consequences beyond your laptop.
Answer each in writing, in your own words, in notes/module-00.md. Then open the answer and compare. Where yours differs, work out which of you is right — that is where the learning is.
1. What does activating a virtual environment actually change?
It changes your shell's PATH so that python and pip resolve to the copies inside .venv/bin instead of the system ones. Nothing is installed or moved; a single environment variable is rewritten for that terminal session. That is also why it applies per terminal window, why deactivate restores the old value instantly, and why which python is the fastest way to check whether it worked.
2. Your app connects to localhost:5432, but Postgres is inside a container. Why does that work?
Because of the published port. ports: - "5432:5432" tells Docker to listen on port 5432 of your machine and forward anything arriving there into port 5432 of the container. Your app never talks to the container directly — it talks to your own machine, and Docker relays it. Remove that line and the database still runs perfectly, but nothing outside the container can reach it. (In Module 11, when your app is also a container, it will reach the database by service name instead, and no published port is needed at all.)
3. You committed a file containing a real password, noticed, deleted it, and committed again. Is the password safe?
No. Git stores history, not just the current state — the old commit still contains the file, and anyone with the repository can read it with git log -p. Deleting it in a later commit hides it from the current checkout and nothing else. The only correct response is to treat that password as compromised and rotate it: change it at the source. Rewriting history is possible but secondary, and useless if the repository was ever pushed or shared. This is why .gitignore is written before the first commit.
4. What is the difference between docker compose down and docker compose down -v?
down stops and removes the containers but leaves named volumes alone, so pgdata survives and your tables and rows are still there when you come back up. down -v also deletes those volumes, which permanently destroys the database contents. The first is "turn it off", the second is "start again from nothing". You will want the second more often than you expect while learning — a broken practice schema is not worth debugging.
5. Given this traceback, where is the bug and what is it?
File "/app/.venv/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
await response(scope, receive, send)
File "/app/main.py", line 18, in create_place
total = place["rating"] + 1
TypeError: can only concatenate str (not "int") to str
Line 18 of main.py, the file you wrote — the starlette frame above it is library code that called you, not the cause. The message says the left operand is a str, so place["rating"] arrived as text like "4" rather than the number 4. The immediate fix is int(place["rating"]), but the real fix is to stop untyped values entering the function at all — which is exactly the job of the request models in Module 06.