Self-hosted Discord-style chat. This repository is the install bundle and the issue tracker. It does not contain application source.
- Run it: Docker Compose in this repo pulls
dotnetdummy/richatand PostgreSQL. - Product site: RICHAT.net
- Issues: file bugs and requests here.
- Release notes:
CHANGELOG.md
The app runs as a PWA. It supports public and private channels, threads, quotes, emoji reactions, custom emojis, GIF search, pinned messages, link previews, and push notifications. Voice is a huddle on an existing text channel (screen share, webcams, soundboard, party mode, poker, pictionary, and word bomb).
- Docker with Compose
- A login provider: a Steam Web API key, email/password, a generic OAuth2 provider, or a trusted JWT reverse-proxy header (exactly one)
- VAPID keys (push notifications)
- HTTPS in production (PWA, push, and microphone)
git clone https://github.com/dotnetdummy/richat.git
cd richat
cp .env.example .envFill in .env:
-
Set
POSTGRES_PASSWORDto a strong password. -
Set
APP_URLto the public origin (https://chat.example.com). -
Set
INITIAL_ADMINto your email (email/password, OAuth2, or trusted JWT) or Steam persona name. The first matching sign-in becomes admin. -
Generate
AUTH_SECRETand paste it:openssl rand -base64 32
Keep the same secret. Rotating it invalidates every session. With email/password login it also breaks sign-in, because stored emails are HMAC'd with this value.
-
Generate Web Push VAPID keys and paste
VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY:npx --yes web-push generate-vapid-keys
Set
VAPID_SUBJECTto amailto:address orhttps:URL that identifies this app. Keep the same key pair; rotating it invalidates every device's push subscription. -
Enable exactly one login provider:
STEAM_API_KEY,AUTH_EMAIL_PASSWORD=true, OAuth2 (OAUTH2_CLIENT_ID+OAUTH2_CLIENT_SECRET+ a discovery URL or explicit endpoints), orAUTH_TRUSTED_JWT_HEADER. The app refuses to start with zero or more than one.
Then:
docker compose up -dOpen http://localhost:1987 (or your APP_URL) and sign in as INITIAL_ADMIN. After that, manage the whitelist from Admin (Shield icon next to the logo in the channel menu). The seed creates public channel general.
Chat data lives in the pgdata volume. GET /health returns JSON readiness (engine, database, listener) and answers 503 until the engine has started or while the database is unreachable. Pending migrations run when the app starts.
Override the image with RICHAT_IMAGE if you want a pinned tag (default dotnetdummy/richat:latest). Testers can follow the dev branch with dotnetdummy/richat:nightly.
Deploy this docker-compose.yml as a stack that pulls RICHAT_IMAGE. Do not add a build: key — Portainer has no app source tree, and compose build fails with mkdir /.docker: permission denied.
Terminate TLS in front of port 1987. Set APP_URL (and AUTH_TRUSTED_ORIGINS if the origin list is wider than APP_URL) to the public HTTPS URL. Production needs HTTPS for install-as-app, push, and huddle microphones. Trusted JWT login (AUTH_TRUSTED_JWT_HEADER) is for this proxy: it must authenticate the visitor and set the named header; Richat does not verify the JWT signature, so the app must not be reachable except through that proxy.
docker compose pull && docker compose up -dPending migrations run when the app starts. Pin RICHAT_IMAGE to a version if you do not want to follow latest. Use dotnetdummy/richat:nightly to follow the dev branch. See CHANGELOG.md for what changed in each version.
Optional. Needed on hard NATs. Coturn is a separate compose file in coturn/ (host networking, public IP). It is not in the app image or the root compose file. See docs/turn.md.
Put these in .env. See .env.example.
| Variable | Required | Purpose |
|---|---|---|
POSTGRES_PASSWORD |
Yes | Postgres password (compose also builds DATABASE_URL) |
AUTH_SECRET |
Yes | Session signing secret |
APP_URL |
Yes. | Public origin of the app |
AUTH_TRUSTED_ORIGINS |
No (defaults to APP_URL) |
Comma-separated allowed origins |
STEAM_API_KEY |
One login provider | Steam OpenID login |
AUTH_EMAIL_PASSWORD |
One login provider | true enables email/password login |
OAUTH2_CLIENT_ID / OAUTH2_CLIENT_SECRET |
One login provider | Generic OAuth2 / OIDC login |
OAUTH2_DISCOVERY_URL |
With OAuth2 | OIDC discovery URL (or set the explicit URLs) |
OAUTH2_AUTHORIZATION_URL / OAUTH2_TOKEN_URL |
With OAuth2 (no discovery URL) | Explicit OAuth2 endpoints |
OAUTH2_USERINFO_URL |
No | Explicit userinfo endpoint |
OAUTH2_SCOPES |
No (default openid,profile,email) |
Comma-separated scopes |
OAUTH2_PROVIDER_NAME |
No (default OAuth) |
Login button label |
AUTH_TRUSTED_JWT_HEADER |
One login provider | Reverse-proxy JWT login (HTTP header name) |
VAPID_PUBLIC_KEY |
Yes | Web Push |
VAPID_PRIVATE_KEY |
Yes | Web Push |
VAPID_SUBJECT |
Yes | Web Push subject (mailto: or https: URL) |
KLIPY_APP_KEY |
No | KLIPY GIF search. Without it, only uploaded GIFs. |
KLIPY_PRIVACY |
No (default off) | true keeps server-side KLIPY search and the CDN proxy. |
PORT |
No (default 1987) |
Host port mapped to the app |
INITIAL_ADMIN |
First setup | Email or Steam name of the first admin |
POSTGRES_USER / POSTGRES_DB |
No (default richat) |
Postgres role and database name |
DB_MIGRATE_ON_START |
No (default true) |
Run pending migrations when the app starts |
PG_POOL_MAX |
No (default 20) |
Database connection pool size |
PG_STATEMENT_TIMEOUT_MS |
No (default 30000) |
Per-query timeout in ms (0 disables) |
LOG_FORMAT |
No (json in production) |
json or pretty |
RICHAT_IMAGE |
No | App image (latest, a version, or nightly) |
EMAIL_SALT_ROUNDS |
No (default 10) |
bcrypt rounds for hashed emails |
MESSAGE_RETENTION_DAYS |
No (default 90) |
Last-edit age after which unpinned messages are deleted |
PINNED_MESSAGE_RETENTION_DAYS |
No (default 365) |
Last-edit age after which pinned messages are deleted |
USER_RETENTION_DAYS |
No (default 365) |
Inactivity after which accounts are deleted (0 off) |
TURN_HOST / TURN_USERNAME / TURN_CREDENTIAL |
No | TURN for voice on hard NATs. See docs/turn.md. |
MAX_VOICE_PARTICIPANTS |
No (default 8) |
Max people in a voice huddle |
Generic OAuth2 sign-in uses /api/auth/callback/oauth2 with PKCE. If the identity provider still lists /api/auth/oauth2/callback/oauth2, update that redirect URI before upgrading.
With trusted JWT, the reverse proxy authenticates the visitor and sets the named header to a JWT. Richat reads sub, email, and name from the payload and does not verify the signature — the app must not be reachable except through that proxy.
With email/password there is no email infrastructure: password resets go through Admin → Users → Reset password, which generates a random password the admin hands over out-of-band; the user can then change it in Settings → Profile.
- Emails are hashed at registration; the plaintext is never stored. Steam, OAuth2, and trusted JWT use bcrypt. Email/password uses a deterministic HMAC-SHA256 (keyed with
AUTH_SECRET) because sign-in must look the user up by email. - Sessions store no IP address and no user agent.
- Whitelist invites hold a plaintext email, Steam name, or
@domain. Person rows are consumed at first sign-in. Domain rows stay until they expire. Every row is purged at its chosen TTL (24 hours to 1 year). - The Steam ID in
auth.accountstays in plaintext: it is the login identity and is needed for lookup at sign-in. - A daily retention job (and on demand from Admin → Retention) purges old unpinned and pinned messages, orphan uploads, expired sessions, stale push subscriptions, and inactive accounts. The owner and system user are never auto-deleted. Deleted accounts keep chat shown as Deleted user.
- Push payloads are encrypted to the device. Each user can hide channel names, sender names, and message text from push payloads (Settings → Notifications).
- Link previews are fetched server-side (public addresses only) and cached in memory only.
- Optional GIF search talks to KLIPY from the browser by default. Set
KLIPY_PRIVACY=trueto send search and GIF bytes through the server so KLIPY does not see user IPs. Privacy mode is not KLIPY Terms of Service compliant. - The TURN sidecar runs with stdout logging off, because coturn allocation logs contain client IPs.
- Messages and files are stored unencrypted in PostgreSQL; there is no end-to-end encryption. Database dumps contain plaintext content.
- Issues: this repository
- Product site: richat.net