# Overview

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

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 |

<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 cannot 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 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.
</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

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

<Callout type="warn" title="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.
</Callout>

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](https://docs.gryt.chat/docs/server/api-reference).
