A complete methodology for building software projects: how to think and build from scratch to deployment, from CLI tools to full-stack apps.
Most developers don't get stuck because they lack tools. They get stuck because they lack a repeatable process. This playbook is that process, written down: a fixed sequence of steps that takes you from a blank folder to a deployed, tested, documented project.
Who it's for
- Students and junior developers building their first portfolio projects
- Self-taught developers who know the syntax but not the workflow
- Anyone who starts projects with enthusiasm and abandons them at hour three
What's inside
- π§ The philosophy: why you get stuck and how to think before you code
- π οΈ The 9-step process: understanding, pseudocode, design, code, tests, refactor, docs, deploy, practice
- π The technical workflow: Git branching, commits, essential files, CI/CD
- π A full deployment guide: Streamlit Cloud, Render, Neon/Supabase, Vercel, Docker
- π Case studies: real projects broken down branch by branch
- β Checklists: copy-paste checklists for the start and end of every project
Stack at a glance
| Path | Stack | Best for |
|---|---|---|
| Classic | Python, Flask / Streamlit, SQLAlchemy, MySQL / PostgreSQL | Fast portfolio projects, dashboards |
| Modern full-stack | FastAPI, React, PostgreSQL, Alembic, Docker | Production-style apps with a separate frontend |
| CLI | Python stdlib, argparse, JSON | Small tools, learning fundamentals |
How to use it
- Read Part A once, since it changes how you approach projects.
- Before every project, walk through Part B in order.
- Use Part C as a reference while you work (Git, files, CI).
- Use Section 10 when you're ready to ship.
- Copy the checklists in Part E into each new project.
- PART A β The Philosophy
- PART B β The Process (9 Steps)
- PART C β The Technical Workflow
- PART D β Case Studies
- PART E β Checklists & Templates
You say: "I don't know the process well enough yet."
That's the right starting point. Every junior developer feels the same. The difference is that most think the problem is:
- β "I don't know enough technologies"
- β "I haven't read enough tutorials"
- β "I haven't built enough projects"
Wrong. The real problem is:
You haven't internalized the PROCESS. You know Python, Flask, FastAPI, SQL, React β but you don't know the sequence of steps that takes you from blank to finished.
It's like knowing how to use a hammer, nails, and a saw β but not knowing how to build a table. You know the tools, not the method.
Before:
- Start a project β open the editor β start coding β 3 hours later you're stuck β don't know what's missing β give up.
After:
- Start a project β think for 30 minutes on paper β write pseudocode β split into modules β write tests first β write code β refactor β deploy.
The difference: You think first, then you write.
Every project goes through 3 phases of thinking:
"What do I want to do, in plain words?"
You think at a high level. You don't touch code.
Example: "I want a CLI that tracks expenses and shows me totals."
"What small pieces does it break into?"
You split the big thing into small pieces. Each piece becomes a module or a function.
Example:
Expense Tracker CLI:
βββ Add expense
βββ View expenses
βββ Summary by category
βββ Persistence (JSON)
"How do I turn this into code?"
You write actual code. The thinking is already done β now you execute.
Example: expense_tracker.py, storage.py, models.py
π Rule: 80% thinking, 20% coding.
Juniors do the opposite: 5% thinking, 95% coding β they rewrite everything 3 times.
Write the answers in a NOTES.md before opening the editor.
| # | Question | Example (Expense Tracker) |
|---|---|---|
| 1 | What problem am I solving? (1 sentence) | "I want to track expenses and see totals." |
| 2 | Who uses it and how? | Me, via terminal |
| 3 | Inputs? | amount: float, category: str, description: str |
| 4 | Outputs? | Expense list, totals, messages |
| 5 | Constraints? | amount > 0, category from a list, persistence |
| 6 | Edge cases? | amount="abc", empty list, huge number |
π‘ For web apps, also answer: What are the API contracts (endpoints, request/response shapes)? Who is authenticated, and what can each user see? Which secrets does the app need?
Rule: If you can't describe a sub-problem in 1-2 sentences, it's too big.
Example:
"Track and analyze expenses"
βββ 1. Add an expense
β βββ 1a. Validate amount
β βββ 1b. Validate category
β βββ 1c. Save
βββ 2. View expenses
βββ 3. Summary by category
βββ 4. Persistence (JSON)
Each leaf β one function or one class.
- β Every leaf is clear on what it does
- β Every leaf can be written independently
- β You can estimate time for each leaf
If not β split more.
For a full-stack project, split first by layer, then by feature:
Full-Stack Expense Tracker
βββ Backend (FastAPI + SQLAlchemy + Alembic)
β βββ Auth (JWT / OAuth)
β βββ Expenses CRUD & Caching
β βββ Database models & Migrations
βββ Frontend (React + Tailwind + Vitest)
βββ Authentication UI
βββ Dashboard & Charts
βββ API Integration (Fetch/Axios)
Write the logic in plain English, using fixed keywords:
FUNCTION add_expense(amount, category):
IF amount <= 0 THEN
RETURN error
IF category NOT IN valid_list THEN
RETURN error
CREATE new expense
APPEND to list
RETURN confirmation
Why: You think about the logic without worrying about Python syntax. When you write it in Python, the translation is nearly 1-to-1.
Take the pseudocode and "run" it by hand:
Input: amount = -5, category = "Food"
Step 1: -5 <= 0 β YES
Step 2: Returns error "Amount must be positive"
β
Correct.
Rule: Dry run with 3 scenarios:
- Happy path:
amount=50, category="Food"β β adds - Edge case:
amount=0β β rejects - Invalid:
amount="abc"β β rejects
Shows:
- Class name
- Attributes (data)
- Methods (behaviors)
βββββββββββββββββββββββ
β Expense β
βββββββββββββββββββββββ€
β - amount: float β
β - category: str β
β - date: date β
βββββββββββββββββββββββ€
β + to_dict() -> dict β
βββββββββββββββββββββββ
βββββββββββββββββββββββββββββββ
β ExpenseTracker β
βββββββββββββββββββββββββββββββ€
β - expenses: list[Expense] β
βββββββββββββββββββββββββββββββ€
β + add_expense(...) β
β + get_summary() β
βββββββββββββββββββββββββββββββ
Questions:
- What "things" (nouns) exist? β Classes
- What data "belongs" to each thing?
- What actions (verbs) does it do?
- Do I need a class or a function? (If no state, a function is enough.)
Each file = one responsibility (SRP).
Classic layout (Flask / Streamlit / CLI):
project/
βββ src/
β βββ __init__.py
β βββ main.py # Entry point
β βββ models/ # Domain models
β βββ services/ # Business logic
β βββ routes/ # HTTP endpoints
β βββ storage/ # Persistence
β βββ ui/ # Presentation
βββ tests/
βββ requirements.txt
βββ .env.example
βββ .gitignore
βββ README.md
Full-stack layout (FastAPI + React):
project/
βββ backend/
β βββ src/
β β βββ models/ # SQLAlchemy models
β β βββ schemas/ # Pydantic schemas
β β βββ services/ # Business logic
β β βββ routers/ # FastAPI endpoints
β β βββ core/ # Config, database, security
β βββ alembic/ # Database migrations
β βββ tests/ # Pytest (unit & integration)
β βββ Dockerfile
β βββ requirements.txt
βββ frontend/
β βββ src/ # React components & hooks
β βββ tests/ # Vitest / E2E tests
β βββ package.json
βββ docker-compose.yml
βββ .env.example
βββ .gitignore
βββ README.md
Key Question: "If I change storage (e.g. JSON β DB), how many files do I touch?"
- β
1 (
storage/) β good design - β 5 β tight coupling
Before writing endpoints, write the contract:
| Method | Path | Auth | Request | Response |
|---|---|---|---|---|
POST |
/auth/register |
No | {email, password} |
201 {id, email} |
POST |
/auth/login |
No | {email, password} |
200 {access_token} |
GET |
/expenses |
JWT | β | 200 [Expense] |
POST |
/expenses |
JWT | {amount, category} |
201 Expense |
Also sketch an ERD (tables, primary keys, foreign keys) before creating any models.
- One function, one job (SRP)
- Name = verb (
add_expense, notexpense_stuff) - Type hints always
- Docstring on every non-obvious function
def add_expense(
tracker: "ExpenseTracker",
amount: float,
category: str,
description: str = "",
) -> "Expense":
"""Add a new expense after validating it.
Args:
tracker: The tracker to add to.
amount: Amount > 0.
category: Category from the allowed list.
description: Optional.
Returns:
The new Expense.
Raises:
ValueError: If amount <= 0 or category is unknown.
"""
if amount <= 0:
raise ValueError("Amount must be positive")
...- Represents a "thing" (not just a bag of functions)
__init__= initialization only (no heavy logic)- Encapsulation β underscore for private attributes
- A class shouldn't know too much about other classes
| Need a class if... | Need a function if... |
|---|---|
It has state (self.x) |
No state |
| You want inheritance | No relation to others |
| You want many objects of the same type | Pure logic |
Example:
ExpenseTrackerβ class (holds the list of expenses)hash_password(pw)β function (no state)
- Never hardcode secrets, URLs, or credentials. Read them from environment variables.
- Load config in one place (
core/config.py) and import it everywhere else. - Use different configs for development, testing, and production.
- Initialize external connections lazily (on first use, not at import time) so tests and CI don't crash.
# core/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Settings:
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./dev.db")
SECRET_KEY: str = os.getenv("SECRET_KEY", "")
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
settings = Settings()- Validate at the boundary. Check user input where it enters the system (routes, CLI args), not deep inside.
- Raise specific exceptions (
ValueError, customNotFoundError), never bareException. - Never swallow errors silently (
except: pass). Log them or re-raise. - In APIs, map exceptions to HTTP status codes (
400bad input,401unauthenticated,403forbidden,404missing,422validation,500unexpected).
- Never edit a production schema by hand. Use a migration tool (Alembic for SQLAlchemy).
- Every schema change = one migration file, committed to Git.
alembic revision --autogenerate -m "add expenses table"
alembic upgrade head # apply
alembic downgrade -1 # roll back one step- β Hash passwords with bcrypt or argon2 (never store plain text, never use MD5/SHA1)
- β Scope every query by the current user (prevents users reading each other's data)
- β Use parameterized queries / the ORM (prevents SQL injection)
- β Keep JWT secrets long, random, and in env vars
- β Restrict CORS to your real frontend origin in production
- β Rotate any secret that was ever committed, even briefly
For every function, write at least 3 tests:
def test_add_expense_happy_path():
"""Normal scenario."""
tracker = ExpenseTracker()
tracker.add_expense(50.0, "Food")
assert len(tracker.expenses) == 1
def test_add_expense_rejects_negative():
"""Edge case: negative."""
with pytest.raises(ValueError):
ExpenseTracker().add_expense(-10, "Food")
def test_add_expense_rejects_unknown_category():
"""Invalid input."""
with pytest.raises(ValueError):
ExpenseTracker().add_expense(10, "Unknown")Why 3:
- Happy path β the basic works
- Edge case β works at the boundaries
- Invalid β rejects bad input
/\
/E2E\ few β full user flows (slow)
/------\
/Integr. \ some β API + database together
/----------\
/ Unit \ many β single functions (fast)
/--------------\
| Layer | Tool (backend) | Tool (frontend) | What it checks |
|---|---|---|---|
| Unit | pytest |
Vitest |
One function / component |
| Integration | pytest + test DB (SQLite in-memory) |
React Testing Library | Layers working together |
| E2E | pytest + httpx / Playwright |
Playwright / Cypress | Real user flows |
Tips:
- Use fixtures for repeated setup (database session, test client).
- Mock external APIs so tests are fast and don't depend on the network.
- Track coverage with
pytest --cov=src(aim for meaningful coverage, not 100%).
- Read the traceback β bottom to top
- Isolate β comment out pieces
- Print/Log intermediate values
- Form a hypothesis β "I think it's X" β test it
- 20-25 minute rule β if stuck, ask for help
print() disappears. logging keeps history:
from loguru import logger
logger.info(f"Added expense: {amount}")
logger.warning(f"Invalid amount: {amount}")
logger.error(f"Failed to save: {e}")Levels:
debugβ dev detailsinfoβ normal eventswarningβ unusualerrorβ something failed
Refactoring = Change the STRUCTURE, not the BEHAVIOR.
Questions:
- Code that repeats 2+ times? β Function
- Function > 20 lines? β Split it
- Magic numbers? β Constants
- Bad names? β Rename
- PEP 8? β
black,ruff
Catch formatting and lint issues before they reach Git:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-formatpip install pre-commit
pre-commit installDocstrings β the "what":
def add_expense(...):
"""Adds a new expense."""Comments β the "why":
# BAD: states the obvious
i += 1 # increments i
# GOOD: explains why
i = 1 # skip header row# Project Name
Short description.
## Features
- ...
## Tech Stack
- ...
## Installation
```bash
git clone ...
pip install -r requirements.txt
```
## Usage
```bash
python main.py
```
## Screenshots

## Testing
```bash
pytest tests/ -v
```
## License
MITFastAPI generates interactive docs automatically. Link them in your README:
- Swagger UI:
https://your-api.onrender.com/docs - ReDoc:
https://your-api.onrender.com/redoc
For Flask, document endpoints in the README as a table (method, path, auth, description).
Deployment is not the last-minute step. It's a skill with its own checklist. Follow the sections below in order.
- Clean venv β does it work from scratch? (
rm -rf .venv && python -m venv .venv && pip install -r requirements.txt) - Secrets in environment variables (not hardcoded)
-
.envin.gitignore -
requirements.txtup to date (pip freezeor manually curated) - All tests pass locally and in CI
- Error handling for production (no stack traces shown to users)
- Debug mode off (
DEBUG=false) - README up to date
- Code pushed to GitHub (all hosts below deploy from a Git repo)
| Project Type | Where | Time | Free Tier |
|---|---|---|---|
| Streamlit | Streamlit Community Cloud | 5 min | β |
| Flask | Render / Railway | 10 min | β (sleeps when idle) |
| FastAPI | Render / Railway | 10 min | β (sleeps when idle) |
| CLI | GitHub only (or PyPI) | β | β |
| Frontend (React/Vite) | Vercel / Netlify / GitHub Pages | 5 min | β |
| Database (PostgreSQL) | Neon / Supabase | 5 min | β |
| Full Stack | Vercel + Render + Neon | 30 min | β |
β οΈ Free tiers change often. Check each provider's current limits and pricing before you rely on them.
Always deploy from the bottom up, because each layer needs the one below it:
1. Database (Neon / Supabase) β get DATABASE_URL
β
βΌ
2. Backend (Render / Railway) β get API URL
β
βΌ
3. Frontend (Vercel / Netlify) β set VITE_API_URL to the API URL
β
βΌ
4. Back to backend β set CORS_ORIGINS to the frontend URL
- Create an account at neon.tech or supabase.com.
- Create a new project and pick the region closest to your backend.
- Copy the connection string.
- Make sure it matches your driver and requires SSL:
postgresql+psycopg2://USER:PASSWORD@HOST/DBNAME?sslmode=require
- Save it as
DATABASE_URL. You'll paste it into your backend host in the next step. - Run migrations against it once (from your machine, with the production URL exported temporarily):
export DATABASE_URL="postgresql+psycopg2://..."
alembic upgrade headπ‘ Use connection pooling (Neon's pooled connection string, or Supabase's pooler) for serverless/free-tier setups to avoid exhausting connections.
Prepare the repo:
requirements.txtincludes your server (gunicornfor Flask,uvicorn[standard]for FastAPI).- Add a health check endpoint:
@app.get("/health")
def health():
return {"status": "ok"}On Render:
- Go to render.com β New β Web Service β connect your GitHub repo.
- Configure:
| Setting | FastAPI | Flask |
|---|---|---|
| Root Directory | backend (if monorepo) |
repo root or subfolder |
| Runtime | Python 3 | Python 3 |
| Build Command | pip install -r requirements.txt |
pip install -r requirements.txt |
| Start Command | uvicorn src.main:app --host 0.0.0.0 --port $PORT |
gunicorn "src:create_app()" --bind 0.0.0.0:$PORT |
| Health Check Path | /health |
/health |
- Under Environment, add your variables:
DATABASE_URL=postgresql+psycopg2://...?sslmode=require
SECRET_KEY=<long random string>
DEBUG=false
CORS_ORIGINS=http://localhost:5173
PYTHON_VERSION=3.12.0
Generate a strong secret locally:
python -c "import secrets; print(secrets.token_urlsafe(48))"- Click Create Web Service. Wait for the build, then open
https://your-service.onrender.com/docs(FastAPI) or/health. - Copy the service URL. The frontend needs it.
β οΈ Free tier note: free web services sleep after a period of inactivity, so the first request can take 30-60 seconds. Mention this in your README so recruiters aren't surprised.
Running migrations on deploy: if your plan supports a pre-deploy command, set it to alembic upgrade head. Otherwise, chain it into the start command:
alembic upgrade head && uvicorn src.main:app --host 0.0.0.0 --port $PORT
Railway alternative: New Project β Deploy from GitHub repo β add the same environment variables β set the start command. The steps are nearly identical.
- Go to vercel.com β Add New β Project β import your GitHub repo.
- Configure:
| Setting | Value |
|---|---|
| Root Directory | frontend |
| Framework Preset | Vite (or Create React App / Next.js) |
| Build Command | npm run build |
| Output Directory | dist (Vite) or build (CRA) |
- Add the environment variable pointing at your backend:
VITE_API_URL=https://your-service.onrender.com
- Click Deploy. Vercel gives you a URL like
https://your-app.vercel.app.
In the frontend, read the URL from the environment, never hardcode it:
const API_URL = import.meta.env.VITE_API_URL;
const res = await fetch(`${API_URL}/expenses`);π‘ Vite variables must start with
VITE_to be exposed to the browser. Never put secrets in them; everything in the frontend is public.
The browser blocks requests from your frontend to your backend unless the backend allows that origin. Go back to Render and set:
CORS_ORIGINS=https://your-app.vercel.app
And in FastAPI:
import os
from fastapi.middleware.cors import CORSMiddleware
origins = os.getenv("CORS_ORIGINS", "").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)Redeploy the backend. Never use allow_origins=["*"] together with credentials in production.
- Push the repo to GitHub. The entry file (e.g.
app.py) andrequirements.txtmust be in the repo. - Go to share.streamlit.io β New app.
- Select the repository, branch (
main), and main file path (app.py). - Open Advanced settings β Secrets and paste your secrets in TOML format:
DATABASE_URL = "postgresql://USER:PASSWORD@HOST/DBNAME?sslmode=require"
API_KEY = "your_api_key_here"- Click Deploy. Your app goes live at
https://your-app.streamlit.app.
Read secrets lazily (inside functions, not at import time) so tests and CI don't fail with StreamlitSecretNotFoundError:
import streamlit as st
def get_database_url() -> str:
return st.secrets["DATABASE_URL"]Docker guarantees "it works on my machine" also means "it works everywhere."
Backend Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["sh", "-c", "uvicorn src.main:app --host 0.0.0.0 --port ${PORT:-8000}"]docker-compose.yml for local development:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: dev
POSTGRES_DB: mydb
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
backend:
build: ./backend
ports: ["8000:8000"]
env_file: .env
depends_on: [db]
frontend:
build: ./frontend
ports: ["5173:5173"]
depends_on: [backend]
volumes:
pgdata:Verify locally before deploying:
docker compose up --buildRender and Railway can also deploy straight from your Dockerfile. Choose Docker as the runtime instead of Python.
-
/healthreturns200 - Swagger UI (
/docs) loads - Register and login work in the production frontend
- Data persists after a page refresh (database connected)
- No CORS errors in the browser console (F12)
- No secrets visible in the frontend source or network tab
- Live URLs added to the README
- README badge / "Live Demo" link works
Update: merge to main β Render and Vercel redeploy automatically on push.
Rolling back:
- Render / Vercel: open the dashboard β Deploys β pick the last good deploy β Rollback / Redeploy.
- Database:
alembic downgrade -1(only if the migration is reversible, so test downgrades locally first). - Code:
git revert <commit>(creates a new commit; never rewritemainhistory).
| Symptom | Likely Cause | Fix |
|---|---|---|
Build fails: ModuleNotFoundError |
Dependency missing from requirements.txt |
Add it and push |
App crashes: KeyError: DATABASE_URL |
Env var not set on the host | Add it in the dashboard, redeploy |
Address already in use / port error |
Not binding to $PORT |
Use --port $PORT --host 0.0.0.0 |
SSL connection required |
Missing sslmode=require |
Append it to DATABASE_URL |
| Frontend shows CORS error | Backend doesn't allow the frontend origin | Set CORS_ORIGINS, redeploy backend |
Frontend calls localhost in production |
Hardcoded API URL | Use VITE_API_URL, redeploy frontend |
relation "x" does not exist |
Migrations not applied | Run alembic upgrade head on production |
| First request takes ~1 minute | Free-tier cold start | Expected; note it in the README |
| Works locally, fails on the host | Python version mismatch | Pin PYTHON_VERSION |
Streamlit StreamlitSecretNotFoundError |
Secrets read at import time | Read lazily; add secrets in app settings |
Level 1 β Daily (15-20 min)
- Exercism.org
- CodeWars
- Advent of Code
Level 2 β Weekly (small projects)
- Rebuild projects without looking at previous solutions
- Todo β Expense β Quiz β Inventory
Level 3 β Core projects
- Your 4 phase projects
- Full portfolio projects
- Re-read old code after 1 month
- Explain your code to someone (rubber duck)
- Read others' code on GitHub
- Keep a postmortem β "what would I change?"
- Timed practice β 30 min/kata
π± The truth: Comfort comes around the 15th-20th project, not the 3rd. Don't get discouraged.
Every feature = new branch. NEVER commit directly to
main.
| Prefix | When | Example |
|---|---|---|
chore/ |
Maintenance | chore/initial-setup |
feat/ |
New feature | feat/storage-layer |
fix/ |
Bug fix | fix/config-ssl |
test/ |
Tests | test/unit-tests |
docs/ |
Documentation | docs/readme |
refactor/ |
Refactor | refactor/service-layer |
# 1. Sync
git checkout main
git pull
# 2. New branch
git checkout -b feat/feature-name
# 3. Work + commits (atomic!)
git add <file>
git commit -m "feat(scope): description"
# 4. Merge back
git checkout main
git merge --no-ff feat/feature-name -m "merge: description"
git branch -d feat/feature-name
# 5. Push
git pushπ‘ Team / PR workflow: instead of step 4, push the branch and open a Pull Request on GitHub. CI runs, you review, then merge via the GitHub UI.
Without: Flat history β you lose the structure.
With: Creates a merge commit β branches are visible in git log --graph.
Format:
<type>(<scope>): <short description>
Types:
featβ new featurefixβ bug fixdocsβ documentationtestβ testsrefactorβ refactorchoreβ maintenancestyleβ formatting
Examples:
chore: add .gitignore for Python
feat(config): add env loader
feat(storage): add JSON file storage
test(storage): add unit tests
docs: rewrite README with screenshots
fix(config): correct indentation
merge: add storage layer
β BAD:
"fixed stuff"
"wip"
"asdf"
"update"
β GOOD:
"feat(auth): add bcrypt password hashing"
"fix(api): handle 404 from GitHub API"
"test(tasks): add edge cases for empty input"
One commit = one logical change.
If you revert, you lose one thing β not five.
Example:
- β
feat: add everythingβ 500 lines, 10 concerns - β
feat(storage): add JSON storage - β
test(storage): add tests for JSON storage - β
docs: document storage format
| # | Branch | What You Do | Commits |
|---|---|---|---|
| 1 | chore/initial-setup |
.gitignore, .env.example, README.md, folders |
4-5 |
| 2 | chore/requirements-and-config |
requirements.txt, config.py |
2-3 |
| 3 | feat/database-models |
SQLAlchemy models | 2 |
| 4 | feat/database-connection |
Engine + session | 1-2 |
| 5 | feat/services-layer |
Business logic | 2-3 |
| 6 | feat/routes-and-templates |
HTTP + UI | 2-3 |
| 7 | test/unit-tests |
Tests | 2-3 |
| 8 | chore/ci |
GitHub Actions | 1 |
| 9 | docs/final-readme |
README + screenshots | 1-2 |
| 10 | chore/add-license |
LICENSE | 1 |
Total: ~20-25 commits, 10 branches.
Every time you:
- β Complete one logical change
- β Tests pass
- β Can describe the change in one line
Not:
- β At end of day (batching 10 changes)
- β When "it works" (without tests)
- β Before merge (if no tests)
git status # what changed?
git log --oneline --graph # visual history
git diff --staged # review before committing
git restore --staged <file> # unstage a file
git stash / git stash pop # park work temporarily
git revert <commit> # safely undo a commit (new commit)Create it BEFORE anything else.
# Virtual Environment
.venv/
venv/
env/
# Secrets
.env
*.env
# Python cache
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
build/
dist/
# Node (full-stack projects)
node_modules/
# OS junk
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# App data
data/
*.log
*.db
tasks.jsonWhy first: Prevents accidental commits.
β οΈ *.envalso ignores.env.example-style names only if they end in.env..env.exampleis not matched, so it stays tracked. Verify withgit status.
What others need, WITHOUT the real secrets.
# Flask
SECRET_KEY=your_secret_key_here
FLASK_DEBUG=True
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password_here
DB_NAME=myapp
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/myapp
# Web (full-stack)
CORS_ORIGINS=http://localhost:5173
VITE_API_URL=http://localhost:8000
Rule: Placeholders always. NEVER real credentials.
NOT pushed to Git (it's in .gitignore).
SECRET_KEY=<generated, long and random>
DB_PASSWORD=<your real password>
DATABASE_URL=mysql+pymysql://root:<your real password>@localhost:3306/myapp
Creation: Copy-Item .env.example .env (Windows PowerShell) or cp .env.example .env (macOS/Linux) β fill in.
Flask>=3.0.0
SQLAlchemy>=2.0.0
python-dotenv>=1.0.0
-r requirements.txt
pytest>=7.4.0
pytest-mock>=3.11.0
pytest-cov>=5.0.0
black>=24.0.0
ruff>=0.3.0
Why separate: Users who pip install don't need pytest.
Creation:
python -m venv .venv
.venv\Scripts\Activate.ps1 # Windows
source .venv/bin/activate # macOS/LinuxWhy: Isolation β each project has its own packages.
.gitignore).
Structure:
- Title + tagline
- Badges (Python, License, Tests, CI)
- Live Demo link
- Screenshots
- About (why you built it)
- Features (what it does)
- Tech Stack (what you use)
- Installation (how to run it)
- Usage (examples)
- Testing (how to run tests)
- Project Structure
- Deployment (where it runs, env vars needed)
- License
If you count 30 seconds: The recruiter reads the README before the code.
MIT License (most common for portfolios):
MIT License
Copyright (c) 2026 Your Name
Permission is hereby granted, free of charge, ...
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "task-cli"
version = "0.1.0"
description = "A simple CLI task manager"
requires-python = ">=3.10"
license = { text = "MIT" }
[project.scripts]
task = "task_cli.main:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 88Why: pip install -e . β command task available everywhere.
See Section 10.9 for complete, ready-to-use files.
install:
pip install -r requirements-dev.txt
test:
pytest tests/ -v --cov=src
lint:
ruff check . && black --check .
format:
black . && ruff check --fix .
run:
uvicorn src.main:app --reloadNow make test replaces a long command you'd otherwise forget.
Continuous Integration = automatic tests on every push.
If they pass β β If they fail β β notification
Continuous Deployment (CD) = passing code is deployed automatically. Render and Vercel do this for you when connected to GitHub: push to main β auto-deploy.
Location: .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest
- name: Lint
run: |
pip install ruff
ruff check .
- name: Run tests
run: pytest tests/ -v services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+psycopg2://postgres:test@localhost:5432/testdbRun alembic upgrade head before pytest to test migrations too.
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm test -- --run
- run: npm run buildShows it works on multiple Python versions β production-ready.
[](https://github.com/USER/REPO/actions)Will show:
- π’
passingβ all good - π΄
failingβ something broke
After:
- β
Tests (branch
test/unit-tests) - β Before deploy
Before:
- β Final README
- β Push to GitHub (if not done yet)
| Error | Cause | Fix |
|---|---|---|
StreamlitSecretNotFoundError |
st.secrets at import |
Lazy init |
ModuleNotFoundError: src |
Missing pyproject.toml |
Add config |
pytest: not found |
Missing from requirements | pip install pytest |
KeyError: DATABASE_URL |
Tests want prod config | TestConfig |
Lesson: CI reveals design flaws before deploy.
AFTER the project works live and has no bugs.
Before you:
- Finalize README
- Build portfolio website
- Post on LinkedIn
| Project Type | How Many | What |
|---|---|---|
| Web app | 3-4 | Home + 2-3 features |
| CLI tool | 2-3 | --help + commands + output |
| API | 1-2 | Swagger UI + 1 request |
| Method | How |
|---|---|
| Win + Shift + S | Snipping tool |
| Win + PrtScn | Full screen β auto-save |
| Alt + PrtScn | Active window only |
project/
βββ docs/
β βββ screenshots/
β βββ home.png
β βββ feature1.png
β βββ feature2.png
In README:
## πΈ Screenshots
| Home | Feature 1 |
|---|---|
|  |  |docs/...), NOT absolute.
- β Zoom before screenshot (Ctrl +)
- β Dark mode (modern look)
- β Full screen (F11)
- β Compress before commit (tinypng.com)
- β No image over 500KB
Put the live demo URL and one screenshot at the very top of the README, right under the title. Visitors decide in seconds whether to keep reading.
| # | Project | Stack | Branches | Commits | Tests | CI |
|---|---|---|---|---|---|---|
| 1 | Weather Dashboard | Streamlit + API | 10 | ~25 | 13 | β |
| 2 | To-Do App | Streamlit + PostgreSQL | 10 | ~30 | 38 | β |
| 3 | Flight Search | Flask + MySQL | 10 | ~25 | 14 | β |
| 4 | Task CLI | Python CLI | 7 | ~20 | 33 | β |
| 5 | Expense Tracker (full-stack) | FastAPI + React + PostgreSQL | 12+ | ~35 | pytest + Vitest | β |
Branches:
chore/initial-setupβ.gitignore,.env.example,README.md, folderschore/requirements-and-configβrequirements.txt,config.pyfeat/weather-fetcherβ API clientfeat/data-visualizerβ Plotly chartsfeat/history-trackerβ JSON persistencefeat/streamlit-uiβ UItest/unit-testsβ pytestdocs/final-readmeβ README + screenshotschore/add-licenseβ MITchore/ciβ GitHub Actions
Skills: REST API, OOP, caching, testing, deployment
Deploy: Streamlit Community Cloud (Section 10.8)
Branches:
chore/initial-setupβ setupchore/requirements-and-configβ deps + configfeat/database-modelsβ User, Taskfeat/database-connectionβ Engine + sessionfeat/auth-serviceβ bcrypt + register/loginfeat/task-serviceβ CRUD scoped per userfeat/streamlit-auth-uiβ Login/Register pagesfeat/streamlit-task-uiβ Task listtest/unit-testsβ 38 testsdocs/final-readmeβ README + screenshotschore/add-licenseβ MITchore/ciβ GitHub Actions
Skills: Auth, PostgreSQL, per-user isolation, bcrypt, SQLAlchemy 2.0
Deploy: Streamlit Community Cloud + Neon / Supabase (Sections 10.4 and 10.8)
Branches:
chore/initial-setupβ setupchore/requirements-and-configβ deps + configfeat/database-modelsβ 6 models + 2 associationsfeat/database-connectionβ lazy init enginefeat/services-layerβ business logicfeat/routes-and-templatesβ Flask blueprints + Jinja2test/unit-testsβ pytest + SQLite in-memorydocs/final-readmeβ README + screenshotschore/add-licenseβ MITchore/ciβ GitHub Actions
Skills: Complex SQL, multi-table JOINs, admin auth, Flask blueprints
Deploy: Render + managed database (Section 10.5)
Branches:
chore/initial-setupβ setupchore/requirements-and-configβ pytest onlyfeat/storageβ JSON persistencefeat/task-logicβ business logicfeat/displayβ output formattingfeat/cliβ argparse entry pointdocs/final-readmeβ README + screenshotschore/ciβ GitHub Actions
Skills: CLI, argparse, stdlib-only, atomic JSON writes
Deploy: GitHub only (optionally publish to PyPI)
Stack: FastAPI, SQLAlchemy, Alembic, PostgreSQL, React, Tailwind, Docker, pytest, Vitest
Branches:
chore/initial-setupβ monorepo folders,.gitignore,.env.examplechore/docker-composeβ Postgres + backend + frontend containersfeat/database-modelsβ User, Expensechore/alembic-migrationsβ first migrationfeat/authβ JWT register/login, bcryptfeat/expenses-crudβ endpoints scoped per userfeat/cachingβ connection pooling + caching strategyfeat/frontend-auth-uiβ login/register pagesfeat/frontend-dashboardβ charts + expense listtest/backend-testsβ pytest with DB fixturestest/frontend-testsβ Vitest + Testing Librarychore/ciβ GitHub Actions (backend + frontend jobs)docs/final-readmeβ architecture, screenshots, live linkschore/deployβ Neon + Render + Vercel
Skills: JWT/OAuth, migrations, Docker, full-stack integration, CORS, CI/CD, three-tier deployment
Deploy: Neon (DB) β Render (API) β Vercel (UI), exactly the order in Section 10.3
Before writing code:
- I answered the 6 questions (NOTES.md)
- I split the problem into sub-problems
- I wrote pseudocode
- I did a dry run with 3 scenarios
- I designed UML/ERD (and API contracts for web apps)
- I decided on the tech stack
- I estimated time
- I created the repo with
.gitignoreas the first commit
Before deploy:
- Tests pass (
pytest -v) - README complete
- Screenshots in
docs/screenshots/ -
.envin.gitignore -
.env.exampleverified (no real secrets) -
requirements.txtup to date - CI workflow in
.github/workflows/ - LICENSE added
- Clean git history (
git log --graph) - Migrations applied to production database
- Docker containers verified locally (
docker compose up --build) - Deployed (database β backend β frontend β CORS)
- Post-deploy verification checklist completed (Section 10.10)
- Live URLs and badge in README
IDEA
β
βΌ
ββββββββββββββββββββ
β 1. UNDERSTANDING β β 30 min of thinking
β (6 questions) β
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 2. DECOMPOSITION β β Split into small pieces
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 3. PSEUDOCODE β β Logic without syntax
β + DRY RUN β
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 4. DESIGN β β UML, ERD, API contracts
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 5. IMPLEMENT β β Write code
β (branch-based) β
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 6. TEST β β 3 tests/function
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 7. REFACTOR β β Clean up
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 8. DOCUMENT β β README + docstrings
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 9. CI/CD β β GitHub Actions
ββββββββββ¬ββββββββββ
β
ββββββββββΌββββββββββ
β 10. DEPLOY β β DB β API β UI β CORS
ββββββββββ¬ββββββββββ
β
βΌ
PORTFOLIO
Think 80%, write 20%.
One commit = one change.
One branch = one feature.
Tests first, code after.
Never commit secrets.
Deploy from the bottom up: database, backend, frontend.
The README is 50% of the project.
Comfort comes from repetition, not from reading.
- 1st project: You think through every step explicitly
- 5th project: You start remembering the sequence
- 10th project: Decomposition becomes automatic
- 20th project: It's second nature
You're at #4. You're not a beginner β you're mid-way.
Found a typo, a better tool, or a missing step? Open an issue or a pull request.
MIT License β see LICENSE for details.
Built with β + π§ β next time you start a project, read this file BEFORE touching the editor. π