Go
Remix
TypeScript
PostgreSQL
Redis

UIU EduQueue: Engineering a Self-Hosted Student Platform with Go, Asynq & Multi-Provider Email

A platform-engineering case study of UIU EduQueue — the self-hosted student platform that started as a 200-line script and grew into a production system with multi-provider email failover, an Asynq-driven PDF pipeline, OTP-protected admin tooling, append-only event analytics, and a Discord-integrated observability stack. Built on Go 1.24, Remix on Cloudflare Workers, PostgreSQL, Redis, and Cloudflare R2.

Azraf Al Monzim
Updated June 13, 2026
698 views
Live
UIU EduQueue: Engineering a Self-Hosted Student Platform with Go, Asynq & Multi-Provider Email cover
Listen to this post··:··

Project Overview

UIU EduQueue is a production student platform I designed, built, and operate end-to-end. It serves UIU students with personalized PDF exam routines, a public live FX converter, an OTP-secured operator console with append-only email analytics and campaign tooling, multi-provider email failover (AWS SES, Azure Communication Services, custom Cloudflare-Worker HTTP relay), a presigned-URL PDF distribution layer on Cloudflare R2, and a JWT-protected club-management API. This case study walks through the architecture, the failure modes it defends against, and the platform-engineering decisions behind every layer.

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.

Scrolled view of the EduQueue home page showing the hero, the routine request form, the Why students love us feature grid, the Stop losing money to bad rates currency promo, the three-step How it works section, and the founder's bio.
The home page in full — hero, routine form, the "Why students love us" feature grid, the currency promo, "How it works," and the founder bio. Every product surface lives behind that single landing page.

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:

NamespacePath prefixAuthSurface
Public/api/*Turnstile + IP rate-limitRoutine requests, currency, tracking pixels
Operator/api/eduqueue/admin/*Email-OTP sessionAnalytics, campaigns, queue control, templates
DSC Club/api/dsc/*JWT (HS256)Member, event, payment, T-shirt distribution
Tracking/api/t/*Signed message IDEmail 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):

SettingValueWhy
Global concurrency10Bound on Postgres/Redis connection use
PDF semaphore2ChromeDP is memory-heavy; 2 concurrent renders fit in 512MB
Bulk email concurrency5Below the Azure 10/min cap; leaves headroom for retries
Email queue tick30sLong enough to amortize the rate-limit math, short enough to feel live
Worker monitor HTTP:8775Out-of-band health endpoints separate from app traffic

Registered task types and their semantics:

TaskHandlerRetriesIdempotency keyNotes
pdf:generateHandlePDFTask2SubscriptionEmail(StudentID, ExamName, Type)50s timeout, 2-permit semaphore
email:bulkHandleBulkEmailTask2De-duped by subscription cooldownFans out into pdf:generate tasks
campaign:custom-sendHandleCustomCampaignSend2EmailCampaign.ID + recipient setDrains via EmailQueue rows
monitor:health-checkgocron @ 5minDispatches Discord alerts on threshold breach
monitor:daily-reportgocron @ 00:012000-char chunked Discord summary
monitor:weekly-reportgocron @ Sun 00:05Full 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:

ProviderRoleStated capFailure signature → fallback trigger
AWS SESPrimary high-volumeNegotiated quotaThrottling, sandbox limits, 5xx
Azure Communication ServicesWarm secondary10/min429, 5xx, regional outage
Custom Cloudflare Worker"Break glass" tertiary10/minWorker 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.

TableShapeCardinalityRead pattern
EmailMessagePer-send record (UUID, status, OpenCount, ClickCount, CampaignID)1 per recipient × campaignDrill-down, KPI denominators
EmailEventAppend-only (MessageID, EventType, OccurredAt)10–100× EmailMessageTime-series, funnel
EmailCampaignAggregate (TotalSent, TotalOpened, TotalClicked, TotalBounced)1 per campaignOverview cards
EmailRateLimitTrackerPer-provider window counterSmallLive 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:

RendererWhenKnobs
ChromeDP (headless Chromium)Layouts that need real CSS, web fonts, or JSA4 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:

FieldPurpose
PIN6-digit, generated server-side, required to mint a presigned URL
AccessCountIncremented per fetch, capped by MAX_ROUTINE_ACCESS_COUNT
ExpiresAtHard wall-clock cutoff (ROUTINE_ACCESS_WINDOW_HOUR, default 168h)
EduQueueBlockedStudentSQL-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:

  1. Generates a 6-digit OTP via crypto/rand.
  2. Stores eduqueue_otp:{otp} → "valid" in Redis with a 10-minute TTL.
  3. 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.

EduQueue admin login screen with the headline EduQueue Admin, subtitle Request OTP to access admin dashboard, a notice that the OTP will be sent to the admin Discord channel, and a red Send OTP to Discord button.
The operator console entry point. There is no password field — the OTP arrives in a Discord channel I check anyway, and the Redis-TTL session evicts itself after 24 hours.

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:

CapabilityEndpointEffect
Pause all sendsPOST /api/eduqueue/admin/email/pauseSets email_processor_enabled=false via Redis pub/sub — workers honor it on the next 30s tick
Send a test routinePOST /api/eduqueue/admin/email/testRenders + sends without touching analytics
Inspect the queueGET /api/eduqueue/admin/email-queuePending / in-flight / failed snapshot
Reset rate limitsDELETE /api/eduqueue/admin/rate-limitsTruncates EmailRateLimitTracker, publishes eduqueue:ratelimit:clear
Live settingsGET/PUT /api/eduqueue/admin/settingsHot-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:

  1. Redis hot cache. Key exchange_rate:usd, 1-hour TTL.
  2. Postgres warm fallback. ExchangeRateData(ID, Base, Timestamp, Rates JSON) — used when the source API is down or rate-limited.
  3. 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.

EduQueue currency converter page showing a USD-to-BDT result of 123.16, the quick-convert grid, currency comparison bars, batch converter, historical trend chart, popular currency pairs, and the live exchange-rate table.
The currency module — a side quest that became a public tool. Three cache layers (Redis → Postgres → Open Exchange Rates) ensure the page always renders something, even when upstream is down.

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:

StateTransition
PaymentVerified=falseAdmin verifies → PaymentVerified=true
TShirtTaken=falseMember endpoint → TShirtTaken=true + TakenAt
FoodTaken=falseEvent 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/:

WorkflowTriggerOutput
ci.ymlPush to main touching server/**Builds ghcr.io/monzim/eduqueue-http:latest and eduqueue-worker:latest
deploy.ymlManual / repository_dispatchSSH 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:

  1. 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.
  2. 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.
  3. 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:

Architecture decision records

Architecture diagram

Technologies:
Go
Remix
TypeScript
PostgreSQL
Redis
Asynq
AWS S3
AWS SES
Azure Communication Services
Cloudflare Workers
Docker
Tailwind CSS
Framer Motion
chromedp
PDF Generation
Email Automation
Multi-Provider Failover
OTP Authentication
Open Exchange Rates
Self-Hosted
Case Study
Azraf Al Monzim

Interested in this project?

Feel free to explore the source code, try the live demo, or reach out if you'd like to collaborate.