- ⚡ Instant delivery — LiveView + PubSub push messages in under 100ms
- 👥 Live presence & typing — Who's online, who's typing, updated in real time
- 🧠 Per-room OTP — Each chat room is an isolated, supervised GenServer. Crash-safe, idle-shutdown after 5m
- 🔑 Password auth — bcrypt-hashed passwords, register + login, session renewal
- 🚪 Room membership — open-join with membership checks, max_members, archived-room lockout
- 🔐 REST API — OpenAPI 3.0 spec, Swagger UI (dev), Bearer-token auth, scope-enforced endpoints
- 🛡 Rate limiting — Per-user, per-API-key, per-IP buckets (Hammer + ETS)
- 📊 Full observability — Token-protected Prometheus
/metrics, Sentry, structured JSON logs, admin LiveDashboard - 🐳 Deploy anywhere — Multi-stage Dockerfile + prod
docker-composewith Caddy (auto-TLS) - 🚑 Health probes —
/health(liveness) and/ready(DB + supervisor) for Kubernetes or compose - 🧪 Tested — Contexts, GenServers, plugs, REST, sessions, and LiveView authorization
┌─────────────┐ ┌───────────────────────────────────┐ ┌──────────┐
│ Browser │◄─WS─┤ Phoenix Endpoint (Bandit) │──►──┤ Postgres │
└─────────────┘ │ │ └──────────┘
│ Router → Pipelines │
REST (Bearer) ────►│ ├─ :browser (CSP, CSRF, HSTS) │ ┌──────────┐
│ ├─ :api_auth (read scope) │─────┤ Caddy │
│ └─ :api_auth_write (write) │ │ (TLS) │
│ │ └──────────┘
│ RoomSupervisor (DynamicSupv.) │
│ ├─ RoomServer room:a (GenSrv) │
│ ├─ RoomServer room:b (GenSrv) │
│ └─ ... │
│ │
│ TaskSupervisor PubSub │
│ Presence Telemetry │
└───────────────────────────────────┘
Each room spawns a named GenServer on first use, persists every message synchronously (no in-memory buffer, no data loss on crash), broadcasts via Phoenix.PubSub, and idle-shuts-down after 5 minutes of silence. Rooms are open-join: the first visit creates a membership; max_members and archived rooms are enforced.
git clone https://github.com/pradhankukiran/blitz-chat.git
cd blitz-chat
# Start Postgres
docker compose up -d db
# Install deps, create DB, run migrations, seed
mix setup
# Start the server (IEx for hot reload)
iex -S mix phx.serverOpen http://localhost:4000. Seeded local demo accounts (never use in production):
| Username | Password | Role |
|---|---|---|
alice |
password123 |
admin |
bob |
password123 |
user |
charlie |
password123 |
user |
Register a new account at /register. Swagger UI at http://localhost:4000/swaggerui.
cp .env.example .env
# Required: POSTGRES_PASSWORD, SECRET_KEY_BASE (mix phx.gen.secret),
# PHX_HOST, METRICS_TOKEN (openssl rand -hex 32)
docker compose -f docker-compose.prod.yml up -d --build
# Always run migrations after deploy / image upgrade
docker compose -f docker-compose.prod.yml exec app /app/bin/migrateCaddy issues Let's Encrypt certificates automatically on first boot (~30s).
-
Migrations: the app container does not auto-migrate. Run
/app/bin/migrateafter every deploy. -
Resource limits: prod compose sets memory/CPU caps on
app,db, andcaddyas a baseline; tune for your host. -
Database TLS: set
DATABASE_SSL=true(default in runtime) and optionallyDATABASE_SSL_CA=/path/to/ca.pemfor peer verification. Compose on a private network can useDATABASE_SSL=false. -
Backups: Postgres data lives in the
db_datavolume. Example snapshot:docker compose -f docker-compose.prod.yml exec db \ pg_dump -U blitz_chat blitz_chat_prod > backup-$(date +%F).sql
-
Metrics scrape: Prometheus must send
Authorization: Bearer $METRICS_TOKENor headerx-metrics-token: $METRICS_TOKEN. -
Single-node: room GenServers and ETS rate limits are process-local. Run one app instance (or sticky sessions + distributed process strategy) until you add multi-node room placement.
All endpoints versioned under /api/v1 and require Authorization: Bearer <api-key>.
Message list/create and room stats require the API key owner to be able to access the room (open-join membership).
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /rooms |
:read |
List rooms (paginated, max 100) |
| GET | /rooms/:id |
:read |
Show a room |
| POST | /rooms |
:write |
Create a room (creator auto-joins) |
| GET | /rooms/:room_id/messages |
:read |
List messages in a room |
| POST | /rooms/:room_id/messages |
:write |
Send a message (author = key owner) |
| GET | /rooms/:room_id/stats |
:read |
Live per-process stats |
Errors always use the same envelope:
{ "error": { "code": "validation_failed", "message": "Invalid input", "details": { "body": ["can't be blank"] } } }lib/
├─ blitz_chat/
│ ├─ accounts/ User schema + password auth context
│ ├─ api_keys/ API key schema + context (scopes, expiry, usage)
│ ├─ chat/ Room, Message, Membership schemas
│ │ ├─ room_server.ex Per-room GenServer (persist-then-broadcast)
│ │ └─ room_supervisor DynamicSupervisor with race-safe start
│ └─ release.ex Release migration tasks (bin/migrate)
└─ blitz_chat_web/
├─ controllers/
│ ├─ api/ REST (versioned /api/v1, FallbackController)
│ ├─ health_controller /health + /ready
│ └─ metrics_controller /metrics (Prometheus)
├─ live/ LobbyLive · RoomLive · AdminDashboardLive
├─ plugs/ ApiKeyAuth · MetricsAuth · RateLimit · SecurityHeaders · SetCurrentUser
└─ router.ex
config/ config · dev · prod · runtime · test
priv/repo/migrations/ migrations (schemas, FKs, indexes, passwords, api keys)
rel/overlays/bin/ server · migrate (mix release)
test/ ExUnit suite
Dockerfile docker-compose.prod.yml Caddyfile .env.example
mix test # full suite
mix precommit # compile --warnings-as-errors + deps.unlock --unused + format + testCoverage includes password auth, membership / capacity / archived rules, GenServer concurrency (50-way race test, concurrent sends, idle shutdown), plugs (rate limit, API key auth, metrics token, scope enforcement), REST controllers, and LiveView authorization gates.
With the server running and a valid API key:
# Rough single-room write throughput check (adjust URL/key/room).
# Expect serial RoomServer + DB latency to bound results on one node.
for i in $(seq 1 50); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"body\":\"load $i\"}" \
"http://localhost:4000/api/v1/rooms/$ROOM_ID/messages" &
done
waitThis is a smoke check, not a formal benchmark. Message sends are serialized per room process.
| Layer | Hardening |
|---|---|
| Browser auth | bcrypt passwords, register + login, session renewal on sign-in/out |
| Session cookie | Signed cookie; Secure flag in production |
| Headers | CSP, HSTS, Permissions-Policy, Referrer-Policy on every HTML response |
| CSRF | Enforced on all mutating browser routes |
| Transport | WebSocket-only (longpoll disabled); check_origin pinned to PHX_HOST |
| Rooms | Membership required to send; open-join; max_members; archived blocked |
| API auth | Bearer tokens with :read / :write / :admin scopes |
| API keys | SHA-256 hashed, prefix-searchable, expiry + revocation + usage tracking |
| Rate limits | Login/register (10/min/IP), API writes (10–60/min/key), LiveView events |
| Mass-assignment | FK fields never cast from user input |
| Admin access | /admin and LiveDashboard role-gated |
| Metrics | /metrics requires METRICS_TOKEN in production |
| Request size | Plug.Parsers body cap 1 MB; message body max 5 KB |
| Secrets | Fail-fast on missing SECRET_KEY_BASE, PHX_HOST, DATABASE_URL, METRICS_TOKEN |
| Endpoint | Purpose |
|---|---|
/health |
Liveness (200 if process alive) |
/ready |
Readiness (DB SELECT 1 + RoomSupervisor alive, 503 if not) |
/metrics |
Prometheus scrape (token-protected in prod); no high-cardinality room_id labels |
/admin/metrics |
Phoenix LiveDashboard (admin only) |
Errors forward to Sentry when SENTRY_DSN is set at runtime. Logs are structured JSON (LoggerJSON.Formatters.Basic) in prod, plain text in dev.
- Message edit / delete
- File and image uploads
- Markdown rendering with sanitization
- Email / OAuth authentication
- Redis backend for rate limiting (horizontal scale)
- Multi-node room process distribution
- End-to-end encryption
- GitHub Actions CI pipeline
MIT © Kiran Pradhan