Live at eduqueue.monzim.com. One Go API, one Remix frontend on Cloudflare Workers, one Asynq worker pool, three email providers, one operator (me).
I build platforms for a living. UIU EduQueue is the one I built for myself first — and then for ten thousand other students. This post is the long version of "how it actually works," written for the kind of reader who reads Dockerfiles for fun.
Why this exists
Every trimester at United International University, the same scramble happens on every group chat: when does the exam start, where's my room, can someone send me the routine? I lived through it for three years, and one weekend I wrote a tiny Go program that fetched my own routine, rendered a PDF, and emailed it to me on a cron. That was the entire scope.
A friend forwarded the email. Then a department chat picked it up. Within a trimester I had to choose between deleting it or building it properly. I chose properly.
Today, EduQueue is a self-hosted multi-tenant platform with five product surfaces (routine generator, currency converter, operator console, club management, analytics) sharing one Go API, one Asynq worker pool, one Postgres database, and one Redis cluster. The complexity is not in any single feature — it's in the way the pieces compose.
The 30-second architecture
┌──────────────────────┐
│ Cloudflare Workers │ ← Remix SSR + edge cache
│ (Remix Web) │
└──────────┬───────────┘
│ HTTPS · Turnstile
▼
┌──────────────────────┐
│ Go 1.24 API │ ← Public + admin routes
│ Gin · GORM · Asynq │ (port 6969)
└─┬──────────┬─────────┘
│ │
┌─────────────┘ └──────────────┐
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ PostgreSQL 14 │ │ Redis 7 │
│ routines · users │ │ Asynq queue · cache │
│ campaigns · events │◀── pub/sub ───▶│ settings live-reload│
└─────────────────────┘ └──────────┬───────────┘
│
▼
┌──────────────────────────┐
│ Asynq Worker Pool │ ← concurrency: 10
│ PDF · Email · Cron │ pdf semaphore: 2
└──────────┬───────────────┘ monitor port: 8775
│
┌────────────────────────────────────┼────────────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ AWS SES │ │ Azure Comm. Services│ │ Cloudflare │
│ Limit: ∞/min │ │ Limit: 10/min │ │ Worker (HTTP) │
└──────────────────┘ └──────────────────────┘ │ Limit: 10/min │
└──────────────────┘A request through this stack touches roughly six systems before the user sees a result. Most platforms break at the seams between them — so a lot of the engineering work is in the seams.

The system, layer by layer
1. Request ingress
The frontend is a Remix app deployed to Cloudflare Workers (web/). Every public form is gated by Cloudflare Turnstile before the request ever reaches my origin — bot abuse is cheap to filter at the edge and expensive to filter at the worker. Turnstile tokens are validated server-side in Go before any work is queued.
The API is a single Go binary served behind Traefik (auto-TLS, label-driven routing) on port 6969. Public, admin, and DSC namespaces are mounted on the same listener:
| Namespace | Path prefix | Auth | Surface |
|---|---|---|---|
| Public | /api/* | Turnstile + IP rate-limit | Routine requests, currency, tracking pixels |
| Operator | /api/eduqueue/admin/* | Email-OTP session | Analytics, campaigns, queue control, templates |
| DSC Club | /api/dsc/* | JWT (HS256) | Member, event, payment, T-shirt distribution |
| Tracking | /api/t/* | Signed message ID | Email open pixel, click redirect, bounce webhook |
2. The Asynq worker pool — where the real work lives
The interesting part of EduQueue is not the HTTP server. It's the Asynq worker pool that backs every long-running operation. The HTTP server's job is mostly to enqueue and return.
Worker config (server/worker/main.go:86-94):
| Setting | Value | Why |
|---|---|---|
| Global concurrency | 10 | Bound on Postgres/Redis connection use |
| PDF semaphore | 2 | ChromeDP is memory-heavy; 2 concurrent renders fit in 512MB |
| Bulk email concurrency | 5 | Below the Azure 10/min cap; leaves headroom for retries |
| Email queue tick | 30s | Long enough to amortize the rate-limit math, short enough to feel live |
| Worker monitor HTTP | :8775 | Out-of-band health endpoints separate from app traffic |
Registered task types and their semantics:
| Task | Handler | Retries | Idempotency key | Notes |
|---|---|---|---|---|
pdf:generate | HandlePDFTask | 2 | SubscriptionEmail(StudentID, ExamName, Type) | 50s timeout, 2-permit semaphore |
email:bulk | HandleBulkEmailTask | 2 | De-duped by subscription cooldown | Fans out into pdf:generate tasks |
campaign:custom-send | HandleCustomCampaignSend | 2 | EmailCampaign.ID + recipient set | Drains via EmailQueue rows |
monitor:health-check | gocron @ 5min | — | — | Dispatches Discord alerts on threshold breach |
monitor:daily-report | gocron @ 00:01 | — | — | 2000-char chunked Discord summary |
monitor:weekly-report | gocron @ Sun 00:05 | — | — | Full analytics roll-up |
Every task is idempotent by construction — re-running the same job either no-ops (cooldown / dedupe row exists) or produces a byte-identical artifact in S3. This is the single most important property of the system. It's the reason I can docker compose down/up mid-batch without losing anyone's routine.
3. Multi-provider email — the core of EduQueue
Email is not an integration. Email is the product. If delivery breaks, the whole platform breaks. So the sending path is the most defensively engineered part of the codebase.
Providers register against a single interface (server/worker/email_worker.go:26-202):
type EmailProvider interface {
Name() string
Send(ctx context.Context, msg *EmailMessage) error
RateLimits() []RateLimitConfig
}The current chain:
| Provider | Role | Stated cap | Failure signature → fallback trigger |
|---|---|---|---|
| AWS SES | Primary high-volume | Negotiated quota | Throttling, sandbox limits, 5xx |
| Azure Communication Services | Warm secondary | 10/min | 429, 5xx, regional outage |
| Custom Cloudflare Worker | "Break glass" tertiary | 10/min | Worker error, account suspension |
Each provider declares its own RateLimitConfig{Limit, Duration}. The worker tracks per-provider counters in Redis sorted sets keyed on provider name with a sliding window equal to the declared duration. When a provider trips its limit, the dispatcher iterates to the next; when all providers are saturated, the message stays on the EmailQueue table for the next 30-second tick.
Retries do not silently re-deliver. The EmailMessage row carries a UUID tracking_id and a Status enum (queued | sent | failed | bounced); state transitions are logged into an append-only EmailEvent table that the analytics layer reads from. Idempotency over speed.
The full design — sliding-window math, bounce handling, tracking pixels, template versioning — is the subject of the companion deep dive on the email subsystem.
4. The analytics subsystem — append-only events, on-demand rollups
The analytics layer is built on a deliberate trade-off: append-only events with on-demand aggregation, not pre-computed rollups.
| Table | Shape | Cardinality | Read pattern |
|---|---|---|---|
EmailMessage | Per-send record (UUID, status, OpenCount, ClickCount, CampaignID) | 1 per recipient × campaign | Drill-down, KPI denominators |
EmailEvent | Append-only (MessageID, EventType, OccurredAt) | 10–100× EmailMessage | Time-series, funnel |
EmailCampaign | Aggregate (TotalSent, TotalOpened, TotalClicked, TotalBounced) | 1 per campaign | Overview cards |
EmailRateLimitTracker | Per-provider window counter | Small | Live operator console |
Rollups for the operator dashboard are computed on demand because the platform does not yet generate enough cardinality to need OLAP-style pre-aggregation. The KPI overview reduces to grouped SUMs against EmailCampaign; the per-campaign drill-down joins EmailMessage to EmailEvent for the open/click timeline. When traffic warrants it, the upgrade path is a materialized view refreshed by a cron task — not a rewrite. The full schema reasoning lives in the operator-console deep dive.
5. The PDF pipeline
PDFs are generated by the worker, never by the request handler. This is the single most important architectural choice in the system: the user's browser tab is never blocked on a render.
The pipeline picks a renderer per use case:
| Renderer | When | Knobs |
|---|---|---|
| ChromeDP (headless Chromium) | Layouts that need real CSS, web fonts, or JS | A4 viewport, 0.3/0.2/0.2/0.2-inch margins, 5s post-nav settle, 50s ctx timeout, --no-sandbox --disable-dev-shm-usage for containerized runs |
| Maroto (native Go PDF) | Tabular reports, simple invoices | ~10× faster, no Chromium dependency |
Output is uploaded to Cloudflare R2 (S3-compatible) at routines/{studentID}/{filename}-{uniqueID}.pdf with Cache-Control: public, max-age=604800. Distribution is via presigned URLs — the database never stores a public URL, only the R2 key. Each user-facing PDF link is signed at request time with a configurable expiry (default 24h via S3_PDF_EXPIRY).
Access is gated by a per-routine PIN:
| Field | Purpose |
|---|---|
PIN | 6-digit, generated server-side, required to mint a presigned URL |
AccessCount | Incremented per fetch, capped by MAX_ROUTINE_ACCESS_COUNT |
ExpiresAt | Hard wall-clock cutoff (ROUTINE_ACCESS_WINDOW_HOUR, default 168h) |
EduQueueBlockedStudent | SQL-side blocklist consulted before any send |
The PIN, the view counter, and the expiry are three independent guards. Any one of them can fail open and the other two still hold the line. That is the platform-engineering instinct — defense in depth where the cost of a leak is asymmetric. The full pipeline (worker pool sizing, ChromeDP flags, R2 distribution, observability) is in the Asynq worker-pool deep dive.
6. The OTP-protected operator console
There is no password anywhere in the operator console. Logging into /admin-dashboard posts to /api/eduqueue/admin/request-otp, which:
- Generates a 6-digit OTP via
crypto/rand. - Stores
eduqueue_otp:{otp} → "valid"in Redis with a 10-minute TTL. - Posts the OTP to a Discord webhook (with requester IP and expiry hint) for out-of-band delivery.
/api/eduqueue/admin/verify-otp deletes the OTP key on success (single-use), then mints a session token (SHA-256 over otp + nanoTimestamp) into eduqueue_session:{token} with a 24-hour TTL. Every subsequent admin request carries X-Session-Token, validated by EduQueueSessionAuth middleware against Redis.

Server-side sessions instead of JWTs, on purpose:
- Revocation is a
DEL. No token denylist, no key rotation game. - TTL is the source of truth. Redis evicts; I don't have to.
- No secrets in the cookie. The token is opaque.
The same console exposes the parts of the platform that are too dangerous for a YAML config:
| Capability | Endpoint | Effect |
|---|---|---|
| Pause all sends | POST /api/eduqueue/admin/email/pause | Sets email_processor_enabled=false via Redis pub/sub — workers honor it on the next 30s tick |
| Send a test routine | POST /api/eduqueue/admin/email/test | Renders + sends without touching analytics |
| Inspect the queue | GET /api/eduqueue/admin/email-queue | Pending / in-flight / failed snapshot |
| Reset rate limits | DELETE /api/eduqueue/admin/rate-limits | Truncates EmailRateLimitTracker, publishes eduqueue:ratelimit:clear |
| Live settings | GET/PUT /api/eduqueue/admin/settings | Hot-reload via SettingsService + eduqueue:settings:update pub/sub |
Live settings is the feature I missed most when I didn't have it. Toggling email_processor_enabled from the dashboard, watching the worker log honor it on the next tick, and not redeploying — that's the difference between a hobby project and a platform.
7. The currency module — the side quest that grew up
The currency converter at eduqueue.monzim.com/currency started as a private debugging tool for AWS billing. Today it serves thousands of weekly hits with three cache layers:
- Redis hot cache. Key
exchange_rate:usd, 1-hour TTL. - Postgres warm fallback.
ExchangeRateData(ID, Base, Timestamp, Rates JSON)— used when the source API is down or rate-limited. - Open Exchange Rates as the cold source.
Reads always answer something. A miss against Redis hits Postgres for the most-recent row by created_at DESC; only a true cold start hits the upstream API. Historical queries (/api/exchange-rate/history) walk the Postgres table by date range — no pre-aggregation, but the row count is small enough that a single covering index makes it free.

8. The DSC club module — platform reuse, not a fork
The UIU Data Science Club needed a member registration system with payment verification, T-shirt distribution, event check-in, and email automation. Building a second backend would have meant a second deployment, a second Postgres, a second on-call rotation. So I built a /api/dsc/* namespace that piggybacks on EduQueue's existing email engine, queue, R2 bucket, and observability — protected by JWT (HS256, signed with ADMIN_KEY) instead of session cookies because club admins log in from places I don't control.
The T-shirt distribution flow is a small finite state machine:
| State | Transition |
|---|---|
PaymentVerified=false | Admin verifies → PaymentVerified=true |
TShirtTaken=false | Member endpoint → TShirtTaken=true + TakenAt |
FoodTaken=false | Event check-in → FoodTaken=true + FoodTakenAt |
A platform that can host a second product on existing primitives is doing its job. A platform that can't is just a website.
9. Observability — Discord as a stack
I do not pay for Datadog. I do not run Grafana. EduQueue's observability stack is Discord, and that is intentional.
A gocron scheduler in the worker (worker/monitoring.go) wakes up to emit:
- Every 5 minutes — a health check that compares CPU/Memory/Disk/API-latency against thresholds (80% / 85% / 90% / 2s) and pages Discord on breach.
- Daily at 00:01 UTC — a 24-hour roll-up: subscriber growth, send volume, open/click rates, top campaigns.
- Weekly Sunday 00:05 — a long-form report.
Long messages are chunked at the Discord 2000-char limit (monitoring.go:1028-1102). OTPs flow through the same channel. Hard-bounce and rate-limit events also surface there. The result is a single timeline I can read on my phone — and a notification model I cannot accidentally silence with mute rules.
When the platform earns paid observability, the upgrade path is a Vector or OpenTelemetry collector tailing the Discord webhook payload format. Until then, this works.
10. Deployment & CI/CD
Two GitHub Actions workflows under .github/workflows/:
| Workflow | Trigger | Output |
|---|---|---|
ci.yml | Push to main touching server/** | Builds ghcr.io/monzim/eduqueue-http:latest and eduqueue-worker:latest |
deploy.yml | Manual / repository_dispatch | SSH into prod, docker login ghcr, docker compose down/up |
Production runs as plain docker compose behind Traefik. There is no Kubernetes. There is no Helm chart. A single prod.compose.yml describes every running service, and a deploy.yml workflow mutates exactly that file in place.
This is a deliberate platform decision. The complexity budget for a one-operator system is small. Spending it on Kubernetes would mean spending it not on multi-provider failover, append-only analytics, and PIN-gated PDF distribution — the things that actually make the product reliable.
What I learned building it
Three lessons that have outlived the project:
- Idempotency is the platform. Every long-running task in EduQueue can be re-run safely — same input, same output, no double-send. That property is what lets a single operator restart, redeploy, or fix-forward without a rollback procedure.
- Async-by-default scales further than it looks. Putting every meaningful operation behind a Redis queue meant I never had to rewrite the architecture as the user count grew. The HTTP layer's job is to enqueue and return.
- A fallback is worth more than a faster path. Multi-provider email was overkill on day one. By month three it had saved an entire exam-week launch. Defense in depth where the asymmetric cost of failure is high.
EduQueue is the platform I built for myself, then for my friends, then for a university. The architecture didn't get fancier as the audience grew — it got more defensive. That is, in my experience, the only thing that matters in the long run.
Companion deep-dives
Three companion posts go deeper into specific subsystems:
- Multi-Provider Email Failover — the rate-limit math, the bounce-handler idempotency proof, the tracking-pixel cache headers.
- The Asynq Worker Pool — why a 10-permit pool with a 2-permit PDF semaphore, the cron schedule, the live-reload settings service.
- The OTP Operator Console + Append-Only Analytics — Redis-backed sessions,
EmailEventschema, on-demand rollups.

