# The Gryt server

Source: https://docs.gryt.chat/docs/host/server

The Gryt server — chat, identity, permissions and everything that isn't 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's 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's 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'll 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 |

<Callout type="warn" title="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 can't connect. The server says so in its log at
startup. Set `HOST=0.0.0.0` for a server other machines should reach.
</Callout>

### 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 |

<Callout title="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 don't interact. All
the old setting ever did was refuse every join.

The old name and its old values still work so that upgrading doesn't take a
server offline, and the new name wins when both are set. An unrecognised value
refuses joins and says so, rather than guessing.
</Callout>

### 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

These cover invites only. [Rate limiting](https://docs.gryt.chat/docs/host/rate-limiting) covers every
other limit the server applies, and what it does when somebody hits one.

| 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 aren't environment variables. They're 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's hidden.

Owners change these from a client. An operator who isn't the owner changes them
through the management API.

## The management API

A server administrator running Gryt on their own machine isn't necessarily its
owner, and shouldn't have to be to turn off LAN discovery on a box they
administer. So there's 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're 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.

<Callout type="warn" title="Don't publish this port">
There's deliberately no check on the caller's address, because a container
can't 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.
</Callout>

Requires server 1.5.0 or newer. Against anything older the endpoint isn't 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](https://docs.gryt.chat/docs/build/server-api).
