System architecture briefing — reflects the system as it runs today

How This Site Is Built

This page is a full, honest look under the hood: how it's hosted, how updates ship, how scores go from ESPN to your standings, and the tradeoffs behind every one of those decisions.

01Big picture

The entire application runs on one Amazon Web Services (AWS) server, packaged with Docker (more on that below). That single server hosts the website, the database, and a reverse proxy — a traffic router that receives every incoming visitor and forwards each request to the right place internally, and is also where HTTPS encryption is handled. There is no fleet of servers or managed cloud infrastructure behind this today — it's a deliberately lean, low-cost setup that fits a free pool with a modest number of players. The only other AWS service in use is Amazon SES (Simple Email Service), which sends account emails such as password resets.

flowchart TB
    subgraph EXT["External services"]
        ESPN["ESPN public APIs
site.api.espn.com"] ANTH["Anthropic Claude API"] SES["Amazon SES
(email, via boto3)"] end subgraph EC2["AWS EC2 instance — one server"] subgraph DOCKER["Docker — all three services below run as containers"] subgraph NGINX_C["nginx container — :80"] NGINX["reverse proxy"] end subgraph WEB_C["web container — python:3.11-slim"] GUNI["gunicorn (production app server)
running the Flask application"] BP1["site sections: public, admin,
scores, history, daily"] SCHED["scoring scheduler thread
(guarded so only 1 runs per host)"] end subgraph DB_C["db container — mysql:8.0"] MYSQL[("MySQL database
teams · players · picks
entries · scores · settings
+ reusable database logic")] end end MCP["mcp_server.py — a local dev/learning tool
(runs on the developer's laptop, not in Docker)"] end BROWSER["Browser
players + admin"] --> NGINX NGINX --> GUNI GUNI --> MYSQL SCHED --> ESPN SCHED --> MYSQL GUNI -.->|"optional admin feature,
dev laptop only"| MCP MCP -.->|direct DB read| MYSQL MCP -.->|tool calls| ANTH GUNI --> SES GUNI --> ANTH style EC2 fill:transparent,stroke:#c65a2e,stroke-width:2px style DOCKER fill:transparent,stroke:#52584f,stroke-width:1px,stroke-dasharray: 4 3

Three things worth knowing for planning purposes, each already accounted for:

  1. Single database, single server, by design. Today the database runs as its own container on the same server as everything else — the right choice for a free app with a modest number of players, since it keeps cost at zero and complexity low. If this ever grows into something with meaningfully more players or higher stakes, the natural next step is splitting the database onto its own dedicated, managed instance (for example, Amazon RDS) so it can scale, back up, and fail over independently of the website.
  2. The score-checking "scheduler" is intentionally lightweight today, with a clear upgrade path. It's a simple built-in timer running inside the website process, not a dedicated scheduling service — the right amount of engineering for the current scale. If the tournament pool grows or we want tighter reliability guarantees, the enhancement is to move score-checking into its own standalone worker process with a real job queue, so it runs independently of the website and isn't affected by website deploys or restarts.
  3. The AI-generated commentary feature now calls its models directly, no MCP hop. An earlier version explored running Anthropic's Claude API against a private MCP (Model Context Protocol) server — a way of giving an AI assistant safe, structured read access to our own data — as a developer learning project. That MCP hop has since been removed: the daily-commentary feature now calls Claude and the self-hosted open-weight model directly (see AI: Foundation vs. Open-Weight). The MCP client code still exists in the repo but nothing imports it today — kept as reference, not deleted mid-tournament.

02Packaging and containers

The application is split into three self-contained building blocks, called containers, all built and run with Docker: the website itself, the database, and the reverse proxy. Packaging the app this way means it runs the same way on a developer's laptop as it does in production, which reduces "it worked on my machine" surprises. There's one setup for local development and a stricter, locked-down setup for production.

Development configuration

web    → built from source        :5000 open for direct testing
         live code reload for developers
db     → standard MySQL image     :3307 open for local inspection
         starter data loaded automatically on first run
nginx  → reverse proxy            :80 and :443 open
         handles HTTPS certificates

Production configuration

web    → production mode, served by gunicorn
         4 worker processes, 120s request timeout
         not directly reachable — traffic must go through the router
db     → same MySQL image, auto-restarts if it crashes
         only reachable by the website component, not the internet
nginx  → reverse proxy, port 80 only
         the single public entry point to the whole system

Two terms worth defining, since they show up throughout this page: a reverse proxy (Nginx, here) is the component that sits in front of everything else and routes incoming traffic to the right place — it's also where encryption is handled, so the actual application never deals with raw internet traffic directly. Gunicorn is the production-grade application server that runs our Python code — Flask's own built-in server is fine for a developer testing locally, but isn't designed to handle real production traffic reliably, so gunicorn takes over that job once deployed.

The build recipe for the website component is intentionally minimal: it installs only what production needs to run (the web framework, the database driver, the AWS email library, and the Claude integration), and leaves out a long tail of heavier tools — browser automation, data scraping, desktop GUI libraries — that only the project's original, now-retired scripts ever used. Keeping the production build lean reduces the app's attack surface and keeps deploys fast.

03How a code change reaches production

Releasing a change is automatic the moment it's merged — and now passes through two automated checks first before anything reaches players.

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub (main branch)
    participant CI as Automated checks
(secret scan + tests) participant GA as GitHub Actions
deploy job participant EC2 as EC2 host participant DC as docker compose Dev->>GH: git push origin main GH->>CI: on: push → branches: [main] CI->>CI: scan for accidentally
committed credentials CI->>CI: run automated test suite alt either check fails CI--)Dev: deploy is stopped here else both checks pass CI->>GA: green light GA->>EC2: SSH (key-based, scoped secrets) EC2->>GH: git fetch (auth: scoped token) EC2->>EC2: git reset --hard FETCH_HEAD EC2->>DC: docker compose up -d --build web DC->>DC: rebuild + restart web only
(db, nginx untouched) end

Every push to main now runs an automated secret scan and a test suite before the deploy step is even allowed to start — see the next section for what each of those actually checks. If either fails, the release stops there and nothing reaches the live server. Deploys only ever rebuild and restart the website component; the database and reverse proxy are left running throughout, so a release never causes a database outage.

04Automated checks before every deploy

Two lightweight, independent safety nets now run on every single push to main, before a deploy is allowed to proceed.

Secret scanning

Every file changed in a push is automatically checked for the structural signatures of real credentials — private keys, cloud API keys, access tokens — before it can be deployed. This same check also runs locally on a developer's machine at the moment of committing, so a credential is caught before it's even shared, not just before it reaches production. Nothing else changes about how anyone works day to day; it only activates if a real credential pattern shows up.

Automated tests

A starter automated test suite now runs on every push, covering the bracket-projection engine — the logic that decides which team advances and what color a bracket slot renders, which is exactly the kind of thing a player would notice immediately if it broke. It's intentionally small today rather than exhaustive, and is meant to grow over time; the value is that this is now a real, enforced gate rather than nothing at all.

05How database changes are rolled out

There is no dedicated migration tool in place. Database changes reach production through two different paths, depending on how old the change is.

Set once, at day one

The original database structure — the core tables (teams, players, scores, entries, picks, settings) and all reusable database logic — is set up automatically the very first time the database is created, and never runs again after that.

Applied on every restart since

Everything added after day one — user accounts, password resets, scoring logs, and a number of individual columns — is instead built into the application's own startup code, and re-checked every time the website component restarts.

In practice, this means the application's startup routine doubles as the database change process: rolling out a schema change is done by adding a small piece of startup logic and deploying it, rather than through a dedicated migration tool. The code is written defensively so that four processes restarting at once don't step on each other. This was the deliberate, ease-of-delivery path chosen to hit a hard, immovable deadline — the tournament starts when it starts — rather than a lack of awareness: a standard migration tool is already the planned next step and is scoped as a Tournament 2027 enhancement (see §11), once there's runway to make that change outside of live tournament season.

Reusable database logic (stored procedures), set up on day one

ProcedureUsed byWhat it's for
InsertTeams / DeleteTeamsByYearSeason setupLoads the 68-team bracket field for the year
InsertPlayers / DeletePlayersByYear / DeletePlayersOnTeamByYearSeason setup, admin toolsLoads team rosters, or reloads one team at a time
getAllPlayersSeason setupReads back the full player pool for the active year
InsertPlayer_PtsLive scoring engineRecords a player's or team's points for a completed game
DeletePlayer_pts / DeleteTeamPlayer_ptsLive scoring engineClears a game's prior results right before rewriting them, so a rescore never leaves stale numbers on screen
ExistsPlayer_ptsLive scoring engineChecks whether a game's results are already recorded, so it isn't scored twice
DeletePicksAdmin toolsClears all player picks, for a full season reset

Every score update follows the same safe pattern: clear the old numbers and write the new ones in a single, all-or-nothing step, rather than editing figures in place. Practically, that means a score can be corrected and rewritten at any time — even hourly, as described below — without a player ever seeing a half-updated or incorrect number on their screen.

06How ESPN scores become player standings

flowchart TB
    T["Scheduler thread starts
when the website boots up"] T --> L{"Every 60 seconds:
are we inside an active
tournament window,
and has it been 5+ minutes
since the last check?"} L -- "not yet" --> WAIT["Wait"] WAIT --> L L -- "yes, go" --> S["Ask ESPN:
what games are happening today?"] S --> F["Keep only the games
that involve teams
still in our bracket"] F --> G["For each game:
ask ESPN for the full box score"] G --> P["Work out who won,
and every player's points"] P --> W["Clear that game's old numbers
and write the new ones,
in one all-or-nothing step"] W --> LOG["Log the result
(so admins can watch it live)"] LOG --> UI["Standings update automatically
for every affected player"] UI -.->|"loop continues"| L LOG -.->|"once every hour, independent of the loop above:
re-check every game again,
even ones already marked final"| S style L fill:transparent,stroke:#c65a2e,stroke-width:2px

Scoring is fully automated: the system checks ESPN for updates every five minutes during active tournament windows, without anyone needing to press a button. It's built as a lightweight, self-managed process rather than a dedicated job-scheduling service — a pragmatic choice for the current scale (see §1), safeguarded so that only one process is ever checking scores at a time. Once a game finishes, ESPN's result is matched to the correct teams, players, and picks in our system, and every affected player's standing updates automatically. As a safety net, results are re-checked once an hour even after a game is marked final, and admins have a manual "run now" button plus a one-click repair tool if ESPN's own data ever needs correcting. A separate "daily simulation" mode lets the team test the scoring logic against real, in-season games before the tournament starts, without affecting live bracket data.

07The visual bracket report

This is the centerpiece feature players see: a full tournament bracket that colors in as the tournament unfolds, showing at a glance who's still alive and who's out. It does more than display results — it projects the entire bracket forward, even for games that haven't been played yet.

1 Houston 16 SIU-Edw. ↳ advances (r-win) 9 TBD — not yet played
  1. Completed games — once a result is final, the team is locked in as a win (green) or loss (red, name struck through).
  2. Games not yet played — the bracket still shows a projected winner for every remaining matchup, favoring whichever team the viewer actually drafted players from, so a player's bracket always looks complete rather than half-empty.
  3. Early-warning alerts — if a player has drafted players from two teams that are on a collision course to face each other, the bracket flags the earliest round that could happen with a 💣 warning icon, so the risk is visible before it happens.

There's also a side-by-side comparison view, so two players can see their brackets projected next to each other.

This logic now has automated test coverage as well (see §4) — the exact scenarios above (a confirmed result overriding a projection, a projection favoring the viewer's own pick, and the earliest-collision math) are each checked automatically on every change.

08Predicting which players will score the most

This is a predictive question, not a bracket simulator: given everything known about a player, which factors actually predict how many points they'll score in the tournament — and can that help players draft smarter? The active approach is a dedicated analytics warehouse, covered in full on its own page.

Current direction: a separate DuckDB-based warehouse pulls conformed, analysis-ready copies of teams/players/games/odds out of the live database, kept deliberately apart from it so exploration never touches production. See Data Warehouse for the full schema, the reporting-then-statistics-then-AI approach, and the first real findings.

An earlier attempt at this same question exists in the codebase (app/blueprints/analytics.py) — a self-contained pipeline that pulled nine years of ESPN box scores, built a per-player feature profile (seed, scoring average, rebounds, assists, team strength, national ranking, hot streaks), and trained a gradient-boosting model to rank likely top scorers. It's fully built but was shelved — the blueprint is present but not registered, so none of its routes are reachable. It's kept as reference for feature ideas rather than revived, since the warehouse-based approach gives a cleaner, more reproducible foundation to build the same answer on.

09Why there's no traditional database backup

This is a deliberate call rather than a gap, given what the data actually is and what it costs to lose.

The reasoning: almost everything in the database is either re-collectable or re-derivable from a source of truth outside our own system, so a full backup-and-restore pipeline would mostly be insuring against a scenario with a cheap fallback.
  1. Almost nothing in this system is typed in by hand — the one exception is what a player selects on the entry form. The 68-team bracket field and every team's roster are pulled directly from ESPN once the field is announced on Selection Sunday night, not manually keyed in by an admin. Scores are then pulled from ESPN in real time throughout the tournament, automatically, as covered in §6. The only information that actually originates inside our own system is a player's entry — their picks, name, and contact info from the entry form.
  2. Every result traces straight back to ESPN, and can be checked against it directly. Because games, teams, and players are all keyed to ESPN's own IDs, every score and standing in the app can be validated by following a link back to the exact ESPN game, team, or player page it came from — there's no "trust us" step; the source is always one click away.
  3. All game and scoring data is rebuildable straight from ESPN. ESPN's public API is the actual source of truth the app already pulls from continuously — if the database were ever lost, every score, result, and standing could be regenerated by simply re-running the same ESPN fetch the app already does every five minutes during the tournament. Player picks are the only data that isn't ESPN-sourced, and that's also the smallest, lowest-volume dataset in the system — a single form submission per player, recoverable by re-collecting entries if it ever needed to be rebuilt.
  4. The scale doesn't justify the overhead. This is a small, free pool for a modest group of players, not a system where downtime or data loss carries financial or contractual consequences — so the cost of maintaining a backup pipeline isn't worth paying today. This is exactly the kind of tradeoff worth revisiting if the app ever grows in scale or stakes (see §1).

10Other components worth knowing about

ComponentRole
Claude / Anthropic + the self-hosted open-weight modelPowers the optional admin daily-commentary feature via direct API calls to both a hosted foundation model (Claude) and a self-hosted open-weight model — see AI: Foundation vs. Open-Weight for the full comparison. An earlier MCP-based version of this feature was retired in favor of calling each model directly.
Amazon SES (Simple Email Service)The only other production AWS service in use besides the server itself — sends account emails such as password resets
Traffic protectionsBuilt-in safeguards against request flooding and cross-site form abuse, applied across the whole site
Legacy scriptsEarly, pre-automation tools used before the current app existed — no longer part of the live system, kept only for historical reference

11Known architecture decisions, scoped for Tournament 2027

These three items are not gaps discovered after the fact — they're known architecture tradeoffs, made deliberately, and already scoped as planned work rather than open risk. 2026 delivery prioritized shipping on a fixed, non-negotiable deadline — the tournament starts when it starts — on top of infrastructure already proven to work. That's the right call under a hard timeline, and it also means these three specific improvements were consciously deferred, not missed. All three are scoped for the offseason ahead of Tournament 2027, when they can be made outside the pressure of live tournament delivery.

Planned itemWhat it adds
Real database migration toolReplaces the current startup-time approach (§5) with a standard, versioned migration tool — giving every schema change a reviewable, reversible history instead of relying on application code
Deploy rollback pathA way to revert a bad release to the last known-good version directly, rather than the current approach of pushing a new fix commit forward
Monitoring and alertingActive notification (uptime checks, error alerts) if a core process — like the scoring scheduler — stops running, instead of relying on someone noticing