Talk High Performance Backend
Nextcloud Talk High Performance Backend
Without the HPB, Nextcloud Talk is peer-to-peer and degrades significantly with more than 4–5 participants. The HPB routes all media through the server, enabling reliable calls at scale.
This guide sets up each component as its own Docker container, giving you full control over versions, configuration, and logging:
| Container | Image | Role |
|---|---|---|
coturn | coturn/coturn:4.17 | TURN/STUN server for NAT traversal |
nats | nats:2.10 | Internal message broker |
janus | built from strukturag Dockerfile | WebRTC media server (SFU/MCU) |
signaling | strukturag/nextcloud-spreed-signaling:latest | WebRTC signaling server |
- A working Nextcloud Docker install (any install method)
- Port
3478open on your firewall and port-forwarded on your router for UDP and TCP - Ports
10000–20000UDP open on your firewall and port-forwarded for Janus WebRTC media - Ports
49152–65535UDP open on your firewall and port-forwarded for coturn TURN relay - A DNS
Arecord pointingtalk.<YourDomain>.comat your server - A reverse proxy entry for the signaling server (port
8081) - If behind NAT: your public WAN IP (run
curl -4 https://icanhazip.comon the server)
Set talk.<YourDomain>.com to DNS only (grey cloud) in Cloudflare.
| Port | Cloudflare proxy | Reason |
|---|---|---|
443 (signaling) | ✅ Would work | Standard HTTPS/WSS |
3478 UDP+TCP (TURN) | ❌ Will not work | Cloudflare doesn't proxy UDP or port 3478 |
Because both services share the same subdomain, the entire record must be grey-clouded.
Directory Structure
Create the config directories alongside your existing Nextcloud compose directory:
mkdir -p /opt/docker/nextcloud/coturn
mkdir -p /opt/docker/nextcloud/janus
mkdir -p /opt/docker/nextcloud/signaling
coturn Configuration
Create the coturn config file:
nano /opt/docker/nextcloud/coturn/turnserver.conf
listening-port=3478
no-cli
fingerprint
use-auth-secret
static-auth-secret=CHANGEME_TURN_SECRET
realm=talk.<YourDomain>.com
# Required for NAT — maps public WAN IP to the server's LAN IP
# Get your WAN IP with: curl -s -4 https://ifconfig.me
# WARNING: paste ONLY the bare IP — some DNS resolvers append "edns0-client-subnet ..." text
external-ip=<WAN_IP>/<LAN_IP>
relay-ip=<LAN_IP>
min-port=49152
max-port=65535
total-quota=100
stale-nonce=600
no-multicast-peers
# Disable TLS; DTLS is already off by default in coturn 4.17+
no-tls
# Block relay to private/loopback/multicast ranges (prevents SSRF via TURN)
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.0.2.0-192.0.2.255
denied-peer-ip=192.88.99.0-192.88.99.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=224.0.0.0-239.255.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
allowed-peer-ipIf you run a VPN or have Docker bridge networks (e.g. 172.17.0.0/16) that need to reach each other via TURN, add allowed-peer-ip entries to punch holes in the denied ranges:
allowed-peer-ip=172.17.0.1
allowed-peer-ip=10.200.0.0-10.200.0.8
Add verbose on its own line to enable detailed coturn logs during initial testing. Remove it once everything is working.
Janus Configuration
No maintained pre-built Janus image is available for Nextcloud Talk — Janus must be built from source. Create a Dockerfile that compiles it with the required plugins:
nano /opt/docker/nextcloud/janus/Dockerfile
FROM alpine:3
RUN apk add --no-cache curl autoconf automake libtool pkgconf build-base \
glib-dev libconfig-dev libnice-dev jansson-dev openssl-dev zlib libsrtp-dev \
gengetopt libwebsockets-dev git curl-dev libogg-dev
# usrsctp
ARG USRSCTP_VERSION=b28f0b55b00bde67f6be80d6623e2775b88026b8
RUN cd /tmp && \
git clone https://github.com/sctplab/usrsctp && \
cd usrsctp && \
git checkout $USRSCTP_VERSION && \
./bootstrap && \
./configure --prefix=/usr && \
make -j$(nproc) && make install
# libsrtp
ARG LIBSRTP_VERSION=2.6.0
RUN cd /tmp && \
curl -L -o v$LIBSRTP_VERSION.tar.gz https://github.com/cisco/libsrtp/archive/v$LIBSRTP_VERSION.tar.gz && \
tar xfv v$LIBSRTP_VERSION.tar.gz && \
cd libsrtp-$LIBSRTP_VERSION && \
./configure --prefix=/usr --enable-openssl && \
make shared_library -j$(nproc) && \
make install
# Janus
ARG JANUS_VERSION=1.3.0
RUN mkdir -p /usr/src/janus && \
cd /usr/src/janus && \
curl -L https://github.com/meetecho/janus-gateway/archive/v$JANUS_VERSION.tar.gz | tar -xz && \
cd /usr/src/janus/janus-gateway-$JANUS_VERSION && \
./autogen.sh && \
./configure --disable-rabbitmq --disable-mqtt --disable-boringssl && \
make -j$(nproc) && \
make install && \
make configs
WORKDIR /usr/src/janus/janus-gateway-$JANUS_VERSION
CMD [ "janus", "--full-trickle" ]
The signaling server requires Janus to broadcast events over WebSocket. Create the three required config overrides:
nano /opt/docker/nextcloud/janus/janus.jcfg
general: {
configs_folder = "/usr/local/etc/janus"
plugins_folder = "/usr/local/lib/janus/plugins"
transports_folder = "/usr/local/lib/janus/transports"
events_folder = "/usr/local/lib/janus/events"
loggers_folder = "/usr/local/lib/janus/loggers"
log_to_stdout = true
debug_level = 4
}
nat: {
# 1:1 NAT mapping — public WAN IP so Janus advertises the correct ICE candidates
# Get your WAN IP with: curl -s -4 https://ifconfig.me
nat_1_1_mapping = "<WAN_IP>"
# keep private IP in candidates too so LAN clients can connect directly
keep_private_host = true
full_trickle = true
# docker0 and br-* are Docker bridge interfaces — exclude them from ICE candidates
ice_ignore_list = "vmnet;docker;br-"
}
media: {
# Constrain Janus media ports so they can be port-forwarded on the router
rtp_port_range = "10000-20000"
ipv6 = false
ipv6_linklocal = false
}
events: {
broadcast = true
# disable unused event handlers to suppress FATAL log spam at startup
disable = "libjanus_sampleevh.so,libjanus_gelfevh.so"
}
nano /opt/docker/nextcloud/janus/janus.eventhandler.wsevh.jcfg
general: {
enabled = true
json = "compact"
grouping = true
# at minimum: handles, media, webrtc
events = "handles,media,webrtc"
# prevents memory runaway if signaling goes down and reconnects
events_cap_on_reconnect = 1000
# points to the signaling server WebSocket endpoint on the host
backend = "ws://localhost:8081/spreed"
subprotocol = "janus-events"
}
nano /opt/docker/nextcloud/janus/janus.transport.websockets.jcfg
general: {
json = "compact"
ws = true
ws_port = 8188
# bind to loopback only — only the signaling server needs this port
ws_interface = "lo"
wss = false
}
Signaling Server Configuration
Create the signaling server config file:
nano /opt/docker/nextcloud/signaling/server.conf
[http]
listen = 0.0.0.0:8081
[app]
debug = false
[sessions]
hashkey = CHANGEME_HASH_KEY
blockkey = CHANGEME_BLOCK_KEY
[clients]
internalsecret = CHANGEME_INTERNAL_SECRET
[backend]
backends = nextcloud
secret = CHANGEME_SIGNALING_SECRET
timeout = 10
connectionsperhost = 8
[nextcloud]
urls = https://cloud.<YourDomain>.com
secret = CHANGEME_SIGNALING_SECRET
skipverify = false
[nats]
# all services run on host network — use localhost for all internal connections
url = nats://localhost:4222
[mcu]
type = janus
url = ws://localhost:8188
# 1 Mbps / 2 Mbps — matches upstream defaults
maxstreambitrate = 1048576
maxscreenbitrate = 2097152
[turn]
apikey = CHANGEME_TURN_API_KEY
secret = CHANGEME_TURN_SECRET
servers = turn:localhost:3478?transport=udp,turn:localhost:3478?transport=tcp
Run openssl rand -hex 32 once per placeholder — each call produces a unique 64-character hex string (32 bytes):
openssl rand -hex 32 # → secret (global + backend)
openssl rand -hex 32 # → internalsecret
openssl rand -hex 32 # → TURN_API_KEY
openssl rand -hex 32 # → hashkey (32 or 64 bytes both valid)
openssl rand -base64 24 # → blockkey (must be 16/24/32 bytes — base64-24 = exactly 32 chars)
TURN_SECRET— must matchstatic-auth-secretinturnserver.confTURN_API_KEY— arbitrary key clients use to request temporary TURN credentials from the signaling serverSIGNALING_SECRET— set the same value in both[backend]→secretand[nextcloud]→secret; must match the shared secret in the Nextcloud Talk admin UIblockkey— AES session encryption key; strictly limited to 16, 24, or 32 bytes
skipverifySet skipverify = true only if your Nextcloud instance uses a self-signed certificate.
Updating the .env File
No extra environment variables are required for these containers — all configuration is handled via the mounted config files above. You only need to ensure your existing .env is present for the nextcloud service.
Adding Services to docker-compose.yaml
Open your existing compose file:
nano /opt/docker/nextcloud/docker-compose.yaml
Add the four new services:
services:
# all HPB services use host networking — no port mappings needed
coturn:
image: coturn/coturn:4.17
container_name: coturn
restart: unless-stopped
# Override CMD to drop the built-in detect-external-ip call; external-ip is hardcoded in turnserver.conf
command: ["--log-file=stdout"]
volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
network_mode: host
nats:
image: nats:2.10
container_name: nats
restart: unless-stopped
network_mode: host
janus:
build: ./janus
container_name: janus
restart: unless-stopped
volumes:
- ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
- ./janus/janus.eventhandler.wsevh.jcfg:/usr/local/etc/janus/janus.eventhandler.wsevh.jcfg:ro
- ./janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro
network_mode: host
signaling:
image: strukturag/nextcloud-spreed-signaling:latest
container_name: signaling
restart: unless-stopped
volumes:
- ./signaling/server.conf:/config/server.conf:ro
depends_on:
- nats
- janus
network_mode: host
With network_mode: host the containers bind directly to the host network — no Docker port mapping is needed. You still need the following open on the host firewall and port-forwarded on your router:
- Port
3478UDP and TCP — coturn STUN/TURN - Ports
10000–20000UDP — Janus WebRTC media - Ports
49152–65535UDP — coturn TURN relay (used when direct WebRTC to Janus fails) - Port
8081TCP — only needs to be reachable by your reverse proxy
Reverse Proxy — Signaling Server
The signaling server listens on port 8081 and requires a WebSocket-capable reverse proxy entry at https://talk.<YourDomain>.com.
Nginx example
A ready-to-use example is available in the Nginx Examples. Your Nextcloud vhost also needs a /spreed location block — see the Nextcloud nginx example.
server {
listen 80;
server_name talk.<YourDomain>.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name talk.<YourDomain>.com;
ssl_certificate /etc/letsencrypt/live/<YourDomain>.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<YourDomain>.com/privkey.pem;
proxy_ssl_trusted_certificate /etc/letsencrypt/live/<YourDomain>.com/chain.pem;
location / {
proxy_pass http://127.0.0.1:8081;
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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
}
Starting the Containers
cd /opt/docker/nextcloud
sudo docker compose up -d coturn nats janus signaling
The first run will take several minutes — Docker builds Janus from source before starting it.
Verify each container started cleanly:
sudo docker logs coturn
sudo docker logs nats
sudo docker logs janus
sudo docker logs signaling
The signaling server log should contain a line like Started serving on 0.0.0.0:8081.
Allow Nextcloud to reach the signaling server
If your signaling server is on the same host or a private network address, Nextcloud will refuse the outbound connection by default. Allow it with:
docker exec -it -u 33 nextcloud php occ config:system:set allow_local_remote_servers --value=true --type=boolean
trusted_proxiesDo not add the talk domain to trusted_proxies. That setting expects IP addresses (optionally in CIDR notation) — adding a hostname causes a "Forwarded for headers" error in your Nextcloud admin overview.
Configuring Nextcloud Talk
In Nextcloud, go to Administration Settings → Talk.
STUN Servers
Remove the default stun.nextcloud.com entry and add:
talk.<YourDomain>.com:3478
TURN Servers
Click Add a new TURN server and fill in:
| Field | Value |
|---|---|
| Server | talk.<YourDomain>.com:3478 |
| Secret | your TURN_SECRET value (matches static-auth-secret in turnserver.conf) |
| Protocol | UDP and TCP |
High-Performance Backend (Signaling)
Click Add High-performance backend server and fill in:
| Field | Value |
|---|---|
| High-performance backend URL | https://talk.<YourDomain>.com |
| Shared secret | your SIGNALING_SECRET value (matches secret in server.conf) |
Leave Validate SSL certificate checked for production.
Click Save after each section.
Testing
Open a Talk call with another user. In your browser's developer console (Network tab), you should see WebSocket connections to wss://talk.<YourDomain>.com and ICE candidates using your server's public IP rather than direct peer-to-peer paths.
Verify TURN is working by watching the coturn log during a call:
sudo docker logs -f coturn
You should see allocation and permission lines as clients connect through the relay.
Talk Federation (Beta)
Talk federation is currently in beta. Expect rough edges and check the Nextcloud Talk release notes for updates on what is supported in your version.
Nextcloud Talk supports federated text chat between users on different Nextcloud instances using the Open Cloud Mesh (OCM) standard. Federated users are added via their Federated Cloud ID (user@cloud.example.com) and receive an invite notification before they can join.
What works in federated chat:
- Group text chat across instances
- User mentions (
@user) - Markdown formatting
- Chat polls
Known limitations:
- ❌ Video calls are not supported — federation is chat-only
- ❌ Federated users cannot be appointed as moderators
- ❌ File/attachment sharing is not available for federated users
Troubleshooting
| Symptom | Likely Cause |
|---|---|
| Calls drop with 3+ participants | Signaling server not reachable — check Nginx WebSocket config |
| "TURN server is not accessible" warning | Port 3478 blocked on firewall — open UDP and TCP |
| Signaling config shows red in admin | Wrong SIGNALING_SECRET or reverse proxy not forwarding WebSocket Upgrade header |
| "Error: Cannot connect to server" in admin | Nextcloud blocking local outbound connection — run the allow_local_remote_servers occ command above |
coturn logs show ERROR: cannot bind to IP | coturn trying to bind to a public IP not present on the interface — remove listening-ip if set |
| Signaling container exits immediately | Malformed server.conf — check indentation and that all CHANGEME_ values are replaced |
lookup nats on ...: no such host in signaling logs | server.conf still uses nats://nats:4222 — change to nats://localhost:4222 (host network has no Docker DNS) |
sessions block key must be 16, 24 or 32 bytes but is 64 bytes | hashkey/blockkey were set with openssl rand -hex 32 (64 chars) — regenerate with openssl rand -base64 24 (32 chars) |
invalid_backend / "The backend URL is not supported" in Nextcloud Talk admin | Backend section is named [backend "nextcloud"] instead of [nextcloud], or url = is used instead of urls = — fix both in server.conf and restart |
| Janus container exits immediately | Check docker logs janus — may need the SHM_SIZE environment variable on low-memory hosts |
| ICE candidates use wrong public IP | Hardcode external-ip=<WAN_IP>/<LAN_IP> and relay-ip=<LAN_IP> in turnserver.conf — env-var auto-detection does not work behind NAT |
ERROR -X : Wrong address format on coturn startup | The default Docker CMD includes --external-ip=$(detect-external-ip) which runs dig and returns EDNS text — override it with command: ["--log-file=stdout"] in the compose service. external-ip in turnserver.conf takes effect without the CLI flag |
no_such_room: The user is not invited to this room. in signaling logs | Federated user attempted to join without a Talk federation invite — the host must send a federation invite through the Talk app and the remote user must accept before connecting |
Recording Backend
The recording backend captures Talk calls as video/audio files (.webm/.ogg) saved to the moderator's Nextcloud Files. It joins the call as a headless browser (Firefox + Selenium + ffmpeg) and uploads the result when recording stops.
- The Talk HPB from this guide is already running (the recording server connects to the signaling server as an internal client)
- At least 2 GB shared memory (
/dev/shm) available on the host — the headless browser requires it - Port
8000reachable from Nextcloud to the recording container (internal only — no public exposure needed)
Setting up the Directory
sudo mkdir -p /opt/docker/nextcloud/recording
Configuration File
nano /opt/docker/nextcloud/recording/server.conf
[logs]
level = 20
[http]
listen = 0.0.0.0:8000
[backend]
backends = nextcloud
secret = CHANGEME_RECORDING_SECRET
[nextcloud]
url = https://cloud.<YourDomain>.com
secret = CHANGEME_RECORDING_SECRET
[signaling]
signalings = signaling
url = https://talk.<YourDomain>.com
internalsecret = CHANGEME_INTERNAL_SECRET
[recording]
# firefox or chrome
browser = firefox
videowidth = 1920
videoheight = 1080
CHANGEME_RECORDING_SECRET— a new secret you choose freely; you will enter the same value in the Nextcloud Talk admin UICHANGEME_INTERNAL_SECRET— must matchinternalsecretin your signaling server'sserver.conf([clients]section)
openssl rand -hex 32 # → RECORDING_SECRET
Adding the Recording Service to Docker Compose
There is no pre-built image — the recording server must be built from source. Clone the repository into your recording directory first:
cd /opt/docker/nextcloud/recording
git clone https://github.com/nextcloud/nextcloud-talk-recording.git src
Open your existing Nextcloud compose file and add the service:
nano /opt/docker/nextcloud/docker-compose.yaml
recording:
build:
context: ./recording/src
dockerfile: docker-compose/Dockerfile
container_name: nextcloud-talk-recording
init: true
shm_size: '2gb'
volumes:
- ./recording/server.conf:/config/server.conf:ro
networks:
- nextcloud
restart: unless-stopped
Save the file by pressing CTRL+X
Build and start the recording container:
cd /opt/docker/nextcloud && sudo docker compose up -d --build recording
Confirm it started cleanly:
sudo docker logs nextcloud-talk-recording
To update to the latest version, pull the source and rebuild:
cd /opt/docker/nextcloud/recording/src && git pull
cd /opt/docker/nextcloud && sudo docker compose up -d --build recording
Nextcloud Talk Admin Configuration
- In Nextcloud, go to Administration Settings → Talk
- Scroll to Recording servers
- Click Add a new recording server and fill in:
| Field | Value |
|---|---|
| Recording backend URL | http://nextcloud-talk-recording:8000 |
| Shared secret | your CHANGEME_RECORDING_SECRET value |
- Click the checkmark to save, then click Test connection — you should see a green tick
The URL uses the Docker service name because the recording container is on the same nextcloud network. If the recording server is on a separate host, use its IP/hostname instead.
Enabling Recording in a Conversation
Once the backend is configured, moderators can start a recording from inside a call:
- Open the call in Nextcloud Talk
- Click the ⋮ More options button in the call toolbar
- Select Start recording
- When finished, click Stop recording — the file is saved to
Talk Recordings/in the moderator's Files
The recording file appears as a notification in Talk once processing is complete. The moderator can share it directly to the conversation from there.
💬 Recent Comments