This FastAPI project is the capstone for Part 3: one small but real Application Programming Interface (API) that you build, test, containerize, gate behind CI, and deploy to a public URL. It is a bookmarks service (sign up, log in, save links, list them with pagination), the kind of thing you could actually put your name on and hand to an interviewer.
“Code without tests is broken as designed.”
Jacob Kaplan-Moss, Django co-creator
Last Updated: July 2026 | Tested on: Python 3.14.6, FastAPI 0.138.0, SQLAlchemy 2.0.51, pytest 9.1.1 | Difficulty: Advanced | Reading Time: 24 minutes
Everything in Part 3 has been a separate skill so far: FastAPI routes, Pydantic validation, JSON Web Token (JWT) auth, Docker, GitHub Actions, ruff and mypy, pytest. This FastAPI project is where you wire them into one thing that works end to end. Think of it like a driving test. You already practised parking, signaling, and merging on their own. Now you get in the car and do the whole drive while someone grades the result. The grade here is a checklist of twelve things a real API needs, and we tick every box with running code.
We use FastAPI because it is the most popular typed-Python API framework at the time of writing, but the shape of this FastAPI project (models, auth, tests, CI, deploy) is identical if you prefer Litestar or Django REST Framework. Swap the framework, keep the workflow. Every output block below is real, captured from running the code on Python 3.14.6, except the two that need a running Docker daemon or a live server, which are clearly marked as example output.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The left side of the diagram is what happens at runtime when a request comes in: Uvicorn hands it to a route, the route asks a dependency to check the JWT, the dependency loads the user, SQLAlchemy fetches only that user’s rows, and Pydantic turns them into a JSON page. The right side is the CI pipeline that runs on every push: lint, type check, tests with a coverage gate, a Docker build, then deploy. If any of ruff, mypy, or pytest goes red, the pull request is blocked. Those two loops, the request and the pipeline, are the whole job.
Table of Contents
What You Are Building (Acceptance Criteria)
Professionals agree on “done” before they start, so nobody argues about it later. It is like a landlord’s move-out checklist: floors clean, no holes in the wall, keys returned. Here is the checklist for this FastAPI project. By the end of the post, every item is ticked with code you ran yourself.
POST /auth/registercreates a user and stores a hashed password, never the plain text.POST /auth/loginreturns a signed JWT that expires.- Every
/bookmarksroute rejects requests without a valid token. - A user can only see and delete their own bookmarks.
- Listing supports pagination with
limitandoffset. - Bad input (a malformed URL, a too-short password) is rejected with a 422 by Pydantic.
GET /healthreturns 200 with no auth, for uptime checks.- The schema lives in SQLAlchemy models plus a versioned Alembic migration.
- A pytest suite covers the routes at 90 percent or higher.
- A Dockerfile builds an image under 200MB.
- GitHub Actions runs ruff, mypy, and pytest on every push (green badge).
- The API is deployed to a public URL and a smoke test passes against it.
Prerequisites
This post pulls together earlier chapters, so skim any you are shaky on: the FastAPI tutorial, JWT authentication, pytest, Docker, and GitHub Actions CI/CD. Install the libraries into a fresh virtual environment with one line: pip install "fastapi[standard]" sqlalchemy alembic pyjwt bcrypt pytest pytest-cov httpx ruff mypy. The whole FastAPI project was tested on Python 3.14.6 with version 0.138.0 of the framework, SQLAlchemy 2.0.51, and pytest 9.1.1, all current at the time of writing. The layout is five short files: models.py, security.py, main.py, test_main.py, and pyproject.toml.
Step 1: The Data Model and Migration
The data model is the blueprint of a house: get the rooms and doorways right on paper before anyone pours concrete. We describe two tables as SQLAlchemy 2.0 models using typed Mapped columns. A User owns many Bookmark rows, and the cascade rule means deleting a user cleans up their bookmarks too, so no orphan rows are left behind.
📄 models.py: two tables as typed SQLAlchemy models
from datetime import datetime
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(default=func.now())
bookmarks: Mapped[list["Bookmark"]] = relationship(
back_populates="owner", cascade="all, delete-orphan"
)
class Bookmark(Base):
__tablename__ = "bookmarks"
id: Mapped[int] = mapped_column(primary_key=True)
url: Mapped[str] = mapped_column(String(2048))
title: Mapped[str] = mapped_column(String(255))
note: Mapped[str] = mapped_column(String(1000), default="")
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
owner: Mapped["User"] = relationship(back_populates="bookmarks")
Models describe the schema, but you still need a way to create and evolve the real tables over time. That is what Alembic is for. It compares your models to the live database and writes a migration script, the way git diff shows what changed. You run alembic revision --autogenerate to write the migration, then alembic upgrade head to apply it.
📄 Terminal: generate and apply the first migration
alembic revision --autogenerate -m "create users and bookmarks" alembic upgrade head
▶ Output
INFO [alembic.autogenerate.compare.tables] Detected added table 'users'
INFO [alembic.autogenerate.compare.constraints] Detected added index 'ix_users_email' on '('email',)'
INFO [alembic.autogenerate.compare.tables] Detected added table 'bookmarks'
INFO [alembic.autogenerate.compare.constraints] Detected added index 'ix_bookmarks_owner_id' on '('owner_id',)'
Generating migrations/versions/f56040967fcd_create_users_and_bookmarks.py ... done
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Running upgrade -> f56040967fcd, create users and bookmarks
What happened here: Alembic read the models, noticed both tables and their indexes did not exist yet, and wrote a migration file named after a random revision id. The second command applied it, printing Running upgrade -> f56040967fcd. That revision id is now recorded in an alembic_version table, so Alembic knows which migrations have run. The generated upgrade() function is plain, readable SQLAlchemy: op.create_table("users", ...), op.create_index(...), and a matching downgrade() that drops them in reverse. You commit this file to git, and every teammate and every server gets the exact same schema by running one command.
Step 2: Auth with JWT and bcrypt
Auth has two separate jobs, and beginners often blur them. Storing passwords safely is one job: we never keep the real password, only a bcrypt hash, which is a one-way scramble that is slow on purpose so guessing is expensive. Proving who you are on later requests is the other job: after login we hand back a JWT, a signed token that says “this is user 5” and cannot be forged without the secret key. Think of the hash as a locked safe for the password and the JWT as a wristband at a concert: the door checks your ID once, gives you a wristband, and after that you just flash the band.
📄 security.py: hashing and tokens, kept in one small module
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
# In a real project this comes from an environment variable, never hardcoded.
SECRET_KEY = "change-me-in-production-with-a-long-random-value"
ALGORITHM = "HS256"
TOKEN_TTL_MINUTES = 30
def hash_password(plain: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(plain.encode(), salt).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MINUTES)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> int:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(payload["sub"])
What happened here: Each function does exactly one thing, which is what makes them easy to test. hash_password adds a random salt so two people with the same password get different hashes. create_token stamps an expiry 30 minutes out, so a stolen token stops working quickly. decode_token will raise if the signature is wrong or the token has expired, and we let that error bubble up to the route, which turns it into a clean 401. Notice the secret is a placeholder here. In the deploy step it comes from an environment variable, because a secret committed to git is not a secret.
Step 3: Routes, Pagination, Health
Now the app itself. Pydantic schemas describe the shape of every request and response (the contract), a couple of dependencies supply the database session and the current user, and the routes stay short because validation and auth happen before they run. The current_user dependency is the security desk from the FastAPI tutorial: write the badge check once, attach it to every protected route.
📄 main.py: schemas, dependencies, and the app wiring
import logging
import os
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import InvalidTokenError
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from models import Base, Bookmark, User
from security import create_token, decode_token, hash_password, verify_password
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
log = logging.getLogger("bookmarks")
DB_URL = os.getenv("DATABASE_URL", "sqlite:///./bookmarks.db")
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False)
Base.metadata.create_all(engine)
app = FastAPI(title="Bookmarks API", version="1.0.0")
bearer = HTTPBearer()
class SignUp(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class BookmarkIn(BaseModel):
url: HttpUrl
title: str = Field(min_length=1, max_length=255)
note: str = Field(default="", max_length=1000)
class BookmarkOut(BaseModel):
id: int
url: str
title: str
note: str
model_config = {"from_attributes": True}
class Page(BaseModel):
items: list[BookmarkOut]
total: int
limit: int
offset: int
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def current_user(
creds: HTTPAuthorizationCredentials = Depends(bearer),
db: Session = Depends(get_db),
) -> User:
try:
user_id = decode_token(creds.credentials)
except InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
With the plumbing in place, the routes read almost like plain English. Register hashes the password and refuses a duplicate email. Login checks the password and hands back a token. The bookmark routes all depend on current_user, so an unauthenticated request never reaches the body. Listing filters by owner_id and slices with limit and offset, and /health is deliberately open.
📄 main.py: the routes (auth, bookmarks, health)
@app.post("/auth/register", status_code=201)
def register(body: SignUp, db: Session = Depends(get_db)):
if db.scalar(select(User).where(User.email == body.email)):
raise HTTPException(status_code=409, detail="Email already registered")
user = User(email=body.email, password_hash=hash_password(body.password))
db.add(user)
db.commit()
log.info("user registered id=%s", user.id)
return {"id": user.id, "email": user.email}
@app.post("/auth/login")
def login(body: SignUp, db: Session = Depends(get_db)):
user = db.scalar(select(User).where(User.email == body.email))
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Wrong email or password")
return {"access_token": create_token(user.id), "token_type": "bearer"}
@app.post("/bookmarks", response_model=BookmarkOut, status_code=201)
def add_bookmark(body: BookmarkIn, user: User = Depends(current_user),
db: Session = Depends(get_db)):
bm = Bookmark(url=str(body.url), title=body.title, note=body.note, owner_id=user.id)
db.add(bm)
db.commit()
db.refresh(bm)
log.info("bookmark created id=%s owner=%s", bm.id, user.id)
return bm
@app.get("/bookmarks", response_model=Page)
def list_bookmarks(user: User = Depends(current_user), db: Session = Depends(get_db),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0)):
base = select(Bookmark).where(Bookmark.owner_id == user.id)
total = len(db.scalars(base).all())
rows = db.scalars(base.order_by(Bookmark.id).limit(limit).offset(offset)).all()
items = [BookmarkOut.model_validate(row) for row in rows]
return Page(items=items, total=total, limit=limit, offset=offset)
@app.delete("/bookmarks/{bookmark_id}", status_code=204)
def delete_bookmark(bookmark_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
bm = db.get(Bookmark, bookmark_id)
if bm is None or bm.owner_id != user.id:
raise HTTPException(status_code=404, detail="Bookmark not found")
db.delete(bm)
db.commit()
@app.get("/health")
def health():
return {"status": "ok", "version": app.version}
Before writing a single test, let us drive the API by hand to see the shapes. This little script uses FastAPI’s TestClient, which sends real requests in memory, no browser or server needed. It registers a user named Anvi, logs in, saves one bookmark, lists it, then tries once with no token.
📄 demo.py: walk the whole flow with TestClient
import json
from fastapi.testclient import TestClient
import main
client = TestClient(main.app)
reg = client.post("/auth/register",
json={"email": "anvi@example.com", "password": "strong-pass-1"})
print("register:", reg.status_code, reg.json())
login = client.post("/auth/login",
json={"email": "anvi@example.com", "password": "strong-pass-1"})
token = login.json()["access_token"]
print("login:", login.status_code, "token starts with", token[:12] + "...")
headers = {"Authorization": f"Bearer {token}"}
bm = client.post("/bookmarks",
json={"url": "https://docs.python.org", "title": "Python docs", "note": "reference"},
headers=headers)
print("create:", bm.status_code)
print(json.dumps(bm.json(), indent=2))
page = client.get("/bookmarks?limit=10&offset=0", headers=headers)
print("list:", json.dumps(page.json(), indent=2))
denied = client.get("/bookmarks")
print("no token:", denied.status_code, denied.json())
▶ Output
register: 201 {'id': 1, 'email': 'anvi@example.com'}
login: 200 token starts with eyJhbGciOiJI...
create: 201
{
"id": 1,
"url": "https://docs.python.org/",
"title": "Python docs",
"note": "reference"
}
list: {
"items": [
{
"id": 1,
"url": "https://docs.python.org/",
"title": "Python docs",
"note": "reference"
}
],
"total": 1,
"limit": 10,
"offset": 0
}
no token: 401 {'detail': 'Not authenticated'}
What happened here: The full loop works. Register returned 201 and a new id, login returned a JWT (we print only the first few characters, since it is a secret), creating a bookmark echoed it back as clean JSON, and listing wrapped it in a page object with total, limit, and offset. The last call, with no Authorization header, was stopped at the door with 401 Not authenticated. Note that Pydantic’s HttpUrl normalized https://docs.python.org to add the trailing slash. That is the kind of quiet correctness you get for free from typed schemas.
The two log.info lines also fired, going to stderr as structured, level-tagged records rather than bare print calls. This is the logging habit from earlier in the series: real apps log, they do not print.
▶ Output (stderr log lines)
INFO bookmarks user registered id=1 INFO bookmarks bookmark created id=1 owner=1
Step 4: The Test Suite and Coverage Gate
A demo you run by eye is not a test. A test is a claim the machine re-checks for you on every change, like a smoke alarm that keeps watching after you leave the room. The key trick for API tests is a fresh, private database per test, so one test never leaks state into the next. We build an in-memory SQLite database and swap it in with FastAPI’s dependency_overrides, which replaces get_db just for the tests.
📄 test_main.py: a fresh database per test, then eleven checks
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import main
from models import Base
@pytest.fixture
def client():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(engine)
TestingSession = sessionmaker(bind=engine, autoflush=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
main.app.dependency_overrides[main.get_db] = override_get_db
yield TestClient(main.app)
main.app.dependency_overrides.clear()
engine.dispose()
def auth_header(client, email="anvi@example.com", password="strong-pass-1"):
client.post("/auth/register", json={"email": email, "password": password})
token = client.post("/auth/login",
json={"email": email, "password": password}).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_health_is_ok(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok", "version": "1.0.0"}
def test_short_password_is_422(client):
r = client.post("/auth/register", json={"email": "x@example.com", "password": "short"})
assert r.status_code == 422
def test_bad_token_is_401(client):
r = client.get("/bookmarks", headers={"Authorization": "Bearer not-a-real-token"})
assert r.status_code == 401
def test_pagination_limits_results(client):
headers = auth_header(client)
for i in range(15):
client.post("/bookmarks",
json={"url": f"https://example.com/{i}", "title": f"Item {i}"},
headers=headers)
page = client.get("/bookmarks?limit=10&offset=0", headers=headers).json()
assert page["total"] == 15 and len(page["items"]) == 10
def test_users_cannot_see_each_others_bookmarks(client):
anvi = auth_header(client, "anvi@example.com", "strong-pass-1")
client.post("/bookmarks",
json={"url": "https://example.com/secret", "title": "Anvi private"},
headers=anvi)
anvay = auth_header(client, "anvay@example.com", "strong-pass-2")
assert client.get("/bookmarks", headers=anvay).json()["total"] == 0
That is five of the eleven tests; the full file also checks duplicate emails (409), a missing token (401), a bad URL (422), successful create-and-list, and delete (204). The addopts line in pyproject.toml turns coverage on automatically, so a plain pytest both runs the suite and prints the coverage table. That coverage number is our gate: if the routes drop below 90 percent, we treat the build as failed.
📄 Terminal: run the suite with coverage
pytest -q
▶ Output
........... [100%] =============================== tests coverage ================================ _______________ coverage: platform win32, python 3.14.6-final-0 _______________ Name Stmts Miss Cover Missing ------------------------------------------- main.py 80 7 91% 56-60, 73, 93, 134 models.py 21 0 100% security.py 18 0 100% ------------------------------------------- TOTAL 119 7 94% 11 passed in 4.66s
What happened here: Eleven dots, eleven passes, and a coverage table showing main.py at 91 percent and the project at 94 percent overall, comfortably past the 90 percent bar. The Missing column even names the exact lines never hit (the “user not found” branch, some error paths), which is your to-do list if you want to push higher. This table is not decoration. In the next step, the same command inside CI decides whether a pull request is allowed to merge.
Step 5: Containerize It
A container is a lunchbox for your app: the code, the Python version, and every dependency packed together so it runs the same on your laptop and on the server. “Works on my machine” stops being an excuse. We start from the official python:3.14-slim image, copy requirements.txt first so Docker caches the dependency layer, then copy only the three runtime files and launch Uvicorn. Copying requirements before the code is the one trick that keeps rebuilds fast, since your dependencies change far less often than your code.
📄 Dockerfile: a small, cache-friendly image
FROM python:3.14-slim
WORKDIR /app
# Install dependencies first so this layer is cached across code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy only what the app needs to run
COPY main.py models.py security.py ./
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
📄 Terminal: build the image and check its size
docker build -t bookmarks-api:1.0.0 .
docker images bookmarks-api:1.0.0 --format "{{.Repository}}:{{.Tag}} {{.Size}}"
▶ Example output
[+] Building 41.2s (11/11) FINISHED => => naming to docker.io/library/bookmarks-api:1.0.0 bookmarks-api:1.0.0 187MB
What happened here: This block is marked example output because it needs a running Docker daemon, which the tutorial’s test runner does not have. On a normal machine the slim base plus these pure-Python dependencies lands around 187MB, under our 200MB budget. If yours comes out bigger, the usual culprit is copying the whole folder (tests, the SQLite file, the virtual environment) into the image. A .dockerignore that excludes bookmarks.db, __pycache__, test_*.py, and .venv fixes that. For a still smaller image, a multi-stage build or the -alpine base are the next steps.
Step 6: The CI Pipeline
CI (continuous integration) is a robot reviewer that runs the same checks on every push, so a tired human never forgets. It is the assembly-line inspector: each unit passes through the same stations in the same order, and anything defective gets pulled before it ships. Our workflow runs three gates, ruff for lint, mypy for types, and pytest for behavior. If any one fails, the pull request goes red and cannot merge.
📄 .github/workflows/ci.yml: lint, type check, test on every push
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt ruff mypy pytest pytest-cov httpx
- name: Lint (ruff)
run: ruff check .
- name: Type check (mypy)
run: mypy main.py models.py security.py
- name: Test (pytest + coverage gate)
run: pytest --cov=. --cov-fail-under=90
The --cov-fail-under=90 flag is the enforcer: pytest returns a non-zero exit code if coverage drops below 90 percent, and GitHub Actions reads that exit code to mark the step failed. Here are the three gates running locally, exactly as they run on the CI server. The first two versions I wrote actually failed here, an unused import and a type mismatch, which is the whole point: the robot caught them before a human did.
📄 Terminal: run the three gates locally
ruff check main.py models.py security.py mypy main.py models.py security.py
▶ Output
All checks passed! Success: no issues found in 3 source files
What happened here: ruff reports All checks passed! and mypy reports Success: no issues found in 3 source files. Combined with the 11 passing tests at 94 percent coverage from Step 4, all three gates are green, so the badge on your README will read passing. Add the badge with one line of Markdown pointing at the workflow, and anyone visiting the repo sees at a glance that the code lints, type-checks, and tests clean.
Step 7: Deploy and Smoke Test
Deploying the FastAPI project means putting the container somewhere the public internet can reach it. At the time of writing, Render offers a free web-service tier that builds straight from your Dockerfile; Fly.io and Railway are two solid fallbacks with similar free allowances. The steps are the same everywhere: connect the repo, tell the host it is a Docker app, and set two environment variables, SECRET_KEY (a long random string) and DATABASE_URL (a managed Postgres URL for real persistence instead of the local SQLite file).
Once it is live, you do not trust that it worked, you check. A smoke test is the quickest possible check: hit /health and confirm a 200. It is the same as flicking the light switch after an electrician leaves, one action that proves the basics are wired up.
📄 Terminal: smoke test the live URL
curl -s https://bookmarks-api.onrender.com/health
▶ Example output
{"status":"ok","version":"1.0.0"}
What happened here: This is example output because it needs a live host and your own URL. A 200 with that JSON body means the container built, Uvicorn started, and the app answers, so your twelfth acceptance box is ticked. The same one-line curl makes a great uptime check: point a free monitor at /health and it pings you if the app ever stops answering. If you get a timeout instead, the usual cause on free tiers is a cold start (the first request after idle can take a few seconds) or a missing environment variable, both visible in the host’s logs.
The README (Your Resume Line One)
A repo with no README is a locked shop with the lights off. The README is the shop window: it tells a busy recruiter in thirty seconds what the project does, that it is tested, and how to run it. This FastAPI project plus the earlier packaging project are the two repos you put at the top of your resume, so give each a real README. Here is a template that covers what matters.
📄 README.md: the template that sells the project
# Bookmarks API
A small, tested REST API built with FastAPI: JWT auth, per-user bookmarks,
pagination, and a health endpoint. Shipped with Docker and GitHub Actions CI.

## Features
- Register / login with hashed passwords (bcrypt) and JWT tokens
- CRUD bookmarks scoped to the logged-in user
- Pagination, input validation, and a /health endpoint
- 94% test coverage, ruff + mypy clean, Docker image under 200MB
## Run locally
pip install -r requirements.txt
alembic upgrade head
uvicorn main:app --reload
# open http://127.0.0.1:8000/docs
## Test
pytest
## Tech
FastAPI, SQLAlchemy, Alembic, PyJWT, pytest, Docker, GitHub Actions.
What happened here: The README leads with one plain sentence, shows the CI badge so trust is instant, lists concrete features (not adjectives), and gives copy-paste commands to run and test. The interactive Swagger docs at /docs come free from FastAPI, so a reviewer can try the API without reading a line of your code. That combination, a green badge and a live doc page, is what makes a portfolio project read as professional rather than practice.
Common Mistakes
- Sharing one database across tests. If tests reuse the same file, one test’s rows leak into the next and failures become random. Build a fresh in-memory database per test with a fixture, as the suite does. Random test failures almost always trace back to shared state.
- Storing plain passwords, or a hardcoded secret. Never save the raw password, only the bcrypt hash. And the JWT
SECRET_KEYmust come from an environment variable in production; a secret committed to git is public the moment you push. - Skipping migrations and calling
create_allin production.Base.metadata.create_allis fine for local SQLite, but it cannot evolve a schema. The day you add a column, you need Alembic. Set it up on day one, not after the data matters. - Chasing 100 percent coverage. Coverage tells you what ran, not whether it is correct. Ninety-plus on the routes is a healthy bar. The last few percent are usually error branches that cost more to test than they are worth.
- Fat Docker images. Copying the whole folder pulls in your virtual environment and the SQLite file. Use a
.dockerignoreand copy only the runtime files, exactly as the Dockerfile does.
Best Practices
- Keep the response contract separate from the model.
BookmarkOutdecides what leaves the API, so you never accidentally leak apassword_hash. The database model and the public schema are two different things on purpose. - Let dependencies do the guarding. Auth, the database session, and pagination all live in
Dependsfunctions, so routes stay short and every protected route is guarded the same way. - Make the gate fail the build.
--cov-fail-under=90and the ruff and mypy steps must return non-zero on failure, or CI is just decoration. A green badge should mean something. - Log, do not print. Use the
loggingmodule with levels so production logs are searchable and you can dial the verbosity without touching code. - Ship a health endpoint from the start. Every host and monitor expects one. It costs three lines and saves you during an outage.
Conclusion
You just built a real FastAPI project end to end and ticked all twelve acceptance boxes: typed SQLAlchemy models with an Alembic migration, JWT auth over bcrypt-hashed passwords, paginated per-user routes, a health endpoint, an eleven-test pytest suite at 94 percent coverage, a sub-200MB Docker image, a three-gate GitHub Actions pipeline, and a live deploy with a passing smoke test. That is the same skeleton behind APIs running in production at real companies, only smaller.
Put this repo and your packaging project at the top of your resume, keep the CI badge green, and you have something concrete to talk through in any interview. For the full roadmap and every other topic in order, head to the Python + AI/ML tutorial series home.
Frequently Asked Questions
Is this FastAPI project big enough to put on a resume?
Yes. Recruiters care about signal, not size. A small FastAPI project that is tested at 90%+ coverage, type-checked, containerized, gated by CI, and actually deployed shows more real-world skill than a large untested one. Lead with the green CI badge and the live /docs page, and describe the twelve acceptance criteria you met.
Should I use FastAPI, Litestar, or Django REST Framework?
All three are solid at the time of writing. FastAPI is the most popular typed-Python API framework and has the largest community, which is why this project uses it. Litestar is a strong typed alternative with a similar feel, and Django REST Framework fits if you already use Django’s ORM and admin. The workflow here (models, auth, tests, CI, deploy) is identical whichever you pick.
Why bcrypt and JWT instead of sessions?
bcrypt is a slow, salted hash built for passwords, so it resists brute-force guessing. JWTs are stateless: the server signs a token at login and verifies the signature on later requests without storing session state, which scales cleanly across multiple servers. Sessions are also valid, especially for server-rendered apps, but token auth is the common choice for APIs.
How do I move from SQLite to Postgres for deployment?
Change one thing: the DATABASE_URL environment variable. SQLAlchemy and Alembic both read it, so pointing it at a managed Postgres URL is enough. Drop the SQLite-only connect_args={‘check_same_thread’: False}, run alembic upgrade head against the new database, and the same code and the same migrations create the same schema on Postgres.
Interview Questions on Shipping a FastAPI Project
The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.
Q: Walk me through what happens when a request hits your GET /bookmarks endpoint.
Uvicorn receives the HTTP request and passes it to FastAPI, which matches the route. Before the function body runs, the current_user dependency reads the Bearer token, decodes and verifies the JWT, and loads the user; a bad or missing token becomes a 401 right there. Then the route queries SQLAlchemy for rows where owner_id matches that user, applies limit and offset, and Pydantic serializes the result into the Page response model as JSON. Validation and auth happen before my code, so the handler stays short.
Q: How do you make API tests independent of each other?
Each test gets its own fresh database. In this project a pytest fixture creates a new in-memory SQLite database and overrides the get_db dependency with app.dependency_overrides, so no test shares state with another. After the test the override is cleared and the engine disposed. This is why the eleven tests can run in any order and still pass; shared state is the most common cause of flaky tests.
Q: What is the difference between hashing a password and encrypting it?
Hashing is one-way: bcrypt turns the password into a fixed scramble you cannot reverse, so even if the database leaks, the real passwords stay hidden. You verify a login by hashing the input and comparing. Encryption is two-way, meant for data you need to read back, like a stored API key. Passwords should always be hashed, never encrypted, and bcrypt adds a per-password salt so identical passwords do not share a hash.
Q: Why use Alembic when SQLAlchemy can create the tables directly?
create_all only creates tables that do not exist yet; it cannot change an existing schema. The moment you add a column or an index to a live database with real data, you need a migration that describes the change and can be applied in order, and rolled back. Alembic autogenerates that script from your model changes and records which revisions have run, so every environment ends up with the identical schema.
Q: What does the coverage gate actually enforce, and what are its limits?
The --cov-fail-under=90 flag makes pytest exit non-zero if less than 90 percent of lines ran during the tests, and CI reads that exit code to block the merge. Its limit is that coverage measures execution, not correctness: a line can run without any assertion checking its result. So I treat 90 percent as a floor that catches untested code paths, but I still write assertions that check real behavior, not just lines that happen to execute.
Q: Scenario: your teammate Aviraj says the CI badge is green but a bug reached production. How is that possible?
Green means the tests that exist passed, not that every case is covered. The bug likely lives in a path with no test, or in an assertion that was too loose. I would reproduce it, write a failing test that captures it, then fix the code until the test passes, so the same bug can never come back silently. I would also check whether coverage was high but shallow, meaning lines ran without meaningful assertions.
Q: How would you keep this API secure and reliable once real users depend on it?
Move the SECRET_KEY and DATABASE_URL into environment variables, switch to managed Postgres, and add rate limiting on the auth routes to slow brute-force attempts. Keep tokens short-lived and consider refresh tokens. Watch the /health endpoint with an uptime monitor, ship structured logs to a central place, and keep the CI gates strict so no unlinted, untyped, or untested code merges. Small, boring, consistent guardrails beat heroics.
Go deeper: when you outgrow this post, FastAPI documentation is the next stop.
Related Posts
Previous: Python Profiling: Find Slow Code with cProfile and py-spy
Next: Python Interview Questions: The 40 That Actually Get Asked
Series Home: Python + AI/ML Tutorial Series

No comment