Production Deployment

Architecture Overview

Internet
    │
    ▼
┌─────────────────────────────────────┐
│         Reverse Proxy               │
│    (nginx/Caddy/Traefik)           │
│    SSL termination, routing         │
└───────────┬─────────────┬───────────┘
            │             │
    ┌───────▼───────┐ ┌───▼───────────┐
    │   Jellyfin    │ │ Session Server│
    │   :8096       │ │    :3000      │
    └───────────────┘ └───────────────┘

Docker Compose Deployment

# docker-compose.prod.yml
version: '3.8'

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    volumes:
      - ./config:/config
      - ./cache:/cache
      - /path/to/media:/media:ro
      - ./plugins/JellyWatchParty.dll:/config/plugins/JellyWatchParty/JellyWatchParty.dll:ro
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.example.com
    restart: unless-stopped
    networks:
      - internal

  session-server:
    image: ghcr.io/tigamingtv/jwp-session-server:latest
    container_name: jwp-session
    environment:
      - ALLOWED_ORIGINS=https://jellyfin.example.com
      - JWT_SECRET=${JWT_SECRET}
      - RUST_LOG=warn
    restart: unless-stopped
    networks:
      - internal

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped
    networks:
      - internal

networks:
  internal:

volumes:
  caddy_data:
  caddy_config:
# .env
JWT_SECRET=your-very-secure-32-character-secret-key

Reverse Proxy Configuration

jellyfin.example.com {
    reverse_proxy jellyfin:8096

    handle /ws {
        reverse_proxy session-server:3000
    }
}

Caddy automatically provisions Let’s Encrypt certificates for the block above.

nginx

location /ws {
    proxy_pass http://session-server;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_read_timeout 86400;
}

For Let’s Encrypt with Certbot: sudo apt install certbot python3-certbot-nginx && sudo certbot --nginx -d jellyfin.example.com (auto-renewal is usually configured automatically via certbot.timer).

Traefik

services:
  jellyfin:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.jellyfin.rule=Host(`jellyfin.example.com`)"
      - "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt"
      - "traefik.http.services.jellyfin.loadbalancer.server.port=8096"

  session-server:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.jwp-ws.rule=Host(`jellyfin.example.com`) && PathPrefix(`/ws`)"
      - "traefik.http.routers.jwp-ws.tls.certresolver=letsencrypt"
      - "traefik.http.services.jwp-ws.loadbalancer.server.port=3000"

Cloudflare Tunnel

Cloudflare Tunnel (cloudflared) creates an outbound-only connection to Cloudflare’s edge, so no ports need to be opened. Important: in the tunnel config, use the Docker service name or host IP to reach the session server — never localhost, which resolves to the tunnel container’s own loopback.

# ~/.cloudflared/config.yml
tunnel: your-tunnel-id
credentials-file: /path/to/credentials.json

ingress:
  - hostname: jellyfin.example.com
    service: http://jellyfin:8096
  - hostname: jwp.example.com
    service: http://session-server:3000
  - service: http_status:404

Set plugin Session Server URL to wss://jwp.example.com/ws. If cloudflared runs on the Docker host (not in a container), use the host IP or host.docker.internal instead of the service name.

Deployment Hardening

  • Use internal networks — don’t expose the session server port externally; put it on the same Docker network as the reverse proxy and expose (not ports) it
  • Set the Session Server URL in plugin settings to the reverse-proxy hostname (e.g. wss://jellyfin.example.com/ws) — without this, the client defaults to ws://<current-host>:3000/ws, which isn’t reachable through a proxy
  • Keep the admin panel private — it listens on its own port (3001) and only starts when ADMIN_PASSWORD is set. Don’t route it through the public /ws proxy rule. Publish it on 127.0.0.1, keep it on the LAN, or proxy it under HTTPS with ADMIN_COOKIE_SECURE=true (see Admin Panel). Set ADMIN_ENABLED=false if you don’t use it.
  • Keep the integration API internal — with the Discord bot, port 3002 must only be reachable from the bot container (the compose files don’t publish it)
  • Use read-only volumes for the plugin DLL and media directory
  • Drop capabilities:
    services:
      session-server:
        cap_drop: [ALL]
        read_only: true
        security_opt: [no-new-privileges:true]
    

See Security for authentication, CORS, and the full threat model.

Health Checks

services:
  session-server:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s
docker inspect --format='{{.State.Health.Status}}' session-server

Logging

Level Use Case
error Production (minimal)
warn Production (recommended)
info Debugging
debug Development
trace Deep debugging
services:
  session-server:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Forward to syslog or Loki instead of local files if you’re aggregating logs centrally:

services:
  session-server:
    logging:
      driver: loki
      options:
        loki-url: "http://loki:3100/loki/api/v1/push"
        labels: "service=jwp-session"

Alerting

Simple uptime check via cron:

#!/bin/bash
if ! curl -sf http://localhost:3000/health > /dev/null; then
    echo "JellyWatchParty session server is DOWN" | mail -s "ALERT: JWP Down" admin@example.com
fi
*/5 * * * * /usr/local/bin/check-jwp.sh

For fleet-style monitoring, point Uptime Kuma at http://session-server:3000/health, or wire an Alertmanager rule against container up metrics if you already run Prometheus:

groups:
  - name: jwp
    rules:
      - alert: JWPSessionServerDown
        expr: up{job="session-server"} == 0
        for: 1m
        labels:
          severity: critical

The session server doesn’t currently expose Prometheus metrics itself — monitor container resource usage (docker stats session-server, or cAdvisor) and connection/room counts from logs in the meantime.

Capacity Planning

Metric Per Client Per Room
Memory ~1 KB ~5 KB
CPU Minimal Minimal
Bandwidth ~1 KB/s ~10 KB/s

Current limitations: single instance (stateful, in-memory) — see Configuration: Multi-Instance Setup for why you shouldn’t run more than one.

Connection limits: WebSocket connections are bounded by OS file-descriptor limits (default 1024) — raise with ulimit -n 65535, or in Docker:

services:
  session-server:
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

Backup Strategy

Back up: Jellyfin /config directory (includes plugin config), Docker Compose files, .env files with secrets, and the jwp-data volume if you use the Discord bot (bot settings, link codes, linked accounts). Back up the whole volume, including secret.key: without that key, every link code stops matching, and you have to assign new codes.

Don’t back up: session server rooms (ephemeral, in-memory) or cache directories.

#!/bin/bash
BACKUP_DIR=/backup/jellyfin
DATE=$(date +%Y%m%d)
docker compose stop jellyfin
tar -czf $BACKUP_DIR/config-$DATE.tar.gz ./config
docker compose start jellyfin

Upgrade Procedure

  1. Backup first: docker compose stop && tar -czf backup-before-upgrade.tar.gz ./config
  2. Pull new images: docker compose pull
  3. Update plugin: download the new .dll from Releases and replace it in your plugins volume
  4. Restart: docker compose up -d
  5. Verify: check Dashboard for plugin status, test Watch Party functionality, check logs for errors

Troubleshooting a Deployment

If the health check is failing: confirm the container is running (docker ps), check its logs (docker logs session-server), and test from inside the container (docker exec session-server curl localhost:3000/health). For everything else, see Troubleshooting & FAQ.


Back to top

JellyWatchParty - Synchronized watch parties for Jellyfin