A self-hosted real-time chat platform with text, voice, and screen sharing -- built with Svelte 5 and Rust.
| Component | Technology |
|---|---|
| Frontend | Svelte 5, TypeScript, Vite |
| Backend | Rust, Axum 0.8, Tokio |
| Database | PostgreSQL, sqlx |
| Voice | WebRTC, SFU architecture |
| Storage | S3-compatible, local filesystem |
| Auth | JWT, Argon2, WebAuthn/Passkeys |
| Desktop | Tauri |
docker compose up -d
cd backend
cargo run
The backend starts on http://127.0.0.1:3000. Database migrations run automatically on startup, and default channels are seeded if the database is empty.
cd frontend
npm install
npm run dev
The frontend dev server starts on http://localhost:1420.
cd frontend
npm run tauri:dev
Known issue (Arch Linux): The
webkit2gtkpackage on Arch Linux is built withoutENABLE_WEB_RTC, so voice channels will not work in the Tauri desktop app. This is an upstream packaging decision. The web browser version is unaffected.
Backend environment variables (set in backend/.env):
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgres://echora:echora@localhost:5432/echora |
JWT_SECRET |
Secret key for JWT signing | (required) |
BIND_ADDR |
Server bind address | 127.0.0.1:3000 |
CORS_ORIGINS |
Comma-separated allowed origins | Permissive (all origins) |
RUST_LOG |
Log level filter | info |
Passkey support is enabled automatically. Users register with a password, then optionally add passkeys from the settings UI for passwordless login. Configure the relying party settings for your domain:
| Variable | Description | Default |
|---|---|---|
WEBAUTHN_RP_ID |
Relying party ID (your domain) | localhost |
WEBAUTHN_RP_ORIGIN |
Relying party origin (full URL) | http://localhost:1420 |
For production, set these to your actual domain:
WEBAUTHN_RP_ID=example.com
WEBAUTHN_RP_ORIGIN=https://example.com
The RP ID must match the domain users access the site from, and the RP origin must match the full URL (including scheme). Passkeys registered under one RP ID will not work under a different one.
File uploads are disabled by default. Set STORAGE_BACKEND to enable.
| Variable | Description | Default |
|---|---|---|
STORAGE_BACKEND |
Storage backend: s3 or local |
(unset = disabled) |
S3_BUCKET |
S3 bucket name | (required when s3) |
S3_REGION |
S3 region | (required when s3) |
S3_ENDPOINT |
Custom S3 endpoint (for DigitalOcean Spaces, MinIO, R2, etc.) | (unset = AWS S3) |
AWS_ACCESS_KEY_ID |
S3 access key (not needed on ECS/EC2 with IAM roles) | (from env/IAM) |
AWS_SECRET_ACCESS_KEY |
S3 secret key | (from env/IAM) |
STORAGE_PATH |
Local filesystem path for uploads | ./uploads |
S3-compatible providers (DigitalOcean Spaces, MinIO, Backblaze B2, Cloudflare R2, Wasabi, etc.) work by setting S3_ENDPOINT:
# DigitalOcean Spaces example
STORAGE_BACKEND=s3
S3_BUCKET=my-space
S3_REGION=nyc3
S3_ENDPOINT=https://nyc3.digitaloceanspaces.com
AWS_ACCESS_KEY_ID=your-spaces-key
AWS_SECRET_ACCESS_KEY=your-spaces-secret
Frontend environment (set in frontend/.env / .env.production):
| Variable | Description | Default |
|---|---|---|
VITE_API_BASE |
Backend API URL | /api |
VITE_WS_BASE |
WebSocket base URL | Auto-detected from page URL |
VITE_STUN_SERVERS |
Comma-separated STUN server URLs | stun:stun.l.google.com:19302 |
Echora uses a Selective Forwarding Unit architecture for voice chat. The server forwards WebRTC signaling (offers, answers, ICE candidates) between clients without decoding or re-encoding audio. Clients capture audio via getUserMedia, establish peer connections through the server, and mix received streams locally. This avoids P2P complexity while keeping server CPU usage low.
Users register/login via REST endpoints. The backend returns a JWT token (7-day expiry) which the frontend stores in localStorage. Protected REST endpoints expect Authorization: Bearer <token>. WebSocket endpoints accept the token via query parameter (?token=...). Passwords are hashed with Argon2 using a random salt.
Passkeys (WebAuthn/FIDO2) are supported as an optional secondary auth method. Users register with a password first, then add passkeys from the key icon in the server header. Passkey login uses the same JWT flow -- after successful WebAuthn authentication, the server issues a JWT identical to password login. Both username-scoped and discoverable credential flows are supported. Challenge state is stored in-memory with automatic cleanup after 5 minutes.
PostgreSQL with sqlx. Migrations run automatically on startup from backend/migrations/. IDs use native UUID columns (UUID v7 for time-ordering). Voice state is managed in-memory with DashMaps and is not persisted.
The backend runs as a container behind a load balancer, the frontend is static files served via CDN.
| Component | Approach |
|---|---|
| Frontend | Static hosting + CDN |
| Backend | Containerized (Docker) + load balancer |
| Database | Managed PostgreSQL |
| Storage | S3-compatible bucket or local disk |
docker build -t echora .
# Tag and push to your container registry, then trigger a redeployment
cd frontend && npm run build
# Sync build/ to your static hosting provider and invalidate CDN cache