DIPSHAN
AboutProjectsExperienceBlog
Away, leave a message
Talk to me

Let's build something reliable.

Email me directly
DIPSHANChitwan, Nepal
AboutProjectsExperienceBlogStatusPrivacy
GitHubLinkedInEmail
All posts

How this site runs

Sep 22, 2026 · 5 min read

architecturedevopsrediswebsockets

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.

PartWhere
Frontend hostingVercel free tier
API, database, cache, TURNOne always-free VM
DNSCloudflare free
DomainFree .com.np
LLM for the assistantGemini 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.