Build log: the Bench stack
Bench is live locally. Deploying to gatekeeper next. Before I do, I want to document what's been built and the reasoning behind it.
The stack
- Python 3.12 / Django 5.1 - full-stack, no separate frontend
- Tailwind CSS via CDN + HTMX - no build step in dev
- PostgreSQL 16 in production, SQLite locally
- Redis 7 + Celery 5.4 - async tasks for email and billing
- django-allauth - email-based auth, no usernames
- markdown2 - class descriptions and blog posts stored as Markdown
- whitenoise - static files served from Django in production
- Gunicorn - WSGI server behind Nginx in production
- GitHub Actions - ruff lint + pytest on every push
Why Django and not something else
I know Python. I know Django. For a solo project with a tight timeline, framework familiarity matters more than framework fashion. Django gives me auth, admin, ORM, migrations, and a solid templating system out of the box. That's a lot of ground not to cover.
I considered FastAPI + a frontend framework. I rejected it. Two languages, two deployment targets, two things to debug. For a site that's fundamentally HTML pages with some interactive bits, that complexity isn't justified.
Why HTMX and not React
Bench has one genuinely interactive component: the queue status widget. It polls for updates and re-renders when someone joins or leaves. That's it.
React is the right tool for applications with complex client-side state. A queue status widget is not that. HTMX handles it in about 20 lines of HTML attributes and a Django view that returns a partial template. No build step, no node_modules, no separate deployment.
The queue model in the data layer
The core business logic lives in apps/queue/services.py. The data model looks like this:
BenchClass → ClassQueue (many) → QueueEntry (many)
A BenchClass is the content definition - title, description, GitHub repo, price. A
ClassQueue is a specific scheduled instance of that class, with a status that moves
through filling → scheduling → scheduled → completed. A QueueEntry is one student
in one queue, with a position and a payment status.
This means a class can run multiple times. When a queue hits the max cap (30), a new
ClassQueue is automatically created. Students who joined late roll into the next
session without losing their place.
Celery for async work
Milestone notifications (25%, 50%, 75% full), scheduling emails, and billing happen asynchronously via Celery tasks. In local dev, Celery isn't running - all the queue mechanics work, but notifications don't fire. That's fine for development.
In production, Celery runs as a separate container alongside Redis. Tasks are
defined in apps/queue/tasks.py.
What's stubbed
Payment processing. The PaymentService interface exists and is wired into the billing
flow, but it doesn't charge anyone. Stripe integration is next on the list after deployment.
CI/CD
GitHub Actions runs two jobs on every push: ruff (lint + format check) and pytest
against a real Postgres + Redis instance. The workflow is in .github/workflows/ci.yml.
Deployment will be a Makefile target that SSHes to gatekeeper, pulls the repo, and restarts the Docker Compose stack. Manual for now. Automation later if it earns it.
What's next
Deploy to gatekeeper. Get bench.perdrizet.org live. Wire up real email. Then Stripe.