How this site runs
Sep 22, 2026 · 5 min read
This portfolio is a small production system: live chat, voice and video calls, an AI assistant and an admin panel behind it. This post covers how it is put together, why, and what went wrong once. The live health of the API is on the status page.
Architecture
Browser
|-- HTTPS ------------------> Vercel (Next.js)
| '-- fetches page content ---.
|-- HTTPS + WebSocket ------> Caddy (TLS) --> Django + Daphne <-'
| |-- Postgres + pgvector
| |-- Redis
| '-- Gemini API
'-- WebRTC audio and video -> the other browser
(coturn relays only if a direct path fails)
- Frontend: Next.js 16 on Vercel. Server components fetch content, client components handle chat and calls.
- API: Django REST framework for requests, Django Channels for WebSockets, served by Daphne behind Caddy, which handles TLS.
- Data: Postgres with the pgvector extension, and Redis.
- Calls: WebRTC between the two browsers. coturn relays only when a direct connection is impossible.
- Where it runs: one small VM with docker compose, plus Vercel for the frontend.
Three flows
Chat
A message is saved with a POST, then fanned out through Redis groups to the visitor's thread and to my admin inbox over WebSockets. Typing and read receipts ride the same sockets. If the socket drops, the widget falls back to polling and reconnects with backoff. Presence is a Redis entry that each open admin tab refreshes every 20 seconds, so closing one tab does not mark me offline while another is open.
Calls
The visitor asks to call. If I am online my browser rings, I accept, and the two browsers exchange session descriptions through the backend. After that the media is direct. Turning the camera on during an audio call renegotiates the same connection. If either side loses the connection for a few seconds, the call ends cleanly on both.
Ask Dipshan
My profile content is chunked and embedded into pgvector. A question is embedded, the closest chunks are retrieved, and the model answers only from them. Each visitor is rate limited, there is a site-wide daily budget, and if the first model is overloaded the request falls through a list of others.
Decisions and tradeoffs
Redis runs on the same box, not on a hosted free tier
Django Channels keeps every open WebSocket blocked on Redis. I measured about 55 commands a minute per open socket. One admin tab left open all day is roughly 2.3 million commands a month, far past a free 500k tier and then billed per command. A local Redis uses about 20 MB and the commands cost nothing.
One Postgres with pgvector instead of a separate vector database
The assistant searches a few hundred chunks of my own writing. That is a table with an embedding column, not a service to run. Fewer moving parts, one backup, one connection string.
Media goes peer to peer, the server only introduces the two sides
The backend relays offer, answer and ICE candidates and never touches audio or video. A TURN relay is the fallback for strict networks, with credentials that expire after an hour and quotas on what each caller can relay.
Anonymous visitors get a signed cookie, not an account
A visitor id in a signed HttpOnly cookie ties chat, calls and rate limits together. No sign-up wall between someone and a conversation. The WebSocket handshake reads the same cookie, so the API lives on a subdomain of the site, and the browser calls it directly so the API sees each visitor's real IP for rate limiting.
One small VM with docker compose, not Kubernetes
Kubernetes earns its keep at real scale. For a single node and a handful of daily visitors it would be cost and complexity with no benefit. The compose file is short, and moving to k3s later is a packaging change, not a rewrite.
What it costs
About zero a month, on purpose.
| Part | Where |
|---|---|
| Frontend hosting | Vercel free tier |
| API, database, cache, TURN | One always-free VM |
| DNS | Cloudflare free |
| Domain | Free .com.np |
| LLM for the assistant | Gemini free tier, with a model fallback chain |
An incident
Symptom. While testing, the admin showed Online but visitors saw Away, and typing and seen indicators never worked. The admin socket was reconnecting about every six seconds.
Finding it. The server log showed a stream of Timeout reading from redis on the WebSocket consumer, each one closing the socket. Redis itself was healthy.
Root cause. The channel layer blocks on Redis for five seconds waiting for a message. The Redis client's own read timeout was shorter, so it gave up first. Every idle socket died and reconnected, and presence and receipts were lost in between.
Fix. Set the client socket timeout to 30 seconds. One line in the channel layer config:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [{"address": REDIS_URL, "socket_timeout": 30}]},
}
}
Then a second bug. Even with that fixed, typing still failed in one setup. A build had baked in the wrong WebSocket host, so the browser sent its cookie to one domain and the socket connected to another. The server rejected it and the chat silently fell back to polling, which carries messages but not typing. The socket host is now chosen at runtime, and the client retries the socket instead of giving up after one failure. I confirmed the fix with a real browser against a scripted admin.
Lesson. A graceful fallback that hides a failure is also a way to ship a bug for weeks. So the widget now reports each fallback, the status endpoint counts them, and a monitor can alert on that count instead of only on hard failures.
What I would change at scale
- Move WebSocket fan-out to its own service and scale it horizontally behind a sticky load balancer.
- Add real uptime checks from outside the box, and alert on the fallback path as well as on hard failures.
- Package the stack for k3s with Terraform, and add CI that builds, tests and deploys on merge.