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
Caddy (Recommended)
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(notports) 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 tows://<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_PASSWORDis set. Don’t route it through the public/wsproxy rule. Publish it on127.0.0.1, keep it on the LAN, or proxy it under HTTPS withADMIN_COOKIE_SECURE=true(see Admin Panel). SetADMIN_ENABLED=falseif 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
- Backup first:
docker compose stop && tar -czf backup-before-upgrade.tar.gz ./config - Pull new images:
docker compose pull - Update plugin: download the new
.dllfrom Releases and replace it in your plugins volume - Restart:
docker compose up -d - 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.