Gryt

Docker Compose

Self-host Gryt with a single docker compose up — no cloning required

The fastest way to self-host Gryt. Everything runs from pre-built images published to GitHub Container Registry — no need to clone any repos or build anything.

What's included

ServiceImagePurposeRequired?
Serverghcr.io/gryt-chat/serverSignaling, chat, file uploadsYes
SFUghcr.io/gryt-chat/sfuWebRTC media forwardingYes
MinIOminio/minioS3-compatible file storageYes
Image Workerghcr.io/gryt-chat/image-workerBackground image compression + thumbnailingOptional (recommended)
Clientghcr.io/gryt-chat/clientWeb UI (React + Nginx)Dev / local only
Prometheusprom/prometheusMetrics collectionOptional
Grafanagrafana/grafanaMetrics dashboardsOptional

Most users connect via the Gryt desktop app (available for Linux, macOS, and Windows).

Web client and authentication

The web client Docker image is included for local development and contributing to Gryt. It is not recommended for production self-hosting because the OIDC login flow requires your domain to be registered as a redirect URI with the centralized auth service at auth.gryt.chat — and only official Gryt domains are whitelisted.

If you need a web-based interface, use the hosted client at app.gryt.chat, or connect with the desktop app.

You can self-host your own Keycloak, but that means your users will have separate accounts that don't work on other Gryt servers (no cross-server identity).

Quick start

Download the compose file and example env

mkdir gryt && cd gryt
curl -Lo docker-compose.yml https://raw.githubusercontent.com/Gryt-chat/gryt/main/ops/deploy/compose/prod.yml
curl -Lo .env https://raw.githubusercontent.com/Gryt-chat/gryt/main/ops/deploy/compose/.env.example
mkdir gryt && cd gryt
wget -O docker-compose.yml https://raw.githubusercontent.com/Gryt-chat/gryt/main/ops/deploy/compose/prod.yml
wget -O .env https://raw.githubusercontent.com/Gryt-chat/gryt/main/ops/deploy/compose/.env.example

Edit the .env file

Open .env in your editor and configure at minimum:

# Give your server a name
SERVER_NAME=My Gryt Server

# Set a real secret in production
JWT_SECRET=<run: openssl rand -base64 48>

# Allowed origins (desktop app + hosted web client)
CORS_ORIGIN=http://127.0.0.1:15738,https://app.gryt.chat

Start the server

docker compose up -d

This starts the core services: Server, SFU, and MinIO. Connect with the Gryt desktop app or the hosted web client at app.gryt.chat.

Invite-only: the first user to join a brand-new server becomes the owner/admin automatically. After that, the server is invite-only. Create invite links in Server settings → Invites and share them.

Architecture

Configuration reference

Image versions

Images default to latest. Pin a specific version for reproducible deploys:

SERVER_VERSION=x.y.z
SFU_VERSION=x.y.z
IMAGE_WORKER_VERSION=x.y.z  # only if running image-worker

Browse available tags at github.com/orgs/gryt-chat/packages.

Beta channel

The Gryt server, SFU, image worker, and web client publish a latest-beta image. To follow the beta channel, set the version variables in .env:

SERVER_VERSION=latest-beta
SFU_VERSION=latest-beta
IMAGE_WORKER_VERSION=latest-beta

# Only used when the optional web profile is enabled
CLIENT_VERSION=latest-beta

Then pull and restart the stack:

docker compose pull
docker compose up -d

latest-beta moves whenever a new beta is published. For a predictable deployment, replace it with the exact tag shown in the package registry, such as 1.2.3-beta.4. Pin each service separately because its version may differ from the others.

Back up before trying a beta

Beta builds may change the SQLite schema when the server starts. Gryt has no down-migration system, so an older server cannot undo those changes. Back up the server data directory before upgrading. To return to stable, restore that backup, change the version variables to latest or exact stable tags, then run docker compose pull and docker compose up -d again.

A beta server does not change any other server a user belongs to. The risk is at the client-server boundary: a stable client may not understand a beta-only event or endpoint, and a beta client may expect something a stable server does not provide. Run the matching beta desktop client when testing a beta server.

People joining cannot rely on the client to identify a self-hosted server's release channel. Add Beta to SERVER_NAME and mention the version in SERVER_DESCRIPTION so they know what they are joining:

SERVER_NAME=My Gryt Server (Beta)
SERVER_DESCRIPTION=Testing Gryt 1.2.3-beta.4

Image Worker

VariableDefaultDescription
IMAGE_WORKER_CONCURRENCY2Max images processed in parallel
IMAGE_WORKER_POLL_MS1000Poll interval for queued jobs (ms)

Ports

VariableDefaultDescription
SERVER_PORT5000Signaling API + WebSocket
SFU_PORT5005SFU WebSocket
ICE_UDP_MUX_PORT3478The one UDP port all WebRTC media flows over

Server

VariableDefaultDescription
SERVER_NAMEMy Gryt ServerDisplay name in the server browser
SERVER_DESCRIPTIONA Gryt voice chat serverServer description
SERVER_PASSWORD(empty)Optional server↔SFU shared secret (not a user join password)
SERVER_INVITE_MAX_RETRIES8Invalid invite attempts before lockout (per IP+user)
SERVER_INVITE_RETRY_WINDOW_MS300000Sliding window for attempt counting (5 min)
SERVER_INVITE_RETRY_COOLDOWN_MS60000Base cooldown after lockout (1 min)
SERVER_INVITE_MAX_COOLDOWN_MS3600000Cooldown escalation cap (1 hour)
SERVER_INVITE_IP_MAX_RETRIES20Per-IP invalid invite attempts before lockout
VOICE_MAX_USERS(auto)Optional voice seat limit override
REFRESH_TOKEN_TTL_DAYS7Refresh token lifetime (days)
JWT_SECRETchange-me-in-productionSession token secret
CORS_ORIGINsee belowAllowed origins (comma-separated). Always include http://127.0.0.1:15738 (desktop app). Include https://app.gryt.chat if you want users to connect via the hosted web client.

If you set SERVER_PASSWORD, keep it stable. Changing it may require restarting the SFU, because the SFU caches the password per server ID.

Changing the server owner (CLI)

The first user to join a brand-new server becomes the owner/admin automatically.

To change the owner later, run:

docker compose exec server node admin-setOwner.js --grytUserId <keycloak_sub>

<keycloak_sub> is your Gryt user ID. You can find it in the desktop app or web client by going to Settings → Profile and scrolling to the bottom.

Authentication

VariableDefaultDescription
GRYT_IDENTITY_TIERSaccountComma-separated identity types the server accepts. Add local to admit people without a Gryt account.
GRYT_SERVER_ENABLEDtrueWhether the server accepts joins. false rejects every join while it is out of service. Any other value also rejects joins and reports itself as misconfigured. The old name GRYT_AUTH_MODE, with required and disabled, still works; the new name wins when both are set.
GRYT_OIDC_ISSUERhttps://auth.gryt.chat/realms/grytOIDC issuer URL
GRYT_OIDC_AUDIENCEgryt-webOIDC audience

WebRTC / NAT

VariableDefaultDescription
STUN_SERVERSGoogle STUNComma-separated STUN URIs
SFU_PUBLIC_HOSTws://localhost:5005Public SFU URL(s) for browsers (comma-separated for multi-network)
ICE_ADVERTISE_IP(auto)Public IP(s) to advertise in ICE candidates (comma-separated for multi-network)

Storage

VariableDefaultDescription
MINIO_ROOT_USERminioadminMinIO admin username
MINIO_ROOT_PASSWORDminioadminMinIO admin password
S3_BUCKETgrytBucket for file uploads

Production checklist

Before going public

  • Set a strong JWT_SECRET — generate with openssl rand -base64 48
  • Change MINIO_ROOT_USER and MINIO_ROOT_PASSWORD
  • Open ICE_UDP_MUX_PORT as UDP for the SFU
  • Set ICE_ADVERTISE_IP if behind NAT
  • Set SFU_PUBLIC_HOST to your public wss:// URL
  • Ensure CORS_ORIGIN includes http://127.0.0.1:15738 (desktop app) and https://app.gryt.chat (hosted web client)
  • Put a reverse proxy (Caddy, Nginx, Traefik) in front for TLS
  • Pin image versions instead of latest

The simplest way to add HTTPS is Caddy — it handles certificates automatically.

Add this to your docker-compose.yml:

services:
  caddy:
    image: caddy:latest
    container_name: gryt-caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    networks:
      - gryt
    restart: unless-stopped

volumes:
  caddy-data:

And create a Caddyfile:

api.example.com {
    reverse_proxy server:5000
}

sfu.example.com {
    reverse_proxy sfu:5005
}

Then update your .env:

CORS_ORIGIN=http://127.0.0.1:15738,https://app.gryt.chat
SFU_PUBLIC_HOST=wss://sfu.example.com

# Multi-network example (LAN party + public):
# SFU_PUBLIC_HOST=wss://sfu.example.com,ws://192.168.1.100:5005
# ICE_ADVERTISE_IP=203.0.113.10,192.168.1.100

LAN optimization

Hosting for a LAN party or local network?

Add your server's LAN IP to ICE_ADVERTISE_IP and SFU_PUBLIC_HOST so clients on the same network connect directly — skipping external STUN entirely for near-zero-latency voice.

# Your server's LAN IP (find it with: hostname -I | awk '{print $1}')
ICE_ADVERTISE_IP=192.168.1.100
SFU_PUBLIC_HOST=ws://192.168.1.100:5005

For mixed setups where some users are on the LAN and others connect over the internet, use comma-separated values. The client automatically pings each SFU endpoint and picks the fastest:

ICE_ADVERTISE_IP=203.0.113.10,192.168.1.100
SFU_PUBLIC_HOST=wss://sfu.example.com,ws://192.168.1.100:5005

Managing the stack

# View logs
docker compose logs -f

# View logs for a single service
docker compose logs -f server

# Restart a service after config change
docker compose restart server

# Update to latest images
docker compose pull && docker compose up -d

# Update a single service to a specific version
SFU_VERSION=x.y.z docker compose up -d sfu

# Stop everything
docker compose down

# Stop and remove all data (clean slate)
docker compose down -v

Health checks

All services expose health endpoints. Check the stack health:

curl http://localhost:5000/health   # server
curl http://localhost:5005/health   # sfu

The image worker's health endpoint (GET / on port 8080) is internal-only — it's used by Docker's built-in healthcheck and is not exposed to the host.

Or use docker compose ps to see the health status of all containers.

Monitoring

Both the Server and SFU expose Prometheus metrics at /metrics. Enable the optional monitoring stack (Prometheus + Grafana) with:

docker compose --profile monitoring up -d

Grafana is available at http://localhost:3000 (default login: admin / admin).

See the full Monitoring guide for configuration, available metrics, and recommended dashboards.

Upgrading

# Pull the latest images
docker compose pull

# Recreate containers with new images
docker compose up -d

# Verify
docker compose ps

To upgrade to specific versions, edit the *_VERSION vars in .env:

SERVER_VERSION=x.y.z
SFU_VERSION=x.y.z

Then run docker compose up -d.

Running more than one server

One compose file can run as many Gryt servers as you like. They are fully separate — their own channels, members, invites and roles — and they share the expensive parts of the stack.

What each extra server needs:

Needs its ownWhy
DATA_DIR volumeThe SQLite database. This is the one that matters — two servers pointed at one gryt.db will corrupt each other's state.
SERVER_NAMEIt is part of the server's identity on the SFU. See the warning below.
Published portOne port per server on the host.
Image workerA worker reads exactly one DATA_DIR, so it serves exactly one server.

What they share, safely:

  • One SFU. Voice rooms are namespaced per server, so a single SFU serves all of them. This is how gryt.chat itself runs — three servers, one SFU.
  • One MinIO, one bucket. Every stored object is keyed by a UUID, so two servers writing to the same bucket cannot collide. Give them separate buckets if you would rather keep the storage separable.

Give every server a different SERVER_NAME. A server identifies itself to the SFU as SERVER_NAME_PORT_SERVER_INSTANCE_ID, and voice rooms are keyed on that. Two servers that produce the same string will fight over room registration and the second one's voice will fail to start. On a bridge network every container has the same internal PORT, so the name is usually the only thing telling them apart — or set SERVER_INSTANCE_ID explicitly, which is what the bundled dev stack does.

Adding a second server

Copy the server and image-worker blocks, and change four things — the container names, the port, the data volume, and the name:

services:
  server-2:
    image: ghcr.io/gryt-chat/server:${SERVER_VERSION:-latest}
    container_name: gryt-prod-server-2
    network_mode: host
    extra_hosts:
      - "sfu:127.0.0.1"
      - "minio:127.0.0.1"
    environment:
      PORT: 5010                      # a free port on the host
      SERVER_NAME: My Second Server   # must differ from the first
      DATA_DIR: /data                 # its own volume, mounted below
      JWT_SECRET: ${JWT_SECRET_2:?Set JWT_SECRET_2 in .env}
      SFU_WS_HOST: ws://sfu:${SFU_PORT:-5005}   # the same SFU
      S3_BUCKET: ${S3_BUCKET:-gryt}             # the same bucket
      IMAGE_WORKER_URL: http://127.0.0.1:8082
      # …every other variable copied from the first server
    volumes:
      - gryt-prod-server-2-data:/data

  image-worker-2:
    image: ghcr.io/gryt-chat/image-worker:${IMAGE_WORKER_VERSION:-latest}
    container_name: gryt-prod-image-worker-2
    ports:
      - "127.0.0.1:8082:8080"         # a different health port
    environment:
      DATA_DIR: /data
      S3_ENDPOINT: http://minio:9000
      S3_BUCKET: ${S3_BUCKET:-gryt}
      # …credentials copied from the first worker
    volumes:
      - gryt-prod-server-2-data:/data # the same volume as its own server

volumes:
  gryt-prod-server-2-data:

ops/deploy/compose/prod.yml ships this as a commented-out block, so uncommenting it is usually faster than writing it out.

The image worker is not on the critical path. Uploads succeed whether or not a worker is running — what you lose without one is thumbnails, recompression and the dominant colour used to tint voice tiles. If you do not care about those on a second server, you can leave its worker out entirely.

The bundled server uses network_mode: host so it can advertise itself on the LAN through avahi, and the avahi service file has a fixed name. Two host-networked servers on one machine will overwrite each other's file, and only one of them will be discoverable on the LAN. Both still work normally by address. If LAN discovery matters for both, put them on separate machines or move them to the bridge network and drop the /etc/avahi/services mount.

Using external S3

To use an external S3-compatible provider (AWS, Cloudflare R2, etc.) instead of the bundled MinIO, remove the minio and minio-init services from docker-compose.yml and set the appropriate environment variables on the server service directly:

server:
  environment:
    S3_ENDPOINT: "https://<account-id>.r2.cloudflarestorage.com"
    S3_REGION: auto
    S3_ACCESS_KEY_ID: "<your-key>"
    S3_SECRET_ACCESS_KEY: "<your-secret>"
    S3_BUCKET: gryt
    S3_FORCE_PATH_STYLE: "false"

On this page