2025 – present
The platform behind this site
An LLM gateway with real spend controls, an Avro-typed streaming pipeline, single sign-on, and a host reproducible from one playbook. This site is the visible tenth of it.
- FastAPI
- LiteLLM
- Kafka
- Avro
- Redis
- Celery
- Authentik
- Docker
- Ansible
- nginx
- Nuxt 4
- Provider-agnostic LLM gateway with a model allowlist, a server-side token ceiling and disconnect detection
- Binance ticks → Avro + Schema Registry → Kafka → a source-agnostic WebSocket bridge → Redis fan-out
- Self-hosted OIDC declared as a version-controlled blueprint, not clicked into a UI
- Whole host reproducible from an Ansible playbook
- Hosting migration took time-to-first-byte from ~0.9 s to ~0.14 s
A personal site needs none of this. That is rather the point: it is a place to build the things I would otherwise only read about, where the consequences of getting it wrong are my own.
What follows is honest about the failures, because those are the parts worth reading.
The LLM gateway
The backend exposes an OpenAI-compatible /v1/chat/completions endpoint
backed by LiteLLM. The interesting work is not the
proxying — it is everything around it.
Spend is a correctness problem. An endpoint that forwards whatever a caller sends to a paid provider is not an API, it is a billing incident waiting to happen. Three controls, all server-side:
- A model allowlist. The request carries a model name; anything outside the configured list is rejected before the provider is contacted. Without this, a caller picks the most expensive model available and you find out at the end of the month.
- A token ceiling applied to every request.
max_tokensis clamped to a server maximum, and omitting it does not mean unbounded — omitting it means the ceiling. - Disconnect detection on the streaming path. If the client goes away mid-stream, the server stops consuming. Otherwise you pay for tokens nobody will ever read.
The provider sits behind a seam. The client is a Protocol resolved
through dependency injection rather than a module-level singleton:
class ChatCompletions(Protocol):
async def create(self, **kwargs: Any) -> Any: ...
class ChatClient(Protocol):
@property
def chat(self) -> Chat: ...
That shape had two concrete payoffs. Swapping the OpenAI SDK for LiteLLM
touched exactly one factory function — no router changed, no service changed,
and the frontend never knew. And tests substitute a fake through
dependency_overrides, so the suite never reaches the network.
The seam also fixed a startup bug. The original client was built at import time and raised when the API key was missing, which crashed the whole application rather than failing one request. Validation now happens inside the factory.
The bug that had been live for a year
The endpoint returned HTTP 500 on every single request, and had since it was written.
The request body was annotated openai.types.chat.CompletionCreateParams.
That looks like a Pydantic model. It is a union of two TypedDict
subclasses — and Pydantic validates a TypedDict into a plain dict. So this
line:
params.stream = True # AttributeError: 'dict' object has no attribute 'stream'
…threw on every call, into a bare except that turned it into a 500. The
frontend defaulted to stream: true, a shape that had never once worked.
The fix was an explicit Pydantic model, which is also where the allowlist and the token ceiling now live. The lesson is narrower than "write tests": it is that a type annotation which looks like validation is more dangerous than no annotation at all, because it stops you looking.
The streaming pipeline
Binance publishes trade ticks over a WebSocket. The pipeline takes them to browsers:
Binance WSS
→ transform
→ Avro + Confluent Schema Registry
→ Kafka
→ consumer
→ Kafka-to-WebSocket bridge
→ Redis pub/sub
→ connected clients
Why Avro rather than JSON. A schema registry turns "the producer changed a field name" from a runtime mystery into a deploy-time failure. For a pipeline nobody is watching, that is the difference between noticing and not.
Why Redis between Kafka and the socket. The WebSocket manager fans out through Redis pub/sub rather than holding connections in process memory, so the API can run more than one worker. That part scales; the rest of the process state does not yet, which is an honest limitation rather than a feature.
The bridge is source-agnostic. It consumes a topic and publishes to a channel; nothing in it knows what Binance is. Pointing it at a different source is configuration.
Kafka plus Schema Registry is roughly 1–1.5 GB of JVM for a pipeline with no consumer in the UI yet, so it now sits behind a Docker Compose profile and is off by default. Keeping the code and not paying for the memory seemed better than deleting work that functions.
A failure worth keeping
An early version called signal.signal() inside a request handler to
install SIGINT and SIGTERM handlers for the producer. This hijacks the web
server's own shutdown handling, and raises outright if ever called off the
main thread. It is the kind of thing that works perfectly in development and
is deeply confusing in production.
Identity
Admin surfaces sit behind Authentik, self-hosted: server, worker, and its own Postgres and Redis, deliberately separate from the application's, so a schema migration during an upgrade cannot disturb the API.
The OAuth2 provider and application are a version-controlled blueprint, not something clicked into a web UI — so the configuration survives a rebuild and is reviewable in a diff.
Two things that cost an evening and are worth writing down:
The published schema was wrong. redirect_uris is now a list of
structured objects, not the newline-separated string that most documentation
and every blog post still shows. I only caught it by reading the example
blueprint shipped inside the running image.
A fresh install deadlocked. The worker owns database migrations, but
server and worker started together — so the server crash-looped on missing
tables while the worker was still migrating, leaving a half-built schema. A
system migration then checked only that django_migrations existed, not that
the table it queried existed, so every subsequent start failed the same way.
Permanently stuck at ten migrations and seven tables. The fix is ordering: the
server waits on the worker's health check. The recovery is wiping the volume,
because a partial schema cannot migrate forward.
Infrastructure
The host is reproducible from a single Ansible playbook: users, SSH hardening, firewall, fail2ban, Docker, nginx with TLS, systemd units, swap, and the interactive environment. Running it twice is a no-op.
That mattered immediately. Moving hosting meant the new machine was built by running the playbook rather than by remembering what had been done to the old one — and the measured result was time-to-first-byte from ~0.9 s to ~0.14 s, almost entirely from putting the server on the same continent as its readers.
The firewall that did nothing
The old server published its API port with no interface binding, so Docker
bound it to 0.0.0.0. The API was reachable directly, bypassing nginx and
every access control in front of it.
The obvious fix — a host firewall — does not work. Docker writes its own
DNAT rules into PREROUTING, which is traversed before the INPUT chain
where ufw lives. Enabling the firewall closed one port and left the other
wide open, while looking like it had secured both.
The real control is binding containers to 127.0.0.1 so nginx is the only
route in. The firewall is defence in depth, and it is worth knowing which of
your controls is load-bearing — because the comforting one here was not.
What I would do differently
- Build in CI, not on the box. Rebuilding images on a small server makes every deploy a multi-minute outage window. Build once, push to a registry, pull on the host.
- Write the test first when the failure is silent. The 500 persisted because nothing exercised the endpoint. Two tests would have caught it on day one.
- Treat memory as a design constraint from the start. The original host had 407 MB free and no swap, which quietly ruled out options long before anybody noticed.