Skip to content
Dhruv Ranjan
← All work

Client work · Live in production

Entrance UG

An exam-prep platform for Indian management-entrance exams. Students sit timed mock papers, work through a lesson library and reading-comprehension drills, and get matched with mentors. Parents, mentors and admins each get their own portal.

I designed and built all of it on my own, and ran it in production through launch.

Role
Sole engineer — architecture through production operations
Type
Client work, paid engagement
Built
Jun – Sep 2026
Status
Launched September 2026, live today

In short

The short version

  • Built the payments layer so money and access can never disagree. The server asks the gateway what happened rather than trusting the browser, settlement is idempotent, and an hourly sweep picks up anything that went quiet. 29 payment scenarios cover it and they run before every deploy.
  • Shipped four products on one Postgres schema of 100+ tables: timed mock exams, a course library, reading drills and cohort mentorship. Five kinds of user see five different versions of it, and each one is a separate portal.
  • Replaced form-by-form content entry with a bulk importer that shows you a dry run first. It spans all four products and now carries 99.7% of a 5,000-question bank.
  • Built the API's delivery pipeline on AWS (GitHub Actions → ECR → SSM onto EC2), instrumented it, then cut image build time 40% by caching the dependency layer.
  • Ran an authorization sweep that fired every route with credentials that shouldn't open it — wrong role, and right role holding someone else's id. Roughly 1,683 negative cases, and it caught a privilege escalation between two mentors who shared a student.
  • Tracked down a cross-user data leak in production caused by two systems that were each working exactly as designed. Closed it with one header and a written invariant.
  • Shipped 72+ schema migrations against live student data with no data loss, under a rule that splits every destructive change across two deploys.

The product

What it is

Entrance UG gets students ready for IPMAT, JIPMAT and CUET, which are undergraduate management-entrance exams in India. They are hard in a particular way: each section is separately timed, a wrong answer costs you marks, and you can post a good total and still get sunk by one weak section. Almost every design decision in the product comes back to those three things.

There are four things a student can do, and they all run on the same examination engine.

Mock tests
Full-length and sectional papers under real exam conditions, with a section clock, a question palette, mark-for-review, auto-save and auto-submit.
Course library
376 lessons organised by subject, topic and subtopic, each with its own practice test.
Daily challenge
A new reading-comprehension passage and data-interpretation set every day, with streaks, a weekly leaderboard and an archive of closed passages.
Mentorship
Cohort batches with an assigned mentor, weekly tasks, live classes, a doubt thread and batch tests.

Five kinds of user, and they each see a different product:

Students
get the four products, a dashboard, their own analysis, bookmarks and a community chat.
Parents
get a read-only view of their own child and nothing else: attendance, tasks, test results.
Mentors
get only the batches assigned to them — roster, tasks, classes, doubts and tests.
Admins
get 43 pages across 16 permission areas that can be granted one at a time.
Everyone else
get a public site with free resources, plan pricing and a sample quiz.

Behind all of that: 295 published tests, a 5,003-question bank, and a 100+ table schema holding it together.

The brief

Where this started

The brief was short to say and large to build. The client wanted their own exam-prep platform: timed mock papers, a course library, daily drills and mentorship in one place, built around the way these exams actually work.

Some things were fixed before I wrote a line.

One developer.
Me. No reviewer, no second pair of eyes, nobody to ask.
A real launch date,
because students were waiting.
A tight monthly budget,
set by the client. That one constraint decided the whole infrastructure.
My first production deployment.
I had never put anything on AWS, never owned a domain, never taken a payment, never had a user I could not just email.
This platform takes money and then has to deliver what the money bought. A bug in a mock test is annoying. A bug in the payment path is somebody’s money.

How it went

The build, in three phases

  1. 01

    Build it

    I did not start with code. I started with the database, on paper first and then in a copy of the schema I could throw away — every table, field and relationship, before anything got built on top of a shape I would regret.

    Two decisions came out of that week and both held for the whole project. The first was splitting the platform into four pillars, each owning its own tables, so a feature touches one pillar and a mistake in one cannot corrupt another. The obvious alternative was one attempts table with a type column; I took the duplication on purpose, and paid for it later when the four question tables turned out not to have matching columns.

    The second was that all four pillars share one examination engine. What surrounds a test differs by pillar, but a student sitting a paper should not be able to tell which pillar it belongs to.

  2. 02

    Harden it, then launch

    Before taking any real money I went looking for ways the payment path could be wrong. It took two days and turned up eight bugs. One of them let a student renew a plan forever off a single payment. I did not just reason about it — I reproduced it, then fixed it so the arithmetic could not produce that answer again.

    The other half of that fortnight was infrastructure, and it went badly. The plan was AWS App Runner, but AWS had stopped taking new App Runner customers months earlier, so it was not available to this account. Lightsail containers came next; that hung partway through issuing its TLS certificate, stayed stuck for days, could not be deleted, and my support case went unanswered.

    I built it a third time on a plain EC2 instance with Caddy in front, picked specifically because every step of bringing the site up is something I can watch happen from a shell and retry. What made three attempts survivable: nothing in the Dockerfile knows what it is running on, so changing hosts twice cost a deploy workflow rewrite and nothing else.

  3. 03

    Run it

    Running software is a different job from writing it, and I did not know that until I was doing it. Within a week students were telling me their dashboard was greeting them by somebody else's name. Nothing was wrong with the API and nothing was wrong with the CDN — the bug lived in the gap between them, so finding it meant thinking about the shape of the deployment rather than reading any code.

    What that month really produced was tooling. I made the deploy record what it costs on every run, because I had noticed a figure in my own documentation was an estimate from watching a screen rather than anything I had measured. I promoted my verification suite from something I ran out of habit to something that gates the deploy and can block a release. And I wrote a workflow that can diagnose the instance and roll it back without me shelling in by hand.

The engineering

Four pieces of the work

Each one stands alone. Read whichever is relevant to you. The engineering notes go a level deeper on all four.

Money

The whole payment system is built around one rule: money and access can never disagree.

The gateway's signed callback looks like a receipt and is not one. It proves a payment id belongs to an order id, and nothing else. Not that the money was captured, not the amount, not the currency. So the server stopped trusting the browser and asks the gateway directly what happened.

The same payment also arrives more than once by design, and sometimes it does not arrive at all. Both are handled without a guard flag: expiry is measured from the moment the payment was captured and written once, so settling twice gives the same answer, and an hourly sweep picks up any checkout that went quiet. A dropped webhook costs an hour's delay, never a lost payment.

Refunds turned out harder than payments. A fully refunded payment still reports its original amount, so every check passes and the grant runs again. Buy, refund, replay, keep the course. There are 29 named payment scenarios in the suite now, holding 127 assertions, and they run before every deploy.

How this works, in the engineering notes: The receipt that isn't a receipt →

The exam engine

Four products share one engine, so everything a real exam needs exists once.

The overall timer and per-section clocks, the question palette, mark-for-review, auto-save, auto-submit when the window closes, negative marking, integer answers, multiple-correct and passage questions, maths rendering and a full review mode.

The bug I am most glad I found needs no technical background. I had treated a section's time limit as a fixed deadline instead of a budget, so a student who finished section one in ten minutes walked into section two with fifty minutes on its clock instead of thirty. Time saved by working quickly got handed to the next section, which is the opposite of what a sectional paper means. The fix shipped while students had attempts in progress and carried every one of them across without invalidating anybody's sitting.

Two more rules came from asking what an admin might do mid-paper. An attempt is marked against its own snapshot rather than the live question, so fixing an answer key does not re-mark somebody who already submitted. And a paper that forbids going back to a section is now checked on the server, because a rule only the browser knows is not a rule.

How this works, in the engineering notes: A clock is a budget, not a deadline →

The analysis layer

This is the part I thought hardest about, and most of the thinking went into knowing when not to answer.

After a paper, a student gets a short written brief. It is built around the one fact that makes these exams different: a wrong answer costs you marks. That makes the most common piece of advice in test prep, attempt more questions, wrong more often than it is right. So the brief works out whether attempting more would actually have scored more, at that student's own rate of getting things right, and says which situation they are in.

The harder problem was knowing when to shut up. A full paper might carry 60 questions across 25 subtopics, so about two each. Ranking a topic on two questions is noise, not analysis. Every competitor does it anyway, because a page full of topic percentages looks thorough. I put an evidence threshold in instead, and anything below it is left out and listed at the bottom, so you can see what was skipped.

That restraint cost me two things I had already shipped. A percentile I was showing students turned out to be invented, so I deleted it. And a reading-speed figure was arithmetic pretending to be measurement: a 347-word passage came out at 1096 words per minute. It is measured properly now, and it refuses to answer when it cannot.

A precise-looking number nobody can defend is worse than an honest gap.

How this works, in the engineering notes: Knowing when not to answer →

The admin console

Students never see any of this, and a lot of the work went into it, because a platform nobody can operate is not finished.

Getting content in was the first problem. The original flow took one level at a time, which made 280 reading-comprehension passages 280 separate uploads, done by hand. So I built an importer that takes one file carrying a whole test and works out the rest. The part I would defend is that the dry run and the real run are the same code path, with the writes held back. A separate preview drifts, and the first time it drifts somebody imports 2,000 questions on the strength of a report describing different behaviour.

The other half is permissions. Sub-admins should not all see everything, and the quick version of that is a role column, which falls over the first time somebody needs two roles and a bit of a third. So there are 16 permission areas instead, granted one at a time, and nothing new is visible to every sub-admin by default.

The small stuff in here is what I would actually bring up in an interview. A student who joins a mentorship batch six weeks in inherits all its history but never shows as having missed any of it. And a deadline decides whether work was on time, not whether it can be handed in, so "done", "done late" and "never done" are three different answers everywhere they appear.

How this works, in the engineering notes: A dry run that can't lie →

Failures

Three things that went wrong

One I found, one my users found, one the machine found.

Found by me, before launch

A signature could be replayed to renew a plan forever

There is nothing in a gateway signature that ages, and my settlement code worked out the access expiry from the current time on every run. So re-posting the same signature a month later bought another month, from one payment, indefinitely. I reproduced it before fixing it: wound a purchase back to five days remaining, replayed the signature, and watched the expiry move out by 24 days. The fix measures expiry from the moment the payment was captured and writes it once, which makes settlement idempotent by construction rather than by a guard.

Found by my users, a week after launch

Students saw somebody else's name on their dashboard

The API sent no caching instructions at all, and in production every API call goes through the CDN that serves the website. So a shared cache was free to hold one student's "who am I" response and hand it to the next person who asked, whatever credentials they were carrying. Nothing was broken in the API and nothing was broken in the CDN — each was doing exactly what it was designed to do, and the bug lived in between. It is closed with one response header, and written down as a numbered invariant in the deployment doc so it cannot quietly come back.

Found by the machine

A deploy died on a full disk, and the cleanup was gated behind the step that needed it

The instance's disk had filled with images from previous deploys. The interesting part is not the disk: there was already a cleanup step, but it ran after the new container reported healthy — which is after the step that had run out of room. So once the disk filled, the pull died, the cleanup never ran, and every retry failed in exactly the same place. It runs before the pull now with no age filter, and because the running container still references the image currently serving, the live version can never be what gets removed.

Today

Where it stands

500+ students, live payments, 5,003 questions in the bank, 295 published tests and 376 lessons, all of it running on one application instance and one database.

Every piece of that was a decision with a cost attached, and I wrote the costs down.

One instance, one container.
A deploy is a restart, so a deploy that fails to start is an outage until it is fixed or rolled back. That is exactly why there is a rollback workflow. A second instance did not fit the budget, and it is the first thing I would change if availability became a requirement rather than a preference.
The API and database are secured by TLS and credentials rather than network isolation.
I costed a private network and it was declined for launch, because the gateway alone would have roughly doubled the monthly bill. Written down as a decision, with the trigger for revisiting it.
Managed container hosting was costed and declined too.
The load balancer alone costs more than everything else put together. The trigger I wrote down is a second instance becoming necessary, at which point the balancer stops being overhead and becomes the thing I needed anyway.
Background jobs run inside the API process on a timer
rather than in a separate worker. That is correct at one instance, and every write they make is idempotent, which is what would make a second instance safe to add without rewriting them.
No load test yet.
Nobody knows how the submit path behaves when forty students finish a mock in the same minute. It is the next thing I would measure.

Method

How I check my own work

I work fast, and I use the tools that make me faster, AI among them. That part is not interesting. What matters is who checks the result, and on this project that was only ever going to be me.

So I check a claim before I make it. Four times that changed what shipped.

  • A percentile I was showing students was invented. I removed the metric rather than keep a number that looked impressive and meant nothing.
  • I was about to write that I had added code splitting to the React app. It was a fair guess and I nearly wrote it down. One production build showed 50 chunks already there.
  • I nearly added a check to my test suite that duplicated one already in it. Padding a test suite is the same mistake as padding a resume.
  • I was told the migration count was 73. It is 72. The extra one is a lock file.

That last one is trivial, and that is exactly why it is on the list. The habit is not caring about the big claims. It is checking the small ones, because the small ones are where you stop looking.

Detail

The stack

Frontend
React 18, TypeScript, Vite, React Router (five separate lazy-loaded route trees), TanStack Query, Tailwind CSS, Recharts, KaTeX for maths in questions
Backend
Node, Express 5, TypeScript, Prisma, Zod for request validation, JWT access and refresh tokens, bcrypt, helmet, rate limiting, compression
Database
PostgreSQL — 100+ tables, 72+ migrations, raw SQL where the query mattered
Infrastructure
AWS — EC2, RDS, S3, CloudFront, ECR, SSM, Secrets Manager, Amplify Hosting. Docker. Caddy terminating TLS with Let's Encrypt certificates it renews itself
CI/CD
GitHub Actions → ECR → SSM. Build, typecheck and the verification suite gate the deploy. Migrations apply when the container starts
Payments
Razorpay — orders, webhooks verified on raw request bytes, refunds, disputes, reconciliation
Other services
Transactional email, SMS phone verification, presigned uploads straight from the browser
Tooling
npm workspaces, TypeScript project references, a 47-module verification suite I wrote

All of the infrastructure was new to me when I started. AWS end to end, Docker, Caddy and certificate management, GitHub Actions, SSM, Razorpay, Prisma, writing migrations that run against live data, and operating anything in production at all. I learned each of them on this project and I am comfortable in all of them now.

One more thing

A note on the source

The source is private, and that is a deliberate call. My deployment documentation names enough live infrastructure to be a useful map for anyone who went looking, and the system holds students’ personal and payment records. Publishing it would trade their security for my convenience.

I will walk through any part of it on a call with my screen shared, which proves more than a repository link would anyway.