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.
| Setting | Default |
|---|---|
GRYT_OIDC_ISSUER | https://auth.gryt.chat/realms/gryt |
GRYT_OIDC_REALM | gryt, or derived from the issuer |
GRYT_OIDC_CLIENT_ID | gryt-web |
GRYT_IDENTITY_URL | https://id.gryt.chat |
GRYT_AUTH_API | https://auth.gryt.chat, or derived from the issuer |
GRYT_AUTH_CALLBACK_URL | https://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:8081In 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.chat127.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=1Zero 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=trueS3_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.
| Setting | Values |
|---|---|
joinPolicy | invite, open |
discoverable | on, off |
lanOpen | on, off |
profanityMode | off, 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-identityThe 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
| Service | Endpoint | Notes |
|---|---|---|
| Server | GET /health | { status, service, serverName, timestamp } |
| SFU | GET /health | Answers 503 with "status":"starting" until its UDP probe passes |
| Image worker | GET / on HEALTH_PORT, 8080 | Any 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.