Web app architecture
Skill wdm0006/python-skills/skills/python/web-app-architecture
Provides an opinionated reference architecture for production Python web apps — FastAPI + async SQLAlchemy + Postgres, pydantic-settings config, Stripe billing, server-rendered Jinja or a decoupled SPA, deployed as a single Docker image on a managed platform via Terraform. Use when starting a new SaaS/web app, choosing a stack, structuring routers/services/models, wiring Stripe webhooks, protecting FastAPI docs or OpenAPI routes, setting up config/secrets, or deciding how to deploy.From its SKILL.md
npx -y skills add wdm0006/python-skills --skill web-app-architectureAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
9.0 KB, as published. Nobody here has run it
Building Python Web Apps
An opinionated, batteries-included blueprint for shipping a small-to-mid SaaS app solo or with a small team. It favors boring, well-understood pieces wired the same way every time so a new app reaches "auth + billing + deploy" in hours, not weeks.
The Default Stack
| Concern | Default |
|---|---|
| Framework | FastAPI (async), uvicorn[standard] |
| Packaging | uv + pyproject.toml + uv.lock, hatchling, src/ layout |
| Database | Postgres 16 + asyncpg, SQLAlchemy 2.0 async, Alembic |
| Config | pydantic-settings BaseSettings, env-prefixed, @lru_cache |
| Payments | Stripe ≥15, one signed webhook, idempotency table |
| Auth | dependency guards + bcrypt; token scheme per app (see AUTH.md) |
| Frontend | Jinja2 + Tailwind (CDN) + Alpine.js, or a decoupled SPA |
| Background work | cron-as-worker (same image, different command) — no Celery |
| Observability | Sentry + PostHog, both no-op until keyed |
| Deploy | Docker → managed platform (Render-style) via Terraform |
| Lint/test | ruff + pytest + pytest-asyncio, Makefile entrypoint |
If you have no reason to deviate, use every row. The rest of this skill is the "how", and the reference files hold copy-pasteable templates.
Package Layout
One layered package, routers split by domain. Business logic never lives in a route.
src/myapp/
main.py # app + middleware + include_router() in a loop
config.py # pydantic-settings Settings + get_settings()
db.py # lazy async engine/sessionmaker + get_session() dep
models.py # SQLAlchemy 2.0 DeclarativeBase, Mapped[]
schemas.py # Pydantic request/response models
security.py # hashing, tokens, auth dependencies
observability.py # Sentry/PostHog init (guarded)
api/ # one APIRouter module per domain (+ deps.py, webhooks.py)
services/ # business logic; external SDKs isolated here
migrations/versions/ # Alembic
tests/ # pytest, mirrors api/ + services/
terraform/ # render.tf, stripe.tf, posthog.tf, variables.tf
Dockerfile docker-compose.yml pyproject.toml uv.lock alembic.ini Makefile
Routes → Services → Models. Routes parse/validate and call services. Services hold the logic and own all external SDK calls (Stripe, email, LLM) so they're mockable. Models are persistence only.
App Wiring
A module-level app (a factory is fine but unnecessary), routers registered in a
loop, cross-cutting concerns as middleware, and a health check that proves the DB
is reachable. Full template in STACK.md.
# main.py (shape)
app = FastAPI(docs_url=None if settings.is_prod else "/docs")
app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins, ...)
# request-id + security-headers middleware, slowapi rate limiter
for router in (auth.router, billing.router, webhooks.router, ...):
app.include_router(router)
@app.get("/health")
async def health(db: SessionDep):
await db.execute(text("SELECT 1")) # 503 if the DB is down
return {"status": "ok"}
Share dependencies as Annotated type aliases — they read cleanly in signatures:
# api/deps.py
SessionDep = Annotated[AsyncSession, Depends(get_session)]
UserDep = Annotated[User, Depends(require_user)]
Database
Lazy singleton async engine, pool_pre_ping=True, expire_on_commit=False, a
get_session() async-generator dependency, and a URL normalizer so platform-issued
postgres:// URLs become postgresql+asyncpg://. Postgres in prod, SQLite
(aiosqlite) for fast local/CI tests. Migrations via Alembic, applied in the
deploy's pre-deploy step. See STACK.md.
Config & Secrets
One Settings(BaseSettings) with an env prefix, @lru_cache get_settings(), and
validators that refuse to boot in production with a default/missing secret.
Commit a .env.example; never commit .env. Computed @property helpers
(is_prod, cors_origins, billing_enabled) keep routes clean.
Payments
Stripe with exactly one signature-verified webhook endpoint, idempotency backed
by a persisted events table, and dispatch by event type that never 500s on an
unknown event. Isolate every SDK call in a services/stripe/ module. Full patterns
(checkout, subscriptions, webhook handler, Terraform-provisioned signing secret) in
PAYMENTS.md.
The Decisions You Actually Make
Everything above is fixed. These are the real per-app choices:
- Frontend — server-rendered Jinja + Tailwind/Alpine (zero JS build, ship fast) or a decoupled React/Vite SPA on its own origin (richer UI, more moving parts). → FRONTEND.md
- Auth scheme — cookie-JWT (browser app), bearer-JWT/API-key (programmatic), magic-link (passwordless), or OAuth (delegated). bcrypt + dependency guards are constant; only the token differs. → AUTH.md
- Background work — inline in the request (simplest), cron-as-worker (the default for periodic/queued work), or a Redis-backed job queue (long-poll jobs). Reach for Celery only when you've outgrown all three. → BACKGROUND_JOBS.md
- LLM features —
pydantic-aiwith structured Pydantic outputs, key required at boot, model configurable via env. → FRONTEND.md has the note.
Deployment
Single Docker image (the astral-sh/uv base, two-stage uv sync for layer
caching, non-root, uvicorn --proxy-headers). The same image runs web, worker,
and migrations via different commands. Target a managed platform; provision it with
Terraform (app + Postgres + Stripe + PostHog as code), with a platform Blueprint
file as the documented fallback. Migrations run in the pre-deploy step. Secrets are
generated in Terraform or marked no-sync and set out of band. → DEPLOYMENT.md
Review Checklist
Structure:
- [ ] Logic in services/, not in route handlers
- [ ] External SDKs (Stripe/email/LLM) isolated behind a service module
- [ ] /health (or /readyz) actually pings the database
- [ ] Job status: failure writes its own terminal value, "in progress" is written
before the work is handed off, and the current value is read by monotonic id
Data:
- [ ] Async engine is a lazy singleton with pool_pre_ping
- [ ] postgres:// URLs normalized to postgresql+asyncpg://
- [ ] Every schema change has an Alembic migration
Config:
- [ ] Production refuses to boot on a default/missing secret
- [ ] CORS not "*" and HTTPS enforced in production
- [ ] .env.example committed; .env gitignored
Billing & security:
- [ ] Exactly one Stripe webhook, signature-verified, idempotent
- [ ] New passwords centrally limited to 72 UTF-8 bytes before bcrypt hashing
- [ ] Password verification remains unbounded for compatibility with existing hashes
- [ ] Passwords bcrypt-hashed; auth via reusable dependencies
- [ ] CSRF enforced globally for cookie auth (not per-form opt-in); no
state-changing GET requests — mutations use POST/DELETE
- [ ] Client IP for rate-limit keys/allowlists/audit logs taken from a fixed
position in the X-Forwarded-For chain (not the spoofable left-most value)
- [ ] Authentication rate limits use a required shared backend; backend outages
fail closed with 503, while healthy exhausted buckets return 429 + actual TTL
- [ ] Private OpenAPI schema served only by a custom guarded route (`openapi_url=None`)
- [ ] Sentry + PostHog init guarded (no-op when unkeyed, never break a request)
Ship:
- [ ] One Docker image; web/worker/migrate are just different commands
- [ ] Migrations run pre-deploy, not at app startup in prod
- [ ] Infra is in Terraform; secrets are not committed
Reference Files
- STACK.md — pyproject,
main.py,db.py,config.py, Makefile templates - PAYMENTS.md — Stripe checkout + idempotent webhook patterns
- AUTH.md — the four token schemes and shared dependency guards
- BACKGROUND_JOBS.md — inline vs cron-worker vs Redis queue
- FRONTEND.md — Jinja/Tailwind/Alpine vs decoupled SPA (+ LLM note)
- DEPLOYMENT.md — Dockerfile, Terraform, migrations, secrets