Overview
The Gryt server — chat, identity, permissions and everything that is not media
The Gryt server is the part that knows who people are. It holds the messages, the members, the permissions and the invites, it decides who may join, and it tells the SFU which voice rooms exist. Media never touches it.
It is a Node service: Express for HTTP, Socket.IO for the live connection,
SQLite through node:sqlite for storage. One process per community.
What runs alongside it
A working server is usually three or four containers:
| Piece | Why it is there |
|---|---|
| The server | Chat, identity, permissions, invites, the database |
| The SFU | Voice and video. One per machine, shared by every server on it |
| An image worker | Thumbnails and compression. One per server, because it reads that server's job queue |
| Object storage | Uploads, when STORAGE_BACKEND=s3 |
The image worker is per-server rather than shared for a specific reason: it pulls jobs out of the server's own SQLite database rather than being handed them over a network.
Configuration
The server reads its settings from the environment, except for the handful that live in the database and are covered further down.
The ones you will actually set
| Variable | Default | What it does |
|---|---|---|
SERVER_NAME | — | The name people see |
SERVER_DESCRIPTION | — | Shown on the join preview |
HOST | 127.0.0.1 | Bind address. Has to be 0.0.0.0 for anything but this machine to connect |
PORT | 5000 | HTTP and WebSocket port |
JWT_SECRET | — | Signs access tokens. Required |
DATA_DIR | ./data | Where SQLite and local uploads go |
CORS_ORIGIN | — | Comma-separated origins allowed to connect |
SFU_WS_HOST | — | Where the server reaches the SFU, internally |
SFU_PUBLIC_HOST | — | Where clients should reach the SFU. Usually different |
STUN_SERVERS | Google's | Passed through to clients |
HOST defaults to loopback
A server left on 127.0.0.1 still advertises itself over mDNS, so clients on
the network find it and then cannot connect. The server says so in its log at
startup. Set HOST=0.0.0.0 for a server other machines should reach.
Capacity and limits
| Variable | Default | What it does |
|---|---|---|
MAX_MEMBERS | 100 | How many members the server accepts |
VOICE_MAX_USERS | 0 | Voice seats. 0 is no limit |
GRYT_INVITE_MAX_JOINS_PER_HOUR | — | Per-invite join rate |
GRYT_SERVER_ICON_MAX_MB | 25 | Server icon upload ceiling. SERVER_ICON_MAX_MB also works |
MESSAGE_CACHE_TTL_MS | 30000 | How long a message page stays cached |
REFRESH_TOKEN_TTL_DAYS | 7 | Refresh token lifetime |
VOICE_MAX_USERS is the server's own cap. The SFU has a separate MAX_PEERS
that covers every server sharing it.
Storage
| Variable | Default | What it does |
|---|---|---|
STORAGE_BACKEND | s3 | s3 or filesystem |
S3_ENDPOINT | — | Object store endpoint |
S3_BUCKET | — | Bucket name |
S3_REGION | — | Region |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | — | Credentials |
S3_FORCE_PATH_STYLE | — | Needed by MinIO and most self-hosted stores |
DISABLE_S3 | — | Turn object storage off entirely |
IMAGE_WORKER_URL | — | Where the image worker listens |
MEDIA_SWEEP_INTERVAL_MS | 600000 | How often orphaned media is swept |
MEDIA_SWEEP_GRACE_MS | 1800000 | How long an orphan is left alone first |
EMOJI_QUEUE_CONCURRENCY | 1 | Emoji jobs at once, clamped to 1–4 |
EMOJI_QUEUE_POLL_MS | 750 | Queue poll interval, clamped to 250–5000 |
Object keys are flat: uploads/<uuid>.<ext>. Several servers can share one
bucket safely, because nothing ever lists the bucket and every delete works
from a key stored in that server's own database.
Identity
| Variable | Default | What it does |
|---|---|---|
GRYT_IDENTITY_TIERS | account | Which identities may join. Add local to allow device-only keypairs |
GRYT_OIDC_ISSUER | — | The account issuer to trust |
GRYT_OIDC_AUDIENCE | — | Expected audience |
GRYT_TRUSTED_CERT_ISSUERS | — | Which certificate authorities an identity may be signed by |
GRYT_SERVER_ENABLED | true | Whether the server accepts joins at all |
GRYT_AUTH_MODE was renamed
GRYT_SERVER_ENABLED used to be GRYT_AUTH_MODE, with values required and
disabled. The name suggested it controlled whether members needed an account.
It never did: that is GRYT_IDENTITY_TIERS, and the two do not interact. All
the old setting ever did was refuse every join.
The old name and its old values still work so that upgrading does not take a server offline, and the new name wins when both are set. An unrecognised value refuses joins and says so, rather than guessing.
Behind a proxy
| Variable | Default | What it does |
|---|---|---|
GRYT_TRUSTED_PROXY_HOPS | 0 | How many proxies in front may be believed |
Zero by default. x-forwarded-for is a header any client can set, so trusting
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 proxy or a tunnel has to set this, or
every client arrives wearing the proxy's address and shares one rate-limit
bucket.
Discovery
| Variable | Default | What it does |
|---|---|---|
SERVER_DISCOVERABLE | — | Initial mDNS advertising state |
MDNS_INTERFACE | — | Pin advertising to one interface |
EXTERNAL_HOST | — | The address to advertise instead of the bound one |
Invite rate limiting
| Variable | Default |
|---|---|
SERVER_INVITE_MAX_RETRIES | 8 |
SERVER_INVITE_RETRY_WINDOW_MS | 300000 |
SERVER_INVITE_IP_MAX_RETRIES | — |
SERVER_INVITE_RETRY_COOLDOWN_MS | — |
SERVER_INVITE_MAX_COOLDOWN_MS | — |
The rest
SERVER_ID, SERVER_INSTANCE_ID, SERVER_PASSWORD and SERVER_VERSION
identify the server to the SFU and to clients. VOICE_CHANNEL_ID,
VOICE_CHANNEL_NAME and ADDITIONAL_CHANNELS seed the channel list on first
boot. NODE_ENV and DISABLE_STUN behave as you would expect.
Settings that live in the database
Four settings are not environment variables. They are stored in the server's own database, they can be changed while it runs, and changing one does more than write a row:
| Setting | Values |
|---|---|
joinPolicy | invite, open |
discoverable | on, off |
lanOpen | on, off |
profanityMode | off, flag, censor, block |
Turning discoverable off has to withdraw the mDNS advertisement, not just
record the intent, so these go through an apply path rather than a direct write.
Writing the row by hand leaves a server broadcasting itself while its own
configuration says it is hidden.
Owners change these from a client. An operator who is not the owner changes them through the management API.
The management API
A server administrator running Gryt on their own machine is not necessarily its owner, and should not have to be to turn off LAN discovery on a box they administer. So there is a second, tiny listener for exactly that.
| Variable | Default | What it does |
|---|---|---|
GRYT_ADMIN_TOKEN | — | Enables the listener. Without it nothing starts |
GRYT_ADMIN_PORT | 5099 | Port for the management listener |
| Endpoint | What it does |
|---|---|
GET /management/settings | The four settings above |
PATCH /management/settings | Change some of them. Send only the keys you are changing |
GET /management/health | {"ok":true} |
All three need Authorization: Bearer <GRYT_ADMIN_TOKEN>, compared as digests
in constant time.
Two separate things protect this listener. The token covers other users and
processes on the same machine. What covers everybody else is where the port is
published: the Compose file the CLI generates publishes it to 127.0.0.1 only,
which Docker enforces at the host before a packet reaches the container.
Do not publish this port
There is deliberately no check on the caller's address, because a container
cannot see a useful one. A request from the host's own loopback arrives at a
container published on 127.0.0.1 with a source address of the Docker bridge
gateway. A guard on req.ip would reject every legitimate request while proving
nothing, which is worse than no guard because it reads like one.
The loopback publish is the boundary. Keep it.
Requires server 1.5.0 or newer. Against anything older the endpoint is not there
at all, and the gryt CLI says so.
Endpoints
| Endpoint | What it does |
|---|---|
GET /health | { status, service, serverName, timestamp } |
GET /info | The public join preview |
GET /icon | The server icon, 404 when none is set |
GET /metrics | Prometheus |
The full REST and Socket.IO surface is in the API reference.