Co-located egress falls back to the TURN relay instead of a loopback path → corrupt HLS
Hi everyone
I run whentheycry.social, a small community social networking site. It’s a passion project: a library, forums, chat, and recently I added live streaming so members can broadcast to each other. There are two playback modes, a low-latency WebRTC mode and a buffered HLS “Smooth” mode built on LiveKit Egress.
LiveKit has been brilliant for everything else, the WebRTC side is rock solid. I’m stuck on one specific networking problem with the HLS egress and I’ve run out of ideas, so I’d really appreciate a steer from anyone who knows the egress/ICE internals. I’ve tried to do my homework below and include the exact configs and logs.
Thanks in advance
TL;DR
One box, everything host-networked, one public IPv4 and no NAT. A co-located egress subscribing to a track connects to the TURN relay on the box’s own public IP instead of going direct over loopback. That relay drops packets and the egress HLS output (SegmentedFileOutput) is visibly corrupt with choppy audio. A normal WebRTC subscriber to the exact same track is clean, so ingest and the SFU are fine, only the SFU → egress subscribe path is bad.
I already tried the obvious SFU-side fix, making the server advertise 127.0.0.1 as well as the public IP (verified in the server log). The egress still relays. egress.yaml has no ICE/rtc config at all.
Question: how do I make a same-host egress connect to the SFU over loopback (or pair with the advertised 127.0.0.1 host candidate) instead of falling back to the relay? Is there an egress-side equivalent of enable_loopback_candidate / an rtc block for egress.yaml?
Environment
- Single bare-metal host (Hetzner), one direct public IPv4
88.198.26.24, no NAT in front. IPv6 is also present on the box. livekit-server,livekit/ingress,livekit/egressall runnetwork_mode: host(WHIP media requires it) and reach each other on127.0.0.1.- Versions:
livekit/livekit-serverlatest,livekit/egress:latest,livekit/ingress:latest. Egress usesserver-sdk-go/v2,pion/ice/v4. - Path: remote OBS → WHIP → ingress (transcoding bypassed, H.264) → SFU → { WebRTC viewers | egress → SegmentedFileOutput → HLS }.
Topology
remote OBS (public internet)
│ WHIP / H.264
▼
┌────────────────────────────────────── one host, all network_mode: host ────────┐
│ ingress ──ws://127.0.0.1:7880──► livekit-server (SFU) │
│ │ │
│ ┌─────────────┴───────────────┐ │
│ WebRTC (clean) ▼ ▼ subscribe (CORRUPT) │
│ remote viewers egress ── HLS .ts/.m3u8 │
└─────────────────────────────────────────────────────────────────────────────────┘
The egress and the SFU are the same machine on loopback, so a 127.0.0.1 ↔ 127.0.0.1 pair should be a guaranteed direct path. It is not being used.
Symptom
- HLS output (egress SegmentedFileOutput) is corrupt: macroblocking, garbled/frozen frames, audio breaks up.
- A WebRTC subscriber to the same track at the same time is perfect, so ingest and the SFU are healthy; the loss is on the SFU → egress subscribe connection.
- The egress log shows it sitting on a relay candidate against the box’s public IP and timing out, never establishing a direct pair (see Evidence 2).
Configs (live, on the box, keys redacted)
livekit.yaml (SFU) - currently deployed (baseline)
port: 7880
prometheus:
port: 6789
bind_addresses:
- ""
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: false # tried `true`
# advertise_internal_ip: true
# enable_loopback_candidate: true
node_ip: 88.198.26.24
ips:
excludes:
- "::/0"
- "172.16.0.0/12"
keys:
<api_key>: <api_secret>
webhook:
api_key: <api_key>
urls:
- http://localhost:2312/api/v1/livekit/webhook
turn:
enabled: true
udp_port: 3478
redis:
address: 127.0.0.1:6380
ingress:
whip_base_url: "https://ingress.whentheycry.social/w"
room:
enabled_codecs:
- mime: audio/opus
- mime: video/H264
- mime: video/VP8
- mime: video/VP9
- mime: video/AV1
- mime: video/rtx
egress.yaml - currently deployed (note: no rtc / ICE block exists)
log_level: info
api_key: <api_key>
api_secret: <api_secret>
ws_url: ws://127.0.0.1:7880
redis:
address: 127.0.0.1:6380
ingress.yaml - currently deployed (shown because the working WHIP path uses the same IP settings)
logging:
level: info
api_key: <api_key>
api_secret: <api_secret>
ws_url: ws://127.0.0.1:7880
redis:
address: 127.0.0.1:6380
whip_port: 8090
rtc_config:
udp_port: 7885
use_external_ip: false
node_ip: 88.198.26.24
ips:
excludes:
- "::/0"
- "172.16.0.0/12"
cpu_cost:
whip_bypass_transcoding_cpu_cost: 0.1
whip_cpu_cost: 2
rtmp_cpu_cost: 2
url_cpu_cost: 2
What I tried on the SFU, and the proof it took effect
I switched the SFU rtc block to advertise the internal/loopback candidate alongside the public IP:
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
advertise_internal_ip: true
enable_loopback_candidate: true
# node_ip removed (single-candidate replace mode was suppressing the loopback candidate)
ips:
excludes:
- "::/0"
- "172.16.0.0/12"
The server confirms it then advertises both 88.198.26.24 and 127.0.0.1:
INFO livekit rtcconfig/webrtc_config.go:105 using external IPs
{"ips": ["88.198.26.24/88.198.26.24", "127.0.0.1/127.0.0.1"], "advertiseInternalIP": true}
INFO livekit rtcconfig/ip.go:211 found external IP via STUN
{"localAddr": "88.198.26.24:7882", "stunServer": "global.stun.twilio.com:3478",
"externalIP": "88.198.26.24", "validateExternalIP": true}
With the SFU offering 127.0.0.1 to subscribers, the egress still selected the relay and the HLS was still corrupt. Because that variant changed nothing for the egress (and use_external_ip: false is the only setting that keeps RTC + WHIP healthy on this box’s IPv6 setup), I reverted to the baseline above.
Evidence
1. SFU advertises the loopback candidate (shown above) - egress ignores it.
2. Egress is parked on the relay candidate and times out
WARN egress.lksdk.pion.ice Failed to read from candidate udp4 relay 88.198.26.24:NNNNN
related 0.0.0.0:NNNNN: read udp 88.198.26.24:NNNNN: i/o timeout
WARN egress.lksdk.pion.ice Failed to ping without candidate pairs. Connection is not possible yet.
(Port numbers vary per session; refresh with docker logs <egress-container> 2>&1 | grep -iE "ice|relay|candidate" | tail -40.)
The egress never forms a direct candidate pair, so it lands on the relay, which on this single-public-IP box means hairpinning the box’s own public IP, that path is lossy, hence the corruption.
Already ruled out
- SFU not advertising loopback - ruled out. With
use_external_ip: true+advertise_internal_ip: truethe SFU advertises127.0.0.1(log above) and the egress still relays. node_ipreplace mode hiding the loopback - addressed (removednode_ipin the variant above so127.0.0.1reaches the wire), no change to the egress.- Reverse-path filtering - loosened (
net.ipv4.conf.all.rp_filter = 2). No change; the box still cannot hairpin its own public IP (no NAT reflection), which is why the public-IP host pair fails and ICE falls onto the relay. - Codec / B-frames - not it. Egress EncodingOptions is
H264_BASELINE,KeyFrameInterval: 2, AAC; the WebRTC view of the same track is clean. egress.yamlICE config - there is nortcblock inegress.yaml, so I cannot see a documented way to make the egress gather its own loopback host candidate or prefer a local path.
Questions
- With the SFU advertising
127.0.0.1to subscribers, why does a co-located egress not form a127.0.0.1 ↔ 127.0.0.1pair and instead fall back to the relay? Does the egress need to gather its own loopback host candidate for that pair to exist? - Is there an egress-side equivalent of
enable_loopback_candidate, or anrtc/ ICE config block foregress.yaml? If so, what are the exact keys? - What is the recommended way to run an egress on the same host as the SFU so its subscribe connection stays on loopback and never relays (force-local ICE, a dedicated internal interface, ICE-lite, etc.)?
Thank you so much!
Victoria M
