The Hack in Front of the CMS: A Cache and a Checkout on Directus

At 2,000 users Directus responses turned slow and heavy and the CMS had no payments, so we added a scoped FastAPI proxy for caching and CCAvenue checkout.

The Hack in Front of the CMS: A Cache and a Checkout on Directus
Contents

The success problem, up close

Chapter 1 ended on a good note with a nasty tail. The throwaway MVP on Directus worked — people came back, finished long talks, and we were climbing toward roughly 2,000 users. But the same thing that proved the bet started to hurt. As traffic grew, the API responses coming out of the CMS got heavy and slow, and the app began to feel sticky on exactly the cheap Android phones our audience actually used.

Underneath that were two separate walls, and we hit them in the same few weeks.

The first was performance. Directus auto-generates an API over your content, and the moment your content has relationships — a course has lessons, a lesson has an audio file and a thumbnail and a language tag — you start asking it to stitch related rows together on every request. A home screen that shows “twelve courses, each with its cover, its lesson count, and its language” is quietly asking the database for a dozen joins and returning a fat blob of JSON, most of which the client throws away. On a fast laptop over office wifi you never notice. At 2,000 real users on 3G, you notice.

The second wall was simpler and more existential: the CMS had no payments, and now we needed them. Chapter 1’s whole thesis was validate that people consume before you ask them to pay. They were consuming. That earned us the right to ask the next question — will they pay? — and Directus had no answer for it, because a headless CMS is a content tool, not a store.

So: responses too slow, and no way to take money. Both true at once, both blocking, and neither one worth a ground-up rebuild yet.

Three words, three plain ideas

Before the decision, three pieces of jargon this chapter turns on. If you already know them, skip ahead — I’d rather over-explain than lose anyone.

A proxy

A proxy is a server that sits in front of another server and speaks for it. The client stops talking to the real backend directly and talks to the proxy instead; the proxy decides what to do with each request — answer it itself, change it, or pass it along to the real backend and relay the reply. The mental model is a receptionist. You don’t walk into the back office and rummage through the filing cabinet; you ask the person at the front desk, and they decide whether to hand you something already sitting on the counter, or walk to the back and fetch it. Everything still works if the back office is a mess — the visitor never sees it.

That “speaks for it” property is the whole trick. A proxy is a place to add behavior — caching, auth, payments — without touching the system behind it.

Caching

Caching is keeping the answer to an expensive question so you don’t have to work it out again. The first time someone asks “what are the twelve courses on the home screen,” the proxy does the slow thing — asks Directus, waits for the joins, gets the fat JSON. Then it writes that answer down in fast memory (we used Redis, an in-memory key-value store) under a label like home-screen. The next person who asks the same question gets the written-down copy in a millisecond, and Directus never even hears about it.

The catch every cache has is staleness: the written-down answer can go out of date when the underlying content changes. So you decide how wrong you’re willing to be, and for how long. A five-minute expiry (a “TTL,” time-to-live) means a newly published talk might take up to five minutes to appear — completely fine for a devotional content library, unacceptable for a stock ticker. Choosing that number is the engineering.

A payment gateway

A payment gateway is the company that stands between your app and the banking system so you never have to touch a raw card number. You hand the gateway an order — amount, a reference id, a signature that proves it’s really you — and it takes over: it shows the user the card or UPI page, talks to the banks, does the fraud and 3-D Secure dance, and then tells you back, with its own signature, whether the money moved. We used CCAvenue, the gateway most Indian banks and cards route through cleanly. The reason you want this middleman is liability: the second you store card numbers yourself, you inherit a mountain of PCI compliance and breach risk. Letting the gateway hold the card and merely telling you the result keeps that whole category of danger out of your codebase.

The fork: rebuild now, or hack a runway

The conventional move — the one a design review would nod along to — was to read “slow responses and no payments” as the signal to start the real thing. Stand up Django, model the domain properly, build a real payments module, migrate off Directus. That’s Chapter 4, and it was coming. But doing it here would have been answering a question we hadn’t earned yet.

We knew people consumed. We did not yet know they’d pay. A months-long rebuild is a colossal bet to place on an unvalidated hypothesis; if it turned out nobody would open their wallet, we’d have spent our best quarter building industrial-grade infrastructure for a business that didn’t exist. The rebuild is the right answer to “how do we scale a thing people pay for” — and we were still on “will they pay at all.”

I considered a middle option too: just tune Directus. Add database indexes, trim the relational expansion, reach for its own caching knobs. That would have helped the speed wall — a bit — but it did nothing for the payments wall, and it meant pouring real optimization effort into a backend we’d already decided to delete. Polishing the throwaway.

The call I made was the third one: leave Directus exactly as it is, and put a thin FastAPI proxy in front of it that does two jobs and nothing else — cache the slow read responses, and own the payment flow. FastAPI because it’s async (well suited to sitting between a client and an upstream, mostly waiting on I/O), quick to write, and something I could stand up in days, not months.

The client now talks only to the proxy. For reads, the proxy checks Redis first and only falls through to Directus on a miss. For money, the proxy owns a /checkout route that Directus never knew existed.

Why async, and why that mattered here

FastAPI runs on ASGI — the async successor to the old WSGI request model Django and Flask grew up on. The distinction is not cosmetic for a proxy, so it’s worth thirty seconds.

A classic synchronous server handles a request on a worker thread, and that thread is pinned for the whole request. When the handler calls out to Directus and waits 300ms for the joins to come back, the thread sits there doing nothing but occupying memory — it can’t pick up another request until the first one finishes. To serve 200 users waiting on slow upstream calls at once, you need ~200 threads, and threads are expensive (memory, context-switch overhead, the GIL).

A proxy is the pathological case for that model, because a proxy is almost entirely I/O wait. Its own CPU work is trivial — copy some bytes, look at a header. The real time goes into waiting on Redis and waiting on Directus. Async turns that wait into an opportunity: when a handler hits await client.get(...), the event loop parks that coroutine and runs someone else’s request on the same thread until the upstream bytes arrive. One process, one thread, thousands of in-flight requests, all overlapping their wait. For an I/O-bound reverse proxy that is exactly the right shape, and it’s the reason a single cheap box could front the whole read path.

Two rules come attached to that gift. Never call a blocking function inside an async handler — one time.sleep or a synchronous DB driver stalls the entire event loop, not just one request. And reuse one connection pool for the lifetime of the app instead of opening a socket per request; a fresh TCP + TLS handshake to Directus on every call would erase the win.

How the caching half worked

The read path is a textbook cache-aside pattern: look in Redis first, fall through to the origin on a miss, write what you got back into Redis with an expiry, return it. The whole thing hangs off one shared httpx.AsyncClient — created once at startup, holding a keep-alive connection pool to Directus, so we amortise the TLS handshake across every request instead of paying it each time.

import hashlib
import httpx
import redis.asyncio as aioredis
from fastapi import FastAPI, Request, Response, HTTPException

app = FastAPI()

# One client, one pool, for the process lifetime. Keep-alive to Directus
# means we don't re-handshake TLS on every upstream call.
@app.on_event("startup")
async def _startup() -> None:
    app.state.http = httpx.AsyncClient(
        base_url=settings.DIRECTUS_URL,
        timeout=httpx.Timeout(5.0, connect=2.0),
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
        headers={"Authorization": f"Bearer {settings.DIRECTUS_STATIC_TOKEN}"},
    )
    app.state.redis = aioredis.from_url(settings.REDIS_URL)

@app.on_event("shutdown")
async def _shutdown() -> None:
    await app.state.http.aclose()
    await app.state.redis.aclose()


def cache_key(collection: str, query: str) -> str:
    # Namespaced, versioned, and query-aware. The v1: prefix lets us bump the
    # scheme and orphan the whole old keyspace in one line during a deploy.
    # Query strings can be long and unordered, so we hash them into the key.
    digest = hashlib.sha1(query.encode()).hexdigest()[:16]
    return f"cache:v1:items:{collection}:{digest}"


@app.get("/items/{collection}")
async def read_items(collection: str, request: Request) -> Response:
    redis = request.app.state.redis
    http = request.app.state.http
    key = cache_key(collection, request.url.query)

    cached = await redis.get(key)
    if cached is not None:
        return Response(cached, media_type="application/json",
                        headers={"X-Cache": "HIT"})  # ~1ms, Directus untouched

    try:
        upstream = await http.get(f"/items/{collection}",
                                  params=request.query_params)
        upstream.raise_for_status()
    except httpx.HTTPError as exc:
        raise HTTPException(status_code=502, detail="upstream unavailable") from exc

    # setex = SET + EXPIRE atomically. 300s is the staleness budget: expiry is
    # the safety net, the webhook below is the real invalidation path.
    await redis.setex(key, 300, upstream.content)
    return Response(upstream.content, media_type="application/json",
                    headers={"X-Cache": "MISS"})

Three details did the real work. First, the cache key is namespaced and versionedcache:v1:items:{collection}:{hash}. The v1: prefix is a cheap escape hatch: the day the response shape changes, we bump it to v2: and the entire old keyspace is orphaned at once, no manual flush. Second, the key hashes the query string, so ?filter[language][_eq]=hi and ?filter[language][_eq]=en are cached separately instead of stepping on each other. Third, staleness. A blunt five-minute TTL was almost right, but the content team publishing a talk and not seeing it for five minutes felt broken to them.

So expiry became the safety net, not the mechanism. Directus fires a webhook on every write, and the proxy turns that into a targeted invalidation. Because the key includes the query hash, we can’t reconstruct the exact keys for a collection — so we key invalidation off a per-collection Redis Set that records the live keys, and drop them all on any write to that collection.

@app.post("/webhooks/directus")
async def invalidate(request: Request) -> dict:
    redis = request.app.state.redis
    # Directus flow posts {collection, keys, event} on items.create/update/delete.
    payload = await request.json()
    if payload.get("token") != settings.DIRECTUS_WEBHOOK_TOKEN:
        raise HTTPException(status_code=403)

    collection = payload["collection"]
    index = f"cache:v1:index:{collection}"     # Set of live keys for this collection
    keys = await redis.smembers(index)
    if keys:
        await redis.delete(*keys, index)       # nuke the cached responses + the index
    return {"invalidated": len(keys)}

The read_items miss branch also does redis.sadd(index, key) when it writes, so the Set stays current — omitted above to keep the read path readable. Expiry as the backstop, explicit invalidation for the case that actually mattered: a freshly published Hindi talk shows up on the next request, not five minutes later.

What the cache actually bought

Numbers, because “it felt faster” is not an engineering claim. Before the proxy, the home-screen feed — twelve courses, each expanded to its cover, lesson count, and language tag — was a dozen joins Directus recomputed on every single call:

$ ab -n 500 -c 20 https://api.example.org/home-feed   # direct to Directus
Requests per second:    30.0 [#/sec] (mean)

Percentage of the requests served within a certain time (ms)
  50%    612
  95%   1180
  99%   1734

After, with a warm cache and a ~92% hit ratio (the feed is identical for most users, so nearly every request is served from Redis):

$ ab -n 500 -c 20 https://api.example.org/home-feed   # through the proxy
Requests per second:   2140.0 [#/sec] (mean)

Percentage of the requests served within a certain time (ms)
  50%      6
  95%     14
  99%     38

The p95 fell from 1,180ms to 14ms, and — the part that mattered more for a box we were paying for — Directus went from handling every request to handling roughly one in twelve. The origin load didn’t drop by a percentage; it dropped by an order of magnitude, because the vast majority of identical requests never reached it.

How the payments half worked

This is where the proxy stopped being a cache and became a small piece of real product. CCAvenue’s flow is a signed round-trip, and drawing it out is the fastest way to see what a gateway actually does for you.

CCAvenue’s integration is a redirect handshake, not an API you POST JSON to. It works in three moves. The proxy builds an order — merchant id, amount, a unique reference id, and the URLs CCAvenue should bounce the user back to — serialises it as an &-joined string, encrypts that whole blob, and renders a tiny auto-submitting HTML form whose single hidden field is the ciphertext. The user’s browser posts that form straight to CCAvenue. CCAvenue decrypts it, shows its own hosted card and UPI page — so the PAN and CVV land on the gateway’s servers, never ours — runs the bank and 3-D Secure dance, then POSTs an encrypted response back to our redirect_url. The proxy decrypts that, reads order_status, and only on Success writes the entitlement.

from fastapi.responses import HTMLResponse, RedirectResponse


@app.get("/checkout/{course_id}")
async def start_checkout(course_id: int, request: Request) -> HTMLResponse:
    order = await create_pending_order(request.state.user, course_id)  # our DB row first

    fields = "&".join([
        f"merchant_id={settings.CCAVENUE_MERCHANT_ID}",
        f"order_id={order.reference}",           # our idempotency handle on the callback
        f"amount={order.amount:.2f}",
        "currency=INR",
        f"redirect_url={settings.BASE_URL}/checkout/callback",
        f"cancel_url={settings.BASE_URL}/checkout/callback",
        "language=EN",
    ])

    # AES-128-CBC, key derived from the working key. The crypto is deliberately
    # NOT inline here — I later pulled it out into its own library. See Chapter 5.
    encrypted = ccavenue_encrypt(fields, settings.CCAVENUE_WORKING_KEY)

    # Auto-submitting form: the browser POSTs the ciphertext straight to CCAvenue.
    return HTMLResponse(f"""
      <body onload="document.forms[0].submit()">
        <form method="post" action="{settings.CCAVENUE_TRANSACTION_URL}">
          <input type="hidden" name="encRequest" value="{encrypted}"/>
          <input type="hidden" name="access_code" value="{settings.CCAVENUE_ACCESS_CODE}"/>
        </form>
      </body>""")


@app.post("/checkout/callback")
async def checkout_callback(request: Request) -> RedirectResponse:
    form = await request.form()
    decrypted = ccavenue_decrypt(form["encResp"], settings.CCAVENUE_WORKING_KEY)
    result = dict(pair.split("=", 1) for pair in decrypted.split("&"))

    # The callback is the ONLY source of truth for "paid" — never the front end.
    # order_id is our reference, so a replayed callback lands on the same row.
    if result.get("order_status") == "Success":
        await grant_entitlement(order_reference=result["order_id"])
        return RedirectResponse("/unlocked", status_code=303)
    return RedirectResponse("/payment-failed", status_code=303)

Two things are load-bearing and easy to get wrong. The callback is the only source of truth — the browser can lie, the server-to-server callback (verified by decrypting with a key only CCAvenue and we hold) cannot, so entitlements are granted there and nowhere else. And order_id is our reference id, which makes grant_entitlement idempotent: a retried or replayed callback resolves to the same pending order and can’t double-grant. The actual crypto — AES-128-CBC with an MD5-derived key — I’ve left as ccavenue_encrypt / ccavenue_decrypt on purpose. I later cleaned it up and open-sourced it as python-pay-ccavenue, and Chapter 5 pulls it apart byte by byte.

And here’s the quiet payoff of Chapter 1’s Directus choice: because Directus is just a lens over plain Postgres, “unlock this content for this user” was a row I could write into a purchases table directly. I didn’t need Directus to grow a payments feature. The proxy owned the money, Postgres owned the record of it, and the CMS stayed exactly the dumb, replaceable content store I’d always intended it to be. The escape hatch I picked before moving in was already paying off.

The outcome

The proxy took days to build and bought us months. On this stack — untouched Directus, a thin FastAPI cache-and-checkout in front — we went from ~2,000 users to roughly 6,000 in three months, and about 10% of them paid. That last number was the one that actually mattered. It wasn’t a projection or a survey; it was money that moved. For the first time we could say, with evidence rather than hope, that there was a real business here and not just an engaged audience. That is what earned the rebuild in Chapter 4 — we now knew what we were building, for whom, and that they’d pay for it.

The lesson

A scoped hack that buys you validated runway is not technical debt — it is good engineering. The instinct to “do it right” the moment you feel pain is often just the urge to build the impressive thing before you’ve earned the right to. We got real caching and real revenue in front of an unchanged CMS in a fraction of the time and risk a rebuild would have cost, and we learned the single most important fact about the business — people pay — before betting a quarter on it.

The danger isn’t building the hack. The danger is forgetting it’s a hack. A proxy like this is seductive; it’s a blank space where “just one more feature” always fits, and if you let business logic sprawl into it, the throwaway quietly becomes the load-bearing thing nobody dares delete. So we kept it deliberately dumb — it cached, it took payments, and it refused to grow a third responsibility. We wrote temporary on it, out loud, the same way we did with Directus. Naming a thing as scaffolding is what keeps you willing to tear it down.

There was exactly one problem the proxy could not paper over, and no amount of caching would ever touch it. A cache makes a repeated request cheap; it does nothing about a single request that is simply too big. Our course videos were around twenty minutes long and 1.2 GB in HD — one lesson could swallow a user’s entire daily data pack — and that is a wall you cannot proxy your way around. That’s Chapter 3.