IntelliStream Chat

Workplace chat you actually own

Everything your team expects from a chat app — channels and threads, direct and group messages, file sharing, reactions, mentions, polls, and search that actually finds things — running on a server you control. Your conversations live in your database, on your disk, under your law. No per-seat pricing, no history that vanishes because you are on the free plan, no vendor changing the terms next year.

A thousand people fit comfortably on the cheapest machine Hetzner rents: about €4 a month, less than one Slack seat. Why that matters →

Quick start

Five commands to a running workspace on your own machine.

1Clone

git clone https://github.com/IntelliStream-DataHub/intellistream-chat.git
cd intellistream-chat

2Install Java 25, Podman and jq

# Fedora / RHEL / AlmaLinux
sudo dnf install -y java-25-openjdk-devel podman podman-compose jq

# Ubuntu / Debian
sudo apt install -y openjdk-25-jdk podman podman-compose jq

Docker works too if you already have it, the compose file is plain OCI. JDK 25 is in the default archives on the RHEL family 9 and 10 (AlmaLinux, Rocky, RHEL), on Ubuntu 24.04 LTS and later, and on Debian 13. Only Debian 12 and Ubuntu releases older than 24.04 need SDKMAN or Adoptium. Gradle itself is not required, ./gradlew fetches it on first use.

3Start Postgres, Keycloak and the call relay

podman compose up -d

Postgres 18 and Keycloak 26, with the ichat-realm realm pre-imported and two test users: alice / alice and bob / bob. First boot takes 15 to 30 seconds while the realm imports. A coturn TURN relay comes up alongside them on loopback, which is what carries the audio and video for 1:1 calls.

4Export the dev secrets

export KEYCLOAK_CLIENT_SECRET=$(jq -r '.clients[] | select(.clientId=="ichat-client") | .secret' keycloak/realm.json)
export ICHAT_TURN_URLS=turn:127.0.0.1:3478?transport=udp
export ICHAT_TURN_SECRET=dev-turn-secret

Needed in every new shell you start the app from. KEYCLOAK_CLIENT_SECRET has no default, in dev as much as in production, so the app fails fast, printing that same jq line, rather than starting into a login that would break at the token exchange. The two ICHAT_TURN_* values match the relay from step 3 and have no defaults either — until both are set the call buttons are not rendered at all, because with relaying forced on there is no media path without a relay.

5Run it

./gradlew bootRun

Open http://localhost:8080 and sign in as alice. The Keycloak admin console is on http://localhost:8081 with admin / admin. To try a call, sign in as bob in a second browser, open a direct message and press the phone or camera button — it has to be localhost, because the camera and microphone APIs need a secure context and localhost is the only origin exempt from HTTPS.

When you're done

Ctrl-C                   # stops the app in the terminal running bootRun

podman compose down      # stop Postgres, Keycloak and coturn; chat history survives
podman compose down -v   # also wipe the Postgres volume, for a fresh database

Gradle reports the cancelled run as a failed build on Ctrl-C, which is expected. The Postgres volume is the only difference between the two down forms — Keycloak keeps no volume of its own, so the realm re-imports either way and the test users come back. Neither touches data/, where attachments, avatars and the Lucene search index live on the host rather than in a container, so remove that directory too for a genuinely clean slate.

It fits on a very small machine

Measured, not estimated: the whole application boots and serves inside a hard 900 MB, one core cgroup, peaking at 490 MB while posting, threading and searching. No second service to run, the search index is embedded and the message broker is in process.

That is enough for a workspace of around a thousand people. At the measured 82 KB per WebSocket connection, a thousand people connected at once is roughly 82 MB on top of the base footprint, and a workspace that size produces a handful of messages a second against a server that does thousands. Memory is what you size for, and a 1 GB VM has room.

Deploying to a server rather than trying it out? The manual setup covers PostgreSQL and Keycloak on the host, the installer script, the hardened systemd unit, SELinux and the reverse proxy: QUICKSTART-MANUAL.md. Containers all the way down instead: QUICKSTART-COMPOSE.md.

Your conversations, on a server you control.

What it looks like

Running on a laptop, with a conversation that got out of hand during the group stage.

Why this exists

Workplace chat is important infrastructure. We should stop handing the keys to a vendor whose interests do not include making sure you can still read your own conversations next year. For me, the ability to self-host isn't a feature, it's a right.

Where your data lives is a decision someone else can revisit

If your chat runs on a US cloud, two things are true regardless of which region you picked. The CLOUD Act lets US authorities compel an American provider to hand over data it controls, wherever in the world that data is stored. And a US provider has to follow US sanctions and export controls — which means access to your account is, in the end, a policy decision made in Washington rather than a contract term negotiated with you.

That is not hypothetical. In 2025 the International Criminal Court's chief prosecutor lost access to his Microsoft-hosted email after the US administration sanctioned him. An institution with every legal protection Europe can offer found that its communications ran through a company obliged to answer to another government first. The Trump administration has been explicit that tariffs, sanctions and market access are instruments to be used for leverage, and cloud services are not exempt from that logic. You do not need to have a view on any of it to notice the exposure: a supplier who can be ordered to switch you off is a single point of failure you do not control, and it sits underneath everything your company says to itself.

Self-hosting removes that dependency rather than insuring against it. The data is on a disk you rent or own, in a jurisdiction you chose, and the only people who can read it are the ones you gave accounts to. If a supplier relationship ends, you copy a Postgres dump and a directory of files and carry on. And it is genuinely cheap: this runs in under a gigabyte of memory on one core, which is roughly €4 a month at Hetzner for a workspace of a thousand people — a rounding error next to per-seat pricing, and the invoice does not grow when you hire.

Slack is mostly good. The UI is slow, and the product is proprietary, cloud-only, and your archive is governed by the vendor's pricing tiers and retention rules. The cost-per-seat and the visibility horizon are theirs to set. That's a workable trade for plenty of teams. It isn't workable for regulated industries, security-conscious orgs, or anyone who'd rather not have their internal knowledge graph held off-premises.

Mattermost sold itself as the open-source Slack alternative, and for a while it was. Then the free edition started taking things back. SAML and OAuth2 logins are paywalled now. Team message history is capped at 10,000 on the free plan. You can still self-host the binary. The open-core playbook is at work here: the things that separate a real chat app from a demo keep migrating into the licence you have to pay for. “Open source” stops meaning much when the table stakes aren't. Mattermost has a very slick and responsive UI, so it's a shame that the company is going open-core for a short-term win.

Microsoft Teams is the worst team-collaboration application I've used. Loved by enterprises that hate their employees. I'm always late to MS Teams meetings, because whenever I start it, it has to update and restart and yadda yadda yadda. The UI is so slow that it freezes for seconds.

For me it's important that a chat/team collaboration application is something I can deploy on a box I control. Fast UI. No message cap. No SSO paywall. No telemetry. No vendor able to change the terms a year from now because the funding round demanded it. It won't have Slack's polish or Mattermost's feature breadth. It will still be readable in five years, on a server you own, running code you can audit, under a licence that can't be retroactively narrowed.

Where it stands. 1.0, under active development. Tested and audited, 981 tests across 101 classes with the integration suite running against a real PostgreSQL, and the installer verified end to end on AlmaLinux 10.2 with SELinux enforcing. What it has not had is years of production exposure across many deployments, so read the code before trusting it with anything sensitive, follow the hardening checklist in SECURITY.md before exposing an instance, and keep backups. ZFS snapshots every 15 minutes plus a daily pg_dump is cheap insurance.

Use it as a starting point

If you want a team-chat / collaboration tool that doesn't quite match Slack or Mattermost, internal-only, compliance-locked, embedded inside another product, an unusual channel taxonomy, a domain-specific slash-command surface, IntelliStream Chat is small enough to fork and extend with Claude Code rather than build from scratch. The codebase was itself built this way; that's the workflow it was designed for.

1Fork and rename

The name lives in several distinct slugs, on purpose: Java package ai.intellistream.chat, config prefix ichat., environment variables ICHAT_*, Keycloak realm ichat-realm with client ichat-client and ichat--prefixed roles, Postgres role/database ichat_role / intellistream_chat, and Gradle artifact / systemd unit / /opt path intellistream-chat. Rename each deliberately, then regenerate V1__init.sql.

2Read (and own) AGENTS.md

Claude Code reads it on every invocation. It codifies the conventions that aren't obvious from the code, the two filter chains, read-vs-write access checks, server-side Markdown render, the strict CSP, embedded Lucene, Testcontainers + real Postgres. Keep it in sync as your fork diverges; Claude follows whatever's in there.

3Write a spec file

A markdown file in the repo, even rough, drives much better Claude Code sessions than chat-style prompts. Acceptance criteria help: “polls auto-close after 7 days; closed polls show the winner above the option list; admins can re-open a closed poll within 24 hours.”

4Run claude and ask for changes

Good prompts name files and reference existing patterns: “Add a slash command /announce modelled on PollCommand, with a Flyway migration for the new announcements table and an IT under integration/AnnounceFlowIT.java.” The codebase is small enough that whole-feature changes fit in a single Claude Code session.

5Keep the test suite green

./gradlew test runs in 1–2 minutes against Testcontainers Postgres. Add a unit test and an integration test for every feature, the existing tests are the floor, not the ceiling.

Pieces deliberately kept simple so a fork can swap them without a rewrite: the in-memory rate limiter (→ Bucket4j / Redis for multi-replica), embedded Lucene (→ Elasticsearch for >10M messages), in-memory STOMP broker (→ RabbitMQ STOMP plugin for multi-replica WebSocket), and local-disk attachments (→ S3 SDK behind AttachmentService).

Features

Everything you'd expect from a real team chat, channels, DMs, threads, calls, attachments, search, with no parts behind a paywall.

Channels & DMs

Public and private channels, renamed, archived and left as projects come and go. 1:1 and group direct messages with the same surface as a channel — threads, typing, read state and their own mute.

Real-time over STOMP

Messages, edits, deletes and avatar updates fan out live over native STOMP-over-WebSocket. No SockJS, no polling.

Threads, reactions, mentions

Threaded replies that mark a channel unread and reach the people in the thread. Reactions, an @-typeahead matching display names as well as handles, @channel and @here, pins, saved messages, forwarding, and permalinks that survive signing in.

Voice & video calls

1:1 calls from any direct message, ringing every device you have open. Media goes peer to peer through your own TURN relay — the chat server never sees a packet of it, and neither participant learns the other's IP address. Opus and VP9, and a line in the conversation when the call ends.

Markdown messages

CommonMark + GFM tables + autolinks, sanitized server-side with jsoup. Fenced-code syntax highlighting and YouTube embeds.

Full-text search

Embedded Apache Lucene index over every channel you may read — joined or not — every conversation you are in, and attachment filenames. from:@bob and in:#channel narrow it; results come with counts and paging.

Attachments & avatars

Uploads streamed straight to disk as raw request bodies, not multipart (no buffering), images open in a lightbox, profile pictures resized server-side and broadcast on change.

Notifications

In-tab toast plus opportunistic OS notification (Notification API) on @-mentions when permission is granted. Per-channel and per-conversation levels, and a Do Not Disturb that genuinely silences without hiding what you missed.

Keycloak OIDC

OAuth2/OIDC sign-in via Keycloak. Two filter chains: stateless bearer JWT for the API, stateful OIDC session for pages.

Self-hostable

One JVM, three external dependencies (Keycloak, Postgres, a data directory). Apache 2.0 licensed, fork it, run it, ship it.

What one machine does

Measured on a single 12 core, 31 GB box with the load generator running on the same machine, so these are floors rather than ceilings. Method, raw results and analysis are in scalability.md; the harness is in benchmark/.

17,066messages persisted and delivered per second, p50 21.6 ms end to end, 0 dropped
136,043deliveries per second fanning out into 50 member rooms, 0 dropped
100,000concurrent WebSocket connections served, 2,000,000 of 2,000,000 deliveries, 0 dropped
11.2 GiBtotal JVM resident memory holding those 100,000 connections

Two design decisions sit behind those numbers. The write path is batched and broadcast waits for the commit, so a message is shown to the room only after its row is durable, and nobody ever sees a line that then failed to persist. The sender does not wait for it, the composer draws optimistically and reconciles on the broadcast. Uploads are raw request bodies rather than multipart, because multipart's boundary scan caps throughput well below line rate.

There is no message broker to operate, no search cluster, no Redis, no sidecar and no npm build. That is the central design decision and everything else follows from it. Each piece is replaceable behind an existing seam when you outgrow it.

No broker. No search cluster. One process.

Stack

Boring, well-understood pieces. No npm bundler, no SPA framework, no Spring Cloud, no reactive layer.

Runtime

  • Java 25 , toolchain, virtual threads on
  • Spring Boot 4.1 , Spring Framework 7, Gradle Kotlin DSL
  • Tomcat 11 , embedded
  • PostgreSQL 18 , the only datastore
  • Keycloak 26 , OIDC issuer

Storage and search

  • Hibernate ORM 7.4 , ddl-auto=validate
  • Flyway 12 , migrations under db/migration/
  • Apache Lucene 10.5 , embedded index, no search cluster
  • HikariCP , 0.03 ms acquire at 13k messages/s

Domain libraries

  • CommonMark 0.29 , GFM tables and autolink
  • jsoup 1.22 , HTML sanitization
  • Apache Tika 3.3 , MIME sniffing on upload
  • Raw body uploads , no multipart, ~380 MB/s

Frontend

  • Thymeleaf , server side templates
  • Vanilla JS , ES modules, no npm dependency tree
  • Closure Compiler , build time bundling
  • StompJS, highlight.js , vendored

Tests

  • JUnit 5, AssertJ, Mockito
  • Testcontainers 2 , real Postgres, never H2
  • spring-security-test
  • 981 tests across 101 classes

Secrets: Vault and OpenBao

Optional. Off unless you turn it on, and the app runs perfectly well without it, reading credentials from the environment. Turn it on when you would rather the database password and the OIDC client secret did not sit in a file on the host.

At startup, before Spring builds any beans, the app fetches one KV v2 record and layers it over everything else, so what Vault says wins over the environment and over application.yml. It reads five keys, and ignores anything else in the record:

db.username              -> spring.datasource.username
db.password              -> spring.datasource.password
keycloak.client-id       -> OIDC client id
keycloak.client-secret   -> OIDC client secret
keycloak.issuer-uri      -> OIDC issuer

1Point the app at your Vault

ICHAT_VAULT_ENABLED=true
ICHAT_VAULT_URI=https://vault.example.com:8200
ICHAT_VAULT_TOKEN=<token>
ICHAT_VAULT_PATH=intellistream-chat     # optional, this is the default

ICHAT_VAULT_PATH is a KV v2 path. The default resolves to secret/data/intellistream-chat; pass something like mymount/myapp/secrets to use a different mount. If you enable this and leave the URI or token unset, startup fails immediately rather than quietly falling back to the environment, because a silent fallback is how a production instance ends up running on a development password.

2Try it locally

podman compose --profile openbao up -d
./scripts/seed-vault.sh

The OpenBao container is profile gated, so a plain podman compose up -d skips it. The seed script writes the five keys and is idempotent; KV v2 keeps a version history, so a bad seed is recoverable with bao kv rollback.

The dev container is not a deployment. It runs in memory with a single root token, so everything vanishes on restart and the token is in the compose file. For production use a sealed deployment with auto unseal, and authenticate with AppRole or Kubernetes auth rather than a long lived token. Token auth is what the app implements today; if you need AppRole, VaultEnvironmentPostProcessor is about sixty lines and is the only place that talks to Vault.

Why not Rust

A fair question for a self hosted server in 2026, and the answer is not that Rust is worse.

Maintenance is the dominant lifetime cost, not CPU. This is a chat server. It will spend years being modified by whoever is around, and the pool of people who can safely change a Spring Boot codebase is very much larger than the pool who can safely change an async Rust one. That gap is the biggest number in the total cost of ownership, and it appears in no benchmark.

The performance argument does not apply here. One machine already does 17,066 messages a second end to end and holds 100,000 concurrent connections in 11.2 GiB, and a small deployment runs in under 500 MB. The limits that matter in a chat server are the database, the search index and the network, not the language runtime. A faster language would move a number that is not the constraint.

The libraries are the product. Spring Security's OIDC support, Hibernate and Flyway, and embedded Lucene are decades of accumulated correctness in exactly the areas where a bug is a security incident or a data loss event. Rust has credible equivalents for some of this and thinner coverage for the rest, particularly enterprise SSO. Writing those parts yourself is not a saving.

Where Rust would genuinely win: memory per connection, cold start, and no GC pauses at the tail. If you were building an edge relay holding a million sockets and doing almost nothing with each one, that is the right trade and Java is the wrong one. This is a different program, where every message is persisted, rendered, sanitised, indexed and fanned out.

Rust would make the cheap part cheaper and the expensive part more expensive.