Gryt

Configuration

How each Gryt service is configured, and the settings that span more than one

Every service takes its settings from the environment. The full variable lists live with the service they belong to:

  • Server — the largest set, plus the four settings that live in its database
  • SFU — nine variables, most of which you will never touch
  • CLI — writes all of this for you, if you would rather not

This page covers the client, and the settings that only make sense across services: CORS, proxies, TLS, and which address the SFU is reachable on.

The client

The desktop and web clients need to know which identity stack to trust. Nothing else about them is configured this way; audio devices, volume and the noise gate are runtime settings in the UI, and capture is fixed at 48 kHz.

SettingDefault
GRYT_OIDC_ISSUERhttps://auth.gryt.chat/realms/gryt
GRYT_OIDC_REALMgryt, or derived from the issuer
GRYT_OIDC_CLIENT_IDgryt-web
GRYT_IDENTITY_URLhttps://id.gryt.chat
GRYT_AUTH_APIhttps://auth.gryt.chat, or derived from the issuer
GRYT_AUTH_CALLBACK_URLhttps://gryt.chat/auth/callback

Three places these come from

In development, Vite reads them from packages/client/.env with a VITE_ prefix:

VITE_GRYT_OIDC_ISSUER=http://localhost:8080/realms/gryt
VITE_GRYT_IDENTITY_URL=http://localhost:8081

In production, the nginx container writes /config.js at startup from its own environment, which sets window.__GRYT_CONFIG__ with the unprefixed names. No VITE_ variable is set in a production build at all, which is why the same image can be pointed at a different identity stack without rebuilding.

A user can also override the issuer and the identity URL from the account settings screen; those are stored in localStorage and beat both of the above.

Which one wins depends on the build

A development build reads VITE_ first and falls back to the window config. A production build reads the window config first and falls back to VITE_.

That flip is deliberate. Reading the window first everywhere meant the tracked public/config.js, which holds production defaults, beat the local values in development. A dev session would sign in against production Keycloak while the dev servers trusted only the local certificate authority. The only symptom was a 401 from the identity service saying "no applicable key found in the JWKS", which describes what happened and not why: the token was genuine, it was just signed by the wrong Keycloak.

Realm and auth API are usually derived

Set GRYT_OIDC_ISSUER to something like https://keycloak.example.com/realms/mycommunity and the realm and the auth API base are pulled out of it. You only set those two by hand for a layout that does not follow the /realms/<name> convention.

GRYT_IDENTITY_URL is never derived, because there is nothing in an issuer URL to derive it from. It is a different service on a different host: id.gryt.chat sits next to auth.gryt.chat, and your own certificate authority sits next to your own Keycloak.

The identity URL has to move too

Pointing the client at your own Keycloak without also moving GRYT_IDENTITY_URL gets you a token from your issuer posted to Gryt's certificate authority, which validates against its own configured issuer and rejects it. Same 401, same misleading message.

Self-hosting the web client

The web client is built for local development and for contributing. Self-hosting it against auth.gryt.chat does not work, because your domain is not a registered redirect URI there. Use the desktop app or app.gryt.chat to reach your server.

With your own Keycloak it works, and your members get identities that are yours rather than ones that carry across other Gryt servers.

Reaching the SFU

Two variables, and they are usually different values for the same thing.

SFU_WS_HOST="ws://sfu:5005"
SFU_PUBLIC_HOST="wss://sfu.example.com"

SFU_WS_HOST is how the server reaches the SFU, on the internal Docker network. SFU_PUBLIC_HOST is what the server hands to clients. A browser cannot resolve sfu, so getting these the same way round is the difference between voice working and voice sitting on "connecting".

SFU_PUBLIC_HOST takes a comma-separated list. Clients are given all of them and use whichever answers fastest, which is how one server can be reachable over a LAN and over the internet at the same time:

SFU_PUBLIC_HOST="ws://192.168.1.50:5005,wss://sfu.example.com"

The media port

Signalling is a WebSocket and goes wherever your other traffic goes. Media is UDP, on one port, and it has to be reachable directly:

ports:
  - "3478:3478/udp"

Tunnels do not carry UDP

Cloudflare Tunnel proxies HTTP and WebSockets, so signalling works through it and voice does not. ICE_UDP_MUX_PORT has to be exposed some other way. This is the single most common reason voice connects for people on the LAN and for nobody else.

There used to be a UDP port range. There is not any more. See the SFU page for what replaced it and why.

CORS

CORS_ORIGIN is a comma-separated list of origins allowed to connect. Unset, it is:

http://127.0.0.1:15738,https://app.gryt.chat,https://beta.gryt.chat

127.0.0.1:15738 is what the desktop app loads from. Outside production, http://localhost:3666 and http://127.0.0.1:3666 are added as well, which is the Vite dev port in both spellings. A server started by hand without those rejects the dev client with a bare 400 on the Socket.IO handshake and no explanation.

Origins are matched exactly, port included, so a server on :5001 does not accept an origin at :5002 on the same machine.

Two things get in without being listed. Origin: null is allowed, which is what a file:// Electron build sends. And an origin naming the same host the request was sent to is allowed, which is what lets the mobile app in: React Native sets Origin from the URL it dials, so a phone connecting to chat.example.com arrives claiming http://chat.example.com.

That second rule matches the exact host, so chat.example.com does not get to speak for evil.example, and a different port on the same machine is still a different origin. The reasoning is that CORS exists to stop one site reading another through a browser. An origin naming the host it is talking to is same-origin, and a page served from that server could do all of it already.

Behind a proxy

GRYT_TRUSTED_PROXY_HOPS=1

Zero unless you say otherwise. x-forwarded-for is a header any client can set, so believing it blindly lets one client walk around every per-IP limit by varying a string. Hops are counted from the right, which is the only end infrastructure writes.

A server behind a reverse proxy or a tunnel has to set this. Without it every client arrives wearing the proxy's address and shares a single rate-limit bucket, so one noisy visitor locks out everybody.

One for a single reverse proxy. More only if you genuinely chained them.

Object storage

STORAGE_BACKEND is s3 by default. Point it at any S3-compatible store:

STORAGE_BACKEND=s3
S3_ENDPOINT=http://minio:9000
S3_BUCKET=gryt
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true

S3_FORCE_PATH_STYLE=true is what MinIO and most self-hosted stores want.

STORAGE_BACKEND=filesystem puts uploads under DATA_DIR instead. No extra containers, and no thumbnails, because those come from the image worker.

Object keys are flat, uploads/<uuid>.<ext>, with nothing naming the server in them. Several servers can share one bucket: nothing ever lists it, and every delete uses a key stored in that server's own database.

Settings that are not environment variables

Four of the server's settings live in its database rather than its environment, because changing them has to do something rather than just record something. Turning discovery off has to withdraw the mDNS advertisement.

SettingValues
joinPolicyinvite, open
discoverableon, off
lanOpenon, off
profanityModeoff, flag, censor, block

lanOpen lets anybody already on the same network join without an invite. It is worth being deliberate about: it treats "can reach this machine" as good enough, which is reasonable on a home network and not on a coffee shop's.

Owners change these from a client. An operator who is not the owner changes them through the management API, which is what the gryt CLI's settings screen uses.

Administrative commands

Run inside the server container, or beside a built server:

yarn admin:set-owner
yarn admin:rotate-identity

The first hands ownership to a different member, which is how you recover a server whose owner is gone. The second replaces the server's identity keypair.

Health checks

ServiceEndpointNotes
ServerGET /health{ status, service, serverName, timestamp }
SFUGET /healthAnswers 503 with "status":"starting" until its UDP probe passes
Image workerGET / on HEALTH_PORT, 8080Any path other than /version answers health. /version gives just the version

The SFU's starting state is worth knowing about in Compose: it is what stops dependent services coming up before the container's UDP forwarding is actually in place.

On this page