Deployment

Premier League Predictor: FastAPI & PostgreSQL

Chapter 11 · Deployment

Every earlier chapter ran with uvicorn main:app --reload — a genuinely fine development server, and a genuinely wrong choice for real production traffic.

Running With Multiple Workers

pip install gunicorn gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 --bind 0.0.0.0:8000

Gunicorn manages several separate Uvicorn worker processes — real request-handling capacity beyond what one process provides, with automatic restarts if a worker crashes.

No CORS anywhere in this course, for the same reason as its own FastAPI sibling
This site's own fastapi-food-tracker-1 course names the exact same fact: mounting the frontend via StaticFiles on the same FastAPI app since Chapter 1 means the frontend and API have shared one origin in every environment, development included — there was never a cross-origin situation for CORS middleware to solve.

A Full-Circle Caveat on Chapter 1's Own Favorite Feature

Chapter 1 named automatic interactive docs at /docs as a genuine advantage this course's own reference-implementation choice earns for free. Before a real deployment, that feature deserves an honest second look — it lets anyone browsing it try real API calls, including writes, directly against the live database:

# main.py import os app = FastAPI( title="Premier League Predictor", docs_url="/docs" if os.getenv("ENVIRONMENT") != "production" else None, redoc_url=None, )
The same feature, praised in Chapter 1, caveated here
A genuinely useful development tool and a genuinely public API explorer aren't automatically the same thing to want live — especially in this course, where nothing gates POST/PATCH/DELETE routes behind any login (Chapter 3's own honest warning). Disabling /docs conditionally, as above, keeps Chapter 1's own advantage intact during development while making its production exposure a deliberate choice.

PostgreSQL Connections at Scale: A Real Math Problem

Unlike the FastAPI/SQLite sibling course — which has no real connection pool at all — SQLAlchemy's create_engine() against PostgreSQL genuinely does pool connections by default. That's a real advantage this course's own stack choice earns... but it introduces a different, equally real problem once gunicorn enters the picture.

Each worker process gets its own engine, and its own pool
database.py's own engine = create_engine(DATABASE_URL) runs once per Python process. With -w 4, gunicorn starts four completely separate OS processes, each importing database.py independently and creating its own engine with its own connection pool — they don't share one pool between them. SQLAlchemy's real defaults are pool_size=5 plus max_overflow=10, meaning a single worker can open up to 15 real connections to PostgreSQL under load. Four workers at that default: up to 60 real connections, all from one deployed app — a meaningful fraction of PostgreSQL's own default max_connections of 100, before counting anything else (an admin tool, a monitoring agent, a second app on the same database) that might also be connecting.

A concrete, sized fix rather than an abstract warning — capping each worker's own pool explicitly:

# database.py (production-sized) engine = create_engine(DATABASE_URL, pool_size=5, max_overflow=2)

Four workers at pool_size=5, max_overflow=2: up to 7 connections per worker, 28 total at genuine peak — comfortably under the default 100-connection ceiling, with real headroom left for anything else connecting to the same database.

Never deploy with Chapter 1's own local fallback DATABASE_URL
Chapter 1's own os.getenv("DATABASE_URL", "postgresql://postgres:@localhost:5432/pl_predictor") fallback has no password at all — fine for a local development database nobody outside the machine can reach, a real security problem for anything actually deployed. A real, strong DATABASE_URL — with a genuine username and password, and pointed at the real production database host — must be set as an actual environment variable on the deployment host; the local fallback should never be what's actually running in production.

Schema Changes After Deployment

Chapter 2's own Base.metadata.create_all(bind=engine) creates tables that don't yet exist — it does not alter a table that already exists. Adding a new column to Fixture after this app is already deployed and running against real data would need a genuine, manual ALTER TABLE, or a real migration tool like Alembic, neither of which this course builds. That's an honest, deliberate scope boundary for a personal project at this size, not an oversight — worth knowing about before it's actually needed rather than discovering it the hard way mid-season.

TLS Termination

Gunicorn itself doesn't handle TLS certificates — a reverse proxy (nginx, the same pattern this site's own Nginx In Depth course already covers) sits in front of it, terminating HTTPS and forwarding plain HTTP internally.

A Production Checklist

  • Run with gunicorn + Uvicorn workers, never --reload.
  • Decide deliberately whether /docs should be public, and disable it if not.
  • Set a real DATABASE_URL with genuine credentials as an environment variable — never the local, passwordless fallback.
  • Size pool_size/max_overflow against worker count and PostgreSQL's own max_connections, not the unmodified defaults.
  • Know that a schema change after deployment needs a manual ALTER TABLE or a real migration tool — create_all alone won't do it.
  • Put a reverse proxy in front for TLS termination.

Where This Course Is Headed

One chapter left: a capstone tying every route and page built across this course into one complete, working predictor.

Hands-On Exercises

Exercise 1

Explain why this course never needed CORS configuration anywhere, tracing the reason back to a decision made in Chapter 1.

📄 View solution
Exercise 2

Explain why four gunicorn workers running with SQLAlchemy's default pool_size/max_overflow settings can collectively open up to 60 connections to PostgreSQL, not 15, and calculate the real total after applying this chapter's own pool_size=5, max_overflow=2 fix.

📄 View solution
Exercise 3

Explain what Base.metadata.create_all actually does and doesn't do, and describe what would genuinely need to happen to add a new column to the Fixture table after this app is already deployed and running.

📄 View solution

Chapter 11 Quick Reference

  • Run with: gunicorn -k uvicorn.workers.UvicornWorker -w 4, never --reload in production
  • No CORS anywhere: StaticFiles has served the frontend same-origin since Chapter 1, same as the FastAPI/SQLite sibling course
  • Full-circle caveat: /docs, Chapter 1's own praised feature, should be a deliberate production decision, especially given no route in this course is auth-gated
  • Real connection math: 4 workers × unmodified defaults (pool_size=5 + max_overflow=10) = up to 60 real PostgreSQL connections; the same 4 workers at pool_size=5/max_overflow=2 = up to 28
  • Never deploy the local fallback DATABASE_URL — it has no password
  • Schema changes after deployment need a manual ALTER TABLE or a real migration tool — create_all alone can't do it
  • TLS: a reverse proxy in front, same pattern as this site's own Nginx In Depth course
  • Next chapter: Capstone