Case study · Live at chess-mate.online
Django · Celery + Redis · React · PostgreSQL · Stockfish · AWS Elastic Beanstalk · Sole builder and operator, Jan 2025 - present
Overview
ChessMate imports a player's games from Chess.com or Lichess, analyzes them with Stockfish through an asynchronous pipeline, and turns the results into coaching: single-game feedback and batch reports that find recurring patterns across 5-30 games. It runs in production on AWS with Stripe-backed credits, currently serving 10+ pilot users who have analyzed 100+ batches. I have built and operated every part of it: 818 commits over 18 months.

The product: a batch coaching report generated from 8 analyzed games
Problem
Chess sites show engine evaluations one game at a time. That tells you what went wrong in a single game, but improvement comes from patterns: the same opening mistake, the same endgame weakness, repeated across dozens of games. That is exactly what a human coach charges for. ChessMate's premise: analyze games in bulk and surface those patterns as a prioritized training plan.
What I built
Architecture
Browser
React SPA
ALB
HTTPS
EC2 t3.small
Elastic Beanstalk · single Docker container
RDS PostgreSQL
games, PGN, report JSONB
External APIs
Chess.com / Lichess · OpenAI · Stripe
GitHub Actions: tests + lint + scans → Docker image → EB deploy
The whole platform runs in one Docker container on a single t3.small behind an ALB, with PostgreSQL on RDS. Game PGNs and analysis results live in Postgres (JSONB), not S3: at roughly 6-80 KB per game, storage is negligible and one datastore keeps queries and backups simple.
Two decisions and their tradeoffs
Stockfish is CPU-bound and shares a t3.small with the web server, so the pipeline runs one game at a time (SEQUENTIAL_BATCH_ANALYSIS, a single worker process). The cost: a 10-game batch takes 30-50 minutes wall-clock and only one batch runs at a time. The gain: no CPU contention with API traffic, predictable memory, and a negligible marginal cost per batch on infrastructure I can run indefinitely. The UI is built around this constraint, with progress tracking per game and honest ETAs, and the documented scaling path (a dedicated worker instance, then parallel workers) waits until real demand exists.
Redis serves as both cache and Celery broker, and it runs bundled in the same container as Django, not as a managed ElastiCache cluster. The tradeoff is durability: a redeploy wipes the cache and any queued tasks. I accepted that because the cache is rebuildable, batches are cancelable and retryable by design, and ElastiCache plus the NAT Gateway it pulls in would roughly double the monthly bill at pilot scale. The failure mode this creates is real (it caused the incident below), so the mitigation lives in the job design rather than the infrastructure.
A production failure and the fix
A deploy restarted the single container while a batch was mid-analysis. The in-flight Celery task died with the worker, and the batch record stayed "in progress" forever: no error, no completion, just a report that never arrived. With everything in one container, any deploy could orphan whatever was running.
The fix was to make long-running work bounded, cancelable, and recoverable. Every per-game task now carries soft/hard time limits (840s/900s) so nothing hangs unbounded. A cancel_batch management command marks orphaned batches failed (by batch ID or Celery task ID, with an audit reason), and the ops runbook documents the diagnosis path. On the user side, a "Retry Failed Games" action re-queues only the games that failed, so recovery doesn't need support intervention or re-paying credits.
The takeaway: if your queue and workers can vanish on deploy, that constraint has to be designed into the jobs themselves: time-bound every task, make every job state reachable by ops tooling, and give users a self-serve retry.
Where it stands
Things I can go deep on