A self-hosted team chat platform — channels, threads, DMs, reactions, files, and real-time presence — built with Rust and React. Run it on your own box, behind your own domain, with no per-seat pricing and no third party holding your messages. petar-basic.github.io/chat-systems shows what it looks like.
Think of it as a small, self-hostable Slack alternative for a team that wants to
own its data. One instance serves multiple workspaces; users are invite-only; and
the whole thing runs from a single docker compose command behind automatic HTTPS.
It's also a deliberately well-engineered reference codebase: a stateless Rust API, a horizontally-scalable WebSocket gateway, and a strictly-typed React SPA, with an integration-test suite and a real CI pipeline.
![]() |
![]() |
| Search reads the text inside PDF, Word, Excel and PowerPoint files | The same app on a phone, installable as a PWA |
Messaging
- Public and private channels, threads, DMs and group DMs (up to nine people)
- Reactions, pins, editing with history, forwarding, mark as unread
@person,@group,@channel/@here/@everyone, and custom emoji- Files and images attached to a message, with a gallery, local disk or S3 storage
- Link previews, with rich previews from GitHub, Jira, Figma, Google Drive and Confluence
- Scheduled send, and announcement channels where only moderators post
Notifications
- Per channel: everything, mentions only, or nothing; mute
- Keywords that notify you like a mention
- A notification schedule and do not disturb
- In-app, desktop and Web Push, with unread badges on the tab and app icon
Finding things
- Search across channels and DMs with filters, including file names and the text inside PDF, Word, Excel and PowerPoint files
- All unreads, Threads, Later (saved messages and reminders), keyboard shortcuts and a quick switcher
- Channel bookmarks, sidebar sections of your own, a status you set yourself, and away when you choose it or after ten idle minutes
Structured work
- Polls
- Lists: tables with typed columns kept in a conversation
- Workflows built without code: triggered by a schedule, a webhook, a reaction, someone joining or a shortcut; they post messages, ask with forms and add to lists
Calls
- Huddles: voice and video with screen sharing; past six people a huddle moves, mid-call, to an optional LiveKit media server
Integrations
- Incoming webhooks (Slack-compatible), signed outgoing webhooks, slash commands that may answer later
- Apps with a bot user and a scoped token that call the same API the client uses, and hear events over a signed HTTP request or a socket they open themselves (bot API)
- An app directory: everything connected, who added it, what it may do and whether it works, with consent to scopes before anything gets a token
- A Slack importer that brings your history with you
Admin and compliance
- Roles: Instance Admin, Workspace Owner and Admin, Channel Admin, Member, and Guests who see only the people they share a channel with
- Invite-only sign-up, SSO (OIDC), TOTP with recovery codes, SCIM deprovisioning
- Audit log, retention policies, workspace and per-person export, erasure
- App tokens and second factors sealed at rest under keys that rotate without anyone reconnecting
- Ed25519-signed sessions with rotating keys; the realtime gateway holds only public keys
Running it
- One
docker composecommand, automatic HTTPS, several workspaces per instance - A desktop app for macOS and Windows and an installable PWA; both connect to several instances
Most of the daily surface is here, from threads, huddles and Later to notification levels, keywords, search inside files, polls, lists and workflows (see Features). The Slack importer brings people, channels and messages, threads included.
What a team moving from Slack should expect to be missing or different:
| Missing / different | Where it stands |
|---|---|
| No native mobile app. The web app is responsive and installs as a PWA with push, but there is nothing in an app store. | Planned after the desktop app, not ticketed yet — see native-apps-plan.md |
| Large huddles need LiveKit. Without it a huddle is a peer-to-peer mesh capped at eight people. No recording. | LiveKit is an optional compose file (see the RUNBOOK); recording deliberately out of scope |
| Search reads text, not pictures. Messages, file names and the text inside PDF, Word, Excel, PowerPoint and text files are searched; a scanned page or a screenshot is not (no OCR). | Deliberate — OCR needs a model beside the stack |
| No shared channels across organisations. | Not planned |
The trade for all of that: every message, file and audit row stays on hardware you control,
and the whole thing is one docker compose command. See
docs/RUNBOOK.md for what running it
actually costs in machine and in time.
Three Rust binaries plus a React SPA. The API is stateless and the realtime gateway
fans messages out across nodes via Redis, so both scale horizontally. Background consumers
live in their own process rather than inside each API replica — otherwise a second API
replica would send every webhook and every notification twice. Durable events reach that
process through Redis Streams consumer groups, each event to exactly one replica, so
chat-worker scales too.
| Component | Technology |
|---|---|
| chat-api | Rust (Axum) — stateless REST API |
| chat-worker | Rust — background consumers (webhooks, reminders, notifications, scheduled messages, email, event outbox relay) |
| chat-realtime | Rust (Axum) — WebSocket gateway |
| Frontend | React 19, Vite, React Router, TailwindCSS, Zustand, TanStack Query |
| Edge (prod) | Caddy — automatic HTTPS + reverse proxy |
| Database | PostgreSQL 16 |
| Bus | Redis 7 — streams with consumer groups, live pub/sub, presence, rate limits |
| Storage | Local disk, or S3 (RustFS bundled) |
HTTP request → chat-api → PostgreSQL write + outbox row → Redis stream + publish
→ chat-realtime → WebSocket push (clients replay the stream on reconnect)
→ chat-worker consumer groups → notifications, webhooks, history
cp .env.example .env # then set JWT_SIGNING_KEYS, JWT_PUBLIC_KEYS, SECRETS_KEYS, POSTGRES_PASSWORD
docker compose --profile frontend up -d --build
docker compose logs api | grep "setup token"Open http://localhost:8080, paste the setup token, and create the first admin and
workspace. For demo users and channels, set ADMIN_PASSWORD in .env before the first start
instead and run ADMIN_PASSWORD=... ./seed.sh.
To run a published release instead of compiling, add to .env, with VERSION set to the
number of the latest release:
BACKEND_IMAGE=ghcr.io/petar-basic/chat-systems-backend
FRONTEND_IMAGE=ghcr.io/petar-basic/chat-systems-frontend
VERSION=X.Y.Zthen docker compose --profile frontend pull and up -d without --build. What each
release changed is in CHANGELOG.md.
Full setup — local development, production deployment with HTTPS and backups, and the contribution workflow — lives in docs/CONTRIBUTING.md.
The desktop app for macOS and Windows opens any Chat Systems server, and several at once: one rail of every workspace you are in, native notifications, a Dock or taskbar badge, the tray, and SSO through the system browser. Download it from the latest release; it updates itself from then on.
The web app is also an installable PWA — no installers, no unsigned-binary warnings, and it updates the moment you deploy:
- Chrome / Edge — open your instance, then click the install icon in the address bar (or menu → Install app). You get a standalone window, dock/taskbar icon, and an unread-count badge on the app icon.
- Safari (macOS) — File → Add to Dock.
- iOS / Android — Share → Add to Home Screen. The layout adapts to a phone, and an installed PWA is what receives Web Push on iOS.
- Contributing & running — dev setup, production deploy, coding standards, testing, CI
- Operations runbook — backups, restore, upgrade/rollback, alerts
- Backend architecture — design rationale + REST/WebSocket API reference
- Frontend architecture — design rationale + components, stores, and data flow
- Manual QA script — end-to-end test checklist
- Roadmap & known limitations — what's deliberately not done yet, and why
- Engineering history — what each shipped wave changed and why
Honest about the edges, since this is a reference codebase:
- Real-time delivery is at-least-once, not exactly-once. Every durable event is written through an outbox and a per-workspace Redis Stream, and a reconnecting client replays up to 1 000 events from its cursor; a longer gap, or one older than the stream keeps (10 000 entries, an hour), falls back to refetching open views, unread counts, pins and the sidebar. Consumers and clients are written to tolerate a duplicate.
- Huddles start as a WebRTC mesh, which is great up to ~6–8 participants. With LiveKit beside the stack a huddle moves to it when a seventh person joins; without it a huddle is capped at eight.
- One instance is one trust domain. SSO, TOTP and SCIM are per instance, not per workspace, and there is no cross-organisation sharing.
- Search reads text, not pictures. A scanned page or a screenshot is not searched (no OCR).
The full prioritized list lives in docs/ROADMAP.md.
MIT © 2026 Petar Basic
If you find this project useful and are feeling generous, consider donating to Svratište — a day center in Belgrade providing support, meals, and shelter for people experiencing homelessness.


