Skip to main content
WebsiteGitHub last commitGitHub commit activityGitHub IssuesDocker PullsDiscordLocalized

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:

ContainerImageRole
coturncoturn/coturn:4.17TURN/STUN server for NAT traversal
natsnats:2.10Internal message broker
janusbuilt from strukturag DockerfileWebRTC media server (SFU/MCU)
signalingstrukturag/nextcloud-spreed-signaling:latestWebRTC signaling server
Prerequisites
  • A working Nextcloud Docker install (any install method)
  • Port 3478 open on your firewall and port-forwarded on your router for UDP and TCP
  • Ports 10000–20000 UDP open on your firewall and port-forwarded for Janus WebRTC media
  • Ports 49152–65535 UDP open on your firewall and port-forwarded for coturn TURN relay
  • A DNS A record pointing talk.<YourDomain>.com at your server
  • A reverse proxy entry for the signaling server (port 8081)
  • If behind NAT: your public WAN IP (run curl -4 https://icanhazip.com on the server)
Cloudflare Users

Set talk.<YourDomain>.com to DNS only (grey cloud) in Cloudflare.

PortCloudflare proxyReason
443 (signaling)✅ Would workStandard HTTPS/WSS
3478 UDP+TCP (TURN)❌ Will not workCloudflare 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
/opt/docker/nextcloud/coturn/turnserver.conf — highlighted items will need to be modified
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-ip

If 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
Logging

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
/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
/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
/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
/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
/opt/docker/nextcloud/signaling/server.conf — highlighted items will need to be modified
[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
Generating secrets

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 match static-auth-secret in turnserver.conf
  • TURN_API_KEY — arbitrary key clients use to request temporary TURN credentials from the signaling server
  • SIGNALING_SECRET — set the same value in both [backend]secret and [nextcloud]secret; must match the shared secret in the Nextcloud Talk admin UI
  • blockkey — AES session encryption key; strictly limited to 16, 24, or 32 bytes
skipverify

Set 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:

Add to your existing docker-compose.yaml
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
Port Exposure

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 3478 UDP and TCP — coturn STUN/TURN
  • Ports 10000–20000 UDP — Janus WebRTC media
  • Ports 49152–65535 UDP — coturn TURN relay (used when direct WebRTC to Janus fails)
  • Port 8081 TCP — 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

note

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.

Highlighted items will need to be modified
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
note

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_proxies

Do 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:

FieldValue
Servertalk.<YourDomain>.com:3478
Secretyour TURN_SECRET value (matches static-auth-secret in turnserver.conf)
ProtocolUDP and TCP

High-Performance Backend (Signaling)

Click Add High-performance backend server and fill in:

FieldValue
High-performance backend URLhttps://talk.<YourDomain>.com
Shared secretyour 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)

Beta Feature

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

SymptomLikely Cause
Calls drop with 3+ participantsSignaling server not reachable — check Nginx WebSocket config
"TURN server is not accessible" warningPort 3478 blocked on firewall — open UDP and TCP
Signaling config shows red in adminWrong SIGNALING_SECRET or reverse proxy not forwarding WebSocket Upgrade header
"Error: Cannot connect to server" in adminNextcloud blocking local outbound connection — run the allow_local_remote_servers occ command above
coturn logs show ERROR: cannot bind to IPcoturn trying to bind to a public IP not present on the interface — remove listening-ip if set
Signaling container exits immediatelyMalformed server.conf — check indentation and that all CHANGEME_ values are replaced
lookup nats on ...: no such host in signaling logsserver.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 byteshashkey/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 adminBackend 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 immediatelyCheck docker logs janus — may need the SHM_SIZE environment variable on low-memory hosts
ICE candidates use wrong public IPHardcode 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 startupThe 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 logsFederated 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.

Prerequisites
  • 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 8000 reachable 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
/opt/docker/nextcloud/recording/server.conf — highlighted items must be changed
[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
info
  • CHANGEME_RECORDING_SECRET — a new secret you choose freely; you will enter the same value in the Nextcloud Talk admin UI
  • CHANGEME_INTERNAL_SECRET — must match internalsecret in your signaling server's server.conf ([clients] section)
Generating a secret
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
docker-compose.yaml (add to services:)
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

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
Updating

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

  1. In Nextcloud, go to Administration SettingsTalk
  2. Scroll to Recording servers
  3. Click Add a new recording server and fill in:
FieldValue
Recording backend URLhttp://nextcloud-talk-recording:8000
Shared secretyour CHANGEME_RECORDING_SECRET value
  1. Click the checkmark to save, then click Test connection — you should see a green tick
info

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:

  1. Open the call in Nextcloud Talk
  2. Click the ⋮ More options button in the call toolbar
  3. Select Start recording
  4. When finished, click Stop recording — the file is saved to Talk Recordings/ in the moderator's Files
tip

The recording file appears as a notification in Talk once processing is complete. The moderator can share it directly to the conversation from there.

Buy me a beer

Changelog

fix(talk-recording): consolidate duplicate [signaling] sections in server.conf example
fix(talk-recording): build from source instead of non-existent ghcr.io image
docs(talk): add recording backend setup section
docs(talk): update warning syntax for Talk federation beta feature
docs(talk): update federation section to reflect beta status and no video call support


💬 Discord Community Chat

Join the conversation! Comments here sync with our Discord community.

💬 Recent Comments

Loading comments...