Big Picture

The full engineering deep-dive: every real piece of the system, the same names used throughout this site, and the tradeoffs behind each decision.

01Big picture

The whole app on one server: nginx, gunicorn, MySQL and the job queue in Tourney's own box; the Spark's worker and its open-weight model in a separate private box; Claude and the external data services reached directly; an offline analytics warehouse fed from the same MySQL Browser players + admin Claude via the Anthropic API Tourney's own box dev laptop, SIT, or production nginx reverse proxy · :80 / :443 gunicorn running the Flask app — public · admin · scores · history · daily web container · python:3.11-slim calls ESPN, CollegeBasketballData, SES, and Claude directly — see right MySQL 8.0 teams · players · picks entries · scores · settings Job queue (same MySQL) scoring scheduler thread guarded — only 1 runs per host (a lock file, so a second gunicorn worker steps aside) its own always-on loop — not one HTTP request The Spark's own box private hardware The Spark's worker spark_worker.py gpt-oss:20b the open-weight model, via Ollama External data services Amazon SES email, via boto3 CollegeBasketballData.com betting odds & round dates ESPN public APIs site.api.espn.com datawarehouse/etl.py standalone script · not scheduled, not in Docker Analytics warehouse DuckDB scikit-learn dev-side, optional Google Looker Studio admin-triggered odds import direct call the Spark's worker hitting the app's API over mTLS — never the queue directly, detail below manually run, never wired into live scoring offline copy for analysis feature profiles, trained model reporting Security tools used checked on push, or at admin login Tools ruff — lint bandit — SAST pip-audit — dependency CVEs scan_secrets.py — committed secrets Admin TOTP — MFA (pyotp)

Tourney's own box (AWS): Start with the one thing everything else sits on top of: a single AWS server, running the whole app in Docker — no server fleet, no managed cloud services to wire together, a deliberately lean footprint sized for a free pool with a modest number of players. On that one box, three containers do the work:

  • a reverse proxy at the front door, handling HTTPS
  • the Flask app itself
  • its MySQL database

gunicorn (Tourney App): gunicorn is the web server that runs the actual Tourney application and handles real traffic reliably. The app sits in the middle, and almost everything it does is a call out to somewhere else:

  • toward players and admins, it's a browser talking to the app
  • it pulls live scores and rosters from ESPN
  • it pulls betting odds from CollegeBasketballData.com
  • it sends account emails through Amazon SES
  • for AI, it calls out to Claude directly (hosted through the Anthropic API), or queues up the call for the Spark's local model instead — the worker polls and picks it up
  • when it needs a tool mid-job, or when it's done, the worker tells the app directly via an HTTP POST — sending a request and waiting on the reply, never the other way around

Spark: an NVIDIA DGX Spark — a dedicated GPU box you load your own models onto, sitting on the developer's own private network, not a vendor (not to be confused with the unrelated Apache Spark data-processing framework). It runs a self-hosted, open-weight model (gpt-oss:20b, served by Ollama) on that hardware. It isn't just a second API call: it runs code we built and operate ourselves, including an agentic loop that lets the model ask for its own data mid-run. A worker process on that same box watches its own job queue and picks up work whenever there's any — and that's also what keeps it safe: the Spark never accepts an inbound connection and never touches our database directly, so the only way anything reaches it is by asking, never by being reached.

Data Warehouse: Behind the live app, a second, quieter path exists purely to learn from the data rather than serve it: an ETL script pulls a conformed, offline copy of teams, players, games, and odds into a DuckDB data warehouse — kept deliberately separate from production so analysis never touches live scoring, feeding the search for what actually predicts a good draft pick (full detail on Data Warehouse).

Two 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.

Other features, and what it's built with

Other features
  • Live Scores — in-progress and upcoming games appear on every standings page, refreshing every 30 seconds.
  • Upsets Tracker — automatically detects and lists every upset (higher seed beats lower seed), with affected bracket count.
  • Compare — pick any two entries head-to-head to see shared picks, unique picks, and current score difference.
  • Prediction Page — filter and sort entries by projected final score.
  • Tiebreaker — used only if two entries are tied; closest guess for championship total (without going over) wins.
  • Backup & Restore — admin can download a full JSON snapshot of all users, entries, and picks at any time. If needed, the site can be fully restored from that file, with users prompted to reset their passwords on next login.
Built with

App & hosting

Python / Flask Flask-WTF (CSRF) Flask-Limiter (rate limiting) Gunicorn MySQL SQLAlchemy Docker Amazon EC2 Nginx

Frontend

Bootstrap 5 HTMX Mermaid.js (diagrams)

Data sources

ESPN public APIs (scores, rosters, schedule) BeautifulSoup (roster-name lookup) CollegeBasketballData.com (betting odds & round dates)

AI

Claude (Anthropic API) gpt-oss:20b (self-hosted, via Ollama) Tailscale (private network to the Spark)

Email & analytics

AWS SES, via boto3 DuckDB (analysis warehouse) pandas scikit-learn (dev-side, optional — a shelved predictive-scoring pipeline)

Quality & CI/CD

ruff (lint) bandit (security lint) pip-audit (dependency audit) Qcoder (AI code review) Git GitHub Actions

02Environments

One straight line from commit to production, through three environments in order — no side branches. A commit to main auto-rebuilds SIT the instant its 5 gates (secrets · tests · lint · bandit · pip-audit) go green; the one human step is testing in SIT and clicking "Run workflow"; from there GitHub Actions promotes sit straight to prod, fast-forward-only.

What each environment holds and its worst case if compromised is on The Environments; full detail on the gates and the promotion mechanics is on Deploy; this is just the shape of it.

commit user tests, then Run workflow deploy Dev worktree trunk-based — every commit lands on main SIT auto-rebuilds the instant gates go green GitHub Actions sitprod, fast-forward only manually triggered Production AWS EC2 — same box, now on prod
commit, gates green
automatic
manual click
✕ Refused unless prod can move straight to sit's exact tip — GitHub Actions' fast-forward-only promotion, covered in full on Deploy.

03AI

Two boxes, two machines. Tourney's own box is wherever the web app and its MySQL happen to be running — a dev laptop, SIT, or production — and that same MySQL holds the job queue, so "dropping a job in the queue" is really just a row in the app's own database. The Spark's own box is separate, physical, private hardware: it holds a worker process and the open-weight model itself. The app calls Claude directly, over the open internet, the same as any other API call. For the open-weight model, the app never calls out at all — it drops a job in the queue and the Spark's worker comes and gets it on its own schedule, so the Spark is never the one being reached into. (A second, queue-free way to reach the Spark directly also exists in the code, over a private Tailscale network, but it's currently switched off while that network path gets revisited — not shown here.) Full detail on both, including exactly how the queue path is secured despite crossing the open internet, on AI: Foundation vs. Open-Weight.

Where each piece actually lives: the app and the job queue share one box, the worker and the open-weight model share a separate private machine, and Claude is Anthropic's own hosted service reached directly from the top Claude hosted by Anthropic Tourney's own box Tourney web app Job queue (in the same MySQL) The Spark's own box — private hardware The Spark's worker gpt-oss:20b the open-weight model, served locally by Ollama 1 5 3 2 4
  1. Tourney drops a pending job in the queue — a row in its own MySQL, nothing more.
  2. The Spark's worker polls Tourney's API and claims it — the worker calls out to the app, over the open internet, on a dedicated connection secured by a private client certificate plus a bearer token; the app never calls the Spark. Every other call the worker ever makes — including a tool ask mid-job — goes over that same secured connection, not a lighter or separate one.
  3. The worker runs gpt-oss:20b locally, on the Spark — a call to Ollama that never leaves that machine.
  4. The worker posts the result back to Tourney's API — again, the worker initiating outward, the same as step 2.
  5. Tourney saves the result to the queue — back in the same MySQL row the job started in.

Solid arrows are one machine calling another directly. Dashed arrows are the Spark's own worker reaching out on its own — the Spark accepts no incoming connections, so every dashed arrow starts from the worker, never the app.

For an agentic run, steps 2 and 4 each still happen only once — claiming the job, and posting the finished commentary, are separate calls that don't repeat. What repeats is a third kind of call, sandwiched inside step 3: every time the model wants data (today's standings, results, top movers) instead of finishing, the worker posts that one specific ask to the app, gets the answer back in the same response, and feeds it to the model before continuing — up to 20 times per job. Same pattern as steps 2/4 (worker calls out, app answers, nothing ever calls back into the Spark), just its own separate exchange. The Claude-direct agentic arm works differently and never touches the Spark at all: its tool loop runs entirely in-process inside Tourney's own box, calling the same Python functions directly rather than crossing any network.

"Tourney's own box" is really three different boxes over time, all hitting this same one Spark. There's only one Spark in the whole system, not a copy per environment — a dev laptop, SIT, and production (AWS) each write their jobs into their own queue, and the same physical Spark reaches out to check all three in turn. SIT is the odd one out: it doesn't even cross a network to get there, since SIT's own deploy lives directly on the Spark's own disk — dev and production reach it over a hop, SIT is already there. Full detail on how that's wired on The Environments.

How that connection proves who's who

Steps 2 and 4 above are the two moments a request actually crosses the network, and both cross it the same secured way: mutual TLS, the same encrypted-connection technology behind any https:// address, extended so both sides prove who they are rather than just one. Every time the Spark's worker reaches out, it presents a certificate — a private credential belonging to it and to no one else — and the app only continues once that certificate checks out.

A certificate only works once two things have already happened, each done once and by hand: it's created for that one worker, and it's separately registered on that environment's own approved list. A certificate that's genuine but was never registered — or one issued for a different environment entirely — is refused before the request ever reaches the app itself. Because dev, SIT, and production each keep their own certificate and their own approved list, a credential from one can never be used against another.

Before any connection happens, a certificate is created for the Spark's worker and separately registered on the app's own approved list; only then does the worker's certificate get accepted each time it reaches out to Tourney's web app Certificate created for this one worker Registered as approved added to this box's own list Tourney web app opens up only for a registered certificate The Spark’s worker reaches out first, every time shows its certificate No certificate, the wrong one, or one never registered — refused before the app ever runs.

04How it's actually built — the whole system, at a glance

The full diagrams — every real piece, the same names used throughout this site, not simplified stand-ins — are in Big Picture and AI above, and on the dedicated Special Features page, which goes deeper still: how ESPN scores become standings, the visual bracket report, predictive scoring, and the reasoning behind decisions like skipping a traditional database backup. One thing worth calling out here: the job queue is how the app reaches the open-weight model without ever calling out to the Spark itself — the app writes a pending job row, spark_worker.py polls it out over that same secured connection rather than the app calling in, and the worker calls Ollama locally before posting the result back. The table below is the click-through map: every named piece links to where it's covered in full engineering depth.

ComponentWhat it isRead more
Flask web app, MySQL, AWS EC2The one-server hosting setup: how it's packaged, deployed, and kept runningDeploy →
ESPN public APIsThe real, live source for the tournament field, rosters, and every scoreSpecial Features →
Claude & the SparkThe same daily-commentary task, run through a hosted model and a self-hosted open-weight model, side by sideAI: Foundation vs. Open-Weight →
Amazon SESSends account emails such as password resetsPlayer Experience →
Analytics warehouse (DuckDB)A separate, offline copy of the data used to study what predicts a good draft pick — deliberately kept apart from live scoringData Warehouse →
This page reflects the system as it is actually built and deployed today, based on a direct review of the source code, configuration, and deployment pipeline. Previously at /about/architecture, which still redirects here. The deeper implementation walkthrough — how ESPN scores become standings, the visual bracket report, predictive scoring, and more — moved to its own page, Special Features, 2026-08-03.