System architecture briefing — reflects the system as it runs today
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.
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:
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.
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
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.
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.
Two lightweight, independent safety nets now run on every single push to main, before a deploy is allowed to proceed.
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.
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.
There is no dedicated migration tool in place. Database changes reach production through two different paths, depending on how old the change is.
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.
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.
| Procedure | Used by | What it's for |
|---|---|---|
InsertTeams / DeleteTeamsByYear | Season setup | Loads the 68-team bracket field for the year |
InsertPlayers / DeletePlayersByYear / DeletePlayersOnTeamByYear | Season setup, admin tools | Loads team rosters, or reloads one team at a time |
getAllPlayers | Season setup | Reads back the full player pool for the active year |
InsertPlayer_Pts | Live scoring engine | Records a player's or team's points for a completed game |
DeletePlayer_pts / DeleteTeamPlayer_pts | Live scoring engine | Clears a game's prior results right before rewriting them, so a rescore never leaves stale numbers on screen |
ExistsPlayer_pts | Live scoring engine | Checks whether a game's results are already recorded, so it isn't scored twice |
DeletePicks | Admin tools | Clears 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.
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.
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.
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.
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.
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.
This is a deliberate call rather than a gap, given what the data actually is and what it costs to lose.
| Component | Role |
|---|---|
| Claude / Anthropic + the self-hosted open-weight model | Powers 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 protections | Built-in safeguards against request flooding and cross-site form abuse, applied across the whole site |
| Legacy scripts | Early, pre-automation tools used before the current app existed — no longer part of the live system, kept only for historical reference |
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 item | What it adds |
|---|---|
| Real database migration tool | Replaces 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 path | A 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 alerting | Active notification (uptime checks, error alerts) if a core process — like the scoring scheduler — stops running, instead of relying on someone noticing |