services: keywave: build: . container_name: keywave restart: unless-stopped ports: - "0.0.0.0:5128:5000" environment: - PYTHONUNBUFFERED=1 # Lock CORS to your real origin in production: - KEYWAVE_ALLOWED_ORIGINS=${KEYWAVE_ALLOWED_ORIGINS:-*} # ── TURN (makes video connect across NAT / mobile networks) ──────────── # Set these in a .env file next to this compose file (see .env.example), # then start with: docker compose --profile turn up -d --build # The SAME .env value reaches both the app and coturn, so credentials match. - KEYWAVE_TURN_HOST=${KEYWAVE_TURN_HOST:-} - KEYWAVE_TURN_SECRET=${KEYWAVE_TURN_SECRET:-} - KEYWAVE_TURN_TLS=${KEYWAVE_TURN_TLS:-} - KEYWAVE_FORCE_RELAY=${KEYWAVE_FORCE_RELAY:-} # External TURN (instead of the bundled coturn) + tunables documented in # README/.env.example. Compose's .env is only used for substitution, so # these must be forwarded explicitly to reach the container ('' -> the # app's built-in default). - KEYWAVE_TURN_URL=${KEYWAVE_TURN_URL:-} - KEYWAVE_TURN_USER=${KEYWAVE_TURN_USER:-} - KEYWAVE_TURN_PASS=${KEYWAVE_TURN_PASS:-} - KEYWAVE_TURN_TTL=${KEYWAVE_TURN_TTL:-} - KEYWAVE_STUN=${KEYWAVE_STUN:-} - KEYWAVE_MAX_ROOM_PEERS=${KEYWAVE_MAX_ROOM_PEERS:-} - KEYWAVE_MAX_ROOMS=${KEYWAVE_MAX_ROOMS:-} - KEYWAVE_ROOM_TTL=${KEYWAVE_ROOM_TTL:-} - KEYWAVE_GRACE_TTL=${KEYWAVE_GRACE_TTL:-} - KEYWAVE_MSG_MAX=${KEYWAVE_MSG_MAX:-} - KEYWAVE_MSG_WINDOW=${KEYWAVE_MSG_WINDOW:-} - KEYWAVE_SIG_MAX=${KEYWAVE_SIG_MAX:-} - KEYWAVE_SIG_WINDOW=${KEYWAVE_SIG_WINDOW:-} read_only: true tmpfs: - /tmp cap_drop: - ALL security_opt: - no-new-privileges:true mem_limit: 256m pids_limit: 256 healthcheck: test: ["CMD-SHELL", "python -c \"import os,urllib.request; urllib.request.urlopen('http://localhost:%s/healthz' % os.environ.get('KEYWAVE_PORT','5000'))\""] interval: 30s timeout: 10s retries: 3 start_period: 15s logging: driver: "json-file" options: max-size: "10m" max-file: "3" # ── Bundled TURN relay (opt-in) ──────────────────────────────────────────── # Started only with: docker compose --profile turn up -d # coturn needs the host's network to relay UDP, so it uses host networking and # the public IP directly. # # >>> YOU MUST OPEN THESE IN THE VPS FIREWALL / CLOUD SECURITY GROUP <<< # UDP + TCP 3478 (TURN control) # UDP 49160-49400 (relay media — the range below) # If the relay range is closed, coturn allocates a port the peer cannot reach, # so control works but MEDIA NEVER FLOWS (mobile fails while LAN/desktop may # still connect directly). This is the #1 cause of "connects but no video". # # KEYWAVE_TURN_SECRET (from .env) is shared with the app so minted credentials # validate; it is REQUIRED. On a 1:1-NAT cloud VPS (AWS/GCP/Oracle), set # KEYWAVE_TURN_EXTERNAL_IP to the public IP (or /) or relay # candidates advertise an unroutable private address and media silently fails. coturn: image: coturn/coturn:4.6 container_name: keywave-turn restart: unless-stopped profiles: ["turn"] network_mode: host environment: # Soft default here so the non-turn `docker compose up` still parses; the # container shell below hard-requires it at start (so coturn under the # turn profile refuses to run without a real secret). - KEYWAVE_TURN_SECRET=${KEYWAVE_TURN_SECRET:-} - KEYWAVE_TURN_EXTERNAL_IP=${KEYWAVE_TURN_EXTERNAL_IP:-} # Use a shell so the secret is required (:?) and --external-ip is included # only when set (:+). $$ defers expansion to the container's shell. # --mobility keeps a phone's relay allocation alive across Wi-Fi<->cellular # network changes; the widened relay range (241 ports) avoids exhaustion. # --relay-ip pins relay allocations to the public IP: with host networking # coturn otherwise discovers every interface (Docker bridges 172.x/192.x and # IPv6) and can hand a phone an UNROUTABLE relay candidate, so control works # but media never flows. ${EXT##*/} is the local NIC IP (the part after '/' # in a / value, or the whole value for a plain public IP). entrypoint: ["/bin/sh", "-c"] command: - > exec turnserver -n --no-cli --no-tls --no-dtls --fingerprint --mobility --use-auth-secret --static-auth-secret="$${KEYWAVE_TURN_SECRET:?set KEYWAVE_TURN_SECRET}" --realm=keywave --listening-port=3478 --min-port=49160 --max-port=49400 --no-multicast-peers $${KEYWAVE_TURN_EXTERNAL_IP:+--external-ip=$${KEYWAVE_TURN_EXTERNAL_IP} --relay-ip=$${KEYWAVE_TURN_EXTERNAL_IP##*/}} # ── Optional automatic HTTPS (opt-in) ────────────────────────────────────── # Started with: docker compose --profile tls up -d # Caddy fetches a Let's Encrypt certificate for KEYWAVE_DOMAIN automatically # and reverse-proxies (WebSockets included) to the app. Requires a domain whose # DNS A/AAAA record points at this host, and ports 80 + 443 open. Skip this # profile if you already run your own reverse proxy (Nginx Proxy Manager, etc.) # — point that at http://keywave:5000 with WebSocket upgrade enabled instead. caddy: image: caddy:2-alpine container_name: keywave-caddy restart: unless-stopped profiles: ["tls"] depends_on: [keywave] ports: - "80:80" - "443:443" - "443:443/udp" environment: - KEYWAVE_DOMAIN=${KEYWAVE_DOMAIN:-} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config logging: driver: "json-file" options: max-size: "10m" max-file: "3" volumes: caddy_data: caddy_config: