A fully automated production installer for LiveKit, Coturn, Nginx, SSL, and LiveKit Egress on a single Ubuntu 24.04 server with two IP addresses.
Designed and tested for environments where WebRTC traffic is filtered or restricted — such as Iran — with TURN over port 443 (TCP + UDP) as the primary relay strategy.
┌─────────────────────────────────────────────────────────────┐
│ Single Ubuntu 24.04 Server │
│ │
│ IP1 (194.x.x.x) IP2 (45.x.x.x) │
│ livekit.example.com livekit-turn.example.com │
│ │
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Nginx │ │ Coturn │ │
│ │ :80 HTTP │ │ :443 TURN/TLS TCP+UDP │ │
│ │ :443 WSS │ │ :3478 TURN standard │ │
│ └──────┬──────┘ │ :49152-65535 UDP relay │ │
│ │ └─────────────────────────┘ │
│ ┌──────▼──────┐ │
│ │ LiveKit │ ┌─────────────────────────┐ │
│ │ :7880 int │ │ LiveKit Egress │ │
│ │ :7881 RTC │ │ (Docker container) │ │
│ └──────┬──────┘ └─────────────────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Redis │ │
│ │ :6379 int │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
- Updates and upgrades all system packages
- Installs all required dependencies
- Sets timezone to UTC (required for time-based HMAC TURN credentials)
- Enables chrony for NTP clock synchronization — prevents TURN auth failures caused by clock drift
- Applies kernel tuning parameters for 100+ simultaneous calls
- Enables BBR congestion control for better TCP throughput and lower latency
- Optimizes UDP buffer sizes for direct WebRTC media
- Increases file descriptor limits
- Configures UFW firewall with the exact ports needed
- Sets up fail2ban with:
- Coturn jail — blocks IPs with repeated 401 TURN auth failures
- Nginx jail — protects the WSS signaling endpoint
- Adds Nginx rate limiting — 30 connections per minute per IP on the WSS endpoint
- All sensitive config files are
chmod 600
- Validates DNS records before attempting certificate issuance
- Obtains Let's Encrypt SSL certificates via
certbot --standalone(not--nginx) — required when two IPs share one server - Configures automatic renewal with hooks:
pre— stops Nginx before renewalpost— starts Nginx after renewaldeploy— copies new cert to Coturn and reloads itdeploy— restarts LiveKit after renewal
- Binds exclusively to the second IP — no conflict with Nginx
- Configures TURN/TLS on port 443 (TCP + UDP) — bypasses most firewalls
- Configures standard TURN on port 3478
- Uses HMAC shared secret authentication (time-based credentials)
- Sets
relay-ipexplicitly to prevent relay on wrong interface - Cipher list includes both RSA and ECDSA — required since Let's Encrypt issues ECDSA certs
- Blocks relay to private IP ranges (security)
- Grants
cap_net_bind_servicecapability so Coturn can bind port 443 without root
- Downloads the correct binary (format:
livekit_VERSION_linux_amd64.tar.gz) - Creates a dedicated system user
livekit - Generates initial HMAC TURN credentials and injects them into config
- Configures
rtc.turn_serverspointing to Coturn — not theturn:block which would conflict with Coturn on port 443 - Installs as a systemd service with auto-restart
- Optionally configures webhook URLs for PHP backend event delivery
- HMAC credentials are time-based and expire after 24 hours
- A cron job runs daily at 3 AM UTC:
- Generates new HMAC username and password
- Updates
config.yamlin-place - Restarts LiveKit
- Handles ACME challenges for both domains on port 80
- Proxies WSS (WebSocket Secure) to LiveKit on the first IP
- Applies rate limiting on the signaling endpoint
- Compatible with nginx 1.24 (
listen ... ssl http2nothttp2 on)
- Installs Docker
- Runs
livekit/egress:latestas a Docker container - Config file is
chmod 644— required since Docker reads it as an internal container user - Recording output goes to
/var/lib/livekit-egress/recordings(persistent across reboots, unlike/tmp)
- Downloads the correct binary (format:
lk_VERSION_linux_amd64.tar.gz) - Configures the project using livekit-cli v2 syntax (
lk project add NAME)
- Checks all service statuses
- Tests SSL renewal with
certbot renew --dry-run - Generates a valid TURN test credential for immediate use with trickle-ice
- Prints all key values to terminal (also saved to
/etc/livekit/.secrets)
- Ubuntu 24.04 LTS
- Root access
- Two public IP addresses on the same server
- Two DNS A records pointing to the correct IPs before running the script
- Ports open:
22,80,443(TCP+UDP),3478(TCP+UDP),7881(TCP),49152–65535(UDP)
chmod +x livekit-install.sh
sudo ./livekit-install.shThe script will interactively ask for:
| Input | Example |
|---|---|
| IP1 (LiveKit) | 194.xxx.xxx.xxx |
| IP2 (TURN) | 45.xxx.xxx.xxx |
| LiveKit domain | livekit.example.com |
| TURN domain | livekit-turn.example.com |
| SSL email | admin@example.com |
| Webhook URLs | One per line, empty Enter to finish |
All generated secrets are saved to /etc/livekit/.secrets.
Since TURN credentials are HMAC-based, generate them server-side for each session. Example in Bash:
TURN_SECRET=$(grep static-auth-secret /etc/turnserver.conf | cut -d'=' -f2)
TIMESTAMP=$(( $(date +%s) + 86400 ))
USERNAME="${TIMESTAMP}:user"
PASSWORD=$(echo -n "$USERNAME" | openssl dgst -sha1 -hmac "$TURN_SECRET" -binary | base64)Example in PHP:
function getTurnCredentials(string $secret, string $user = 'user'): array {
$timestamp = time() + 86400;
$username = "{$timestamp}:{$user}";
$password = base64_encode(hash_hmac('sha1', $username, $secret, true));
return ['username' => $username, 'password' => $password];
}Example in Dart (Flutter):
import 'dart:convert';
import 'package:crypto/crypto.dart';
Map<String, String> getTurnCredentials(String turnSecret) {
final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000) + 86400;
final username = '$timestamp:user';
final hmac = Hmac(sha1, utf8.encode(turnSecret));
final password = base64.encode(hmac.convert(utf8.encode(username)).bytes);
return {'username': username, 'password': password};
}WebRTC uses ICE and tries candidates in order:
- Direct UDP — fastest, used when both peers have open UDP
- Direct TCP — fallback when UDP is blocked
- TURN relay — used when direct connection is impossible
TURN is triggered by:
- UDP filtering (common on Iranian mobile networks)
- Symmetric NAT (enterprise/corporate routers)
- Strict firewalls (only ports 80/443 open)
- ISP-level UDP restrictions
force_relay: truein LiveKit config
/etc/livekit/
config.yaml # LiveKit server config
.secrets # All generated keys and secrets (chmod 600)
/etc/turnserver.conf # Coturn config
/etc/livekit-egress/
config.yaml # Egress config (chmod 644)
/var/lib/livekit-egress/
recordings/ # Egress output (persistent)
/usr/local/bin/
livekit-server # LiveKit binary
lk # livekit-cli binary
livekit-turn-refresh.sh # TURN credential refresh script
/etc/cron.d/
livekit-turn-refresh # Daily cron at 3 AM UTC
/etc/letsencrypt/renewal-hooks/
pre/stop-nginx.sh
post/start-nginx.sh
deploy/coturn-reload.sh
deploy/livekit-restart.sh
/etc/fail2ban/
jail.d/coturn.conf
jail.d/nginx-livekit.conf
filter.d/coturn.conf
MIT