Deploying the Nextcloud Talk High-Performance Backend with Docker

| A high-performance call backend with a decidedly low-resolution sunset. |
Table of Contents 📑
- Changelog
- 1. Prerequisites
- 2. Directory Structure and Download
- 3. Configuration Files
- 4. Launch the Stack
- 5. Configure Nextcloud
- 6. Talk Recording
- 7. Troubleshooting & Monitoring
- 8. Maintenance and Updates
- Conclusion
- Sources
This guide will walk you through deploying the official Nextcloud Talk High-Performance Backend (HPBE). This backend, which includes a signaling server (Spreed), a STUN/TURN server (Coturn), and a WebRTC MCU (Janus), significantly improves the performance and reliability of video calls, especially for multiple participants.
This setup is designed to integrate seamlessly with an existing Traefik v3 reverse proxy, making it a powerful addition to your self-hosted infrastructure.
Customer-facing Talk and recording endpoints are left outside CrowdSec HTTP middleware in the examples below. An IP decision learned from another service can otherwise interrupt legitimate calls or server callbacks. If you deliberately opt in, append crowdsec-bouncer@file to the relevant router and test authentication, WebSockets and recording callbacks with real clients. The TURN ports use their own authentication and enforcement path.
Changelog
| Date | Change |
|---|---|
| 2026-10-11 | Fixed secure builds, TURN renewal/NAT checks and customer middleware defaults. |
| 2026-09-30 | Maintenance: Secured secrets and preserved all custom files during updates. |
| 2026-07-01 | Recording Update: Added migration notes, pinned helper versions, Janus IP checks, TURN cert refresh, and Talk recording guidance. |
| 2025-09-21 | Initial Version: Guide created, focusing on Docker Compose deployment and Traefik v3 integration. |
A patched Go compiler does not update the application’s module dependencies. For a source build, also review the pinned dependency versions and scan the resulting executable with govulncheck -mode=binary /path/to/binary. A targeted library update must be tested with the same application source version before deployment. Preserve the original source commit, dependency diff, build command and rollback binary or image. Binary findings can overestimate runtime reachability; distinguish module metadata from matched symbols and qualify remaining findings. Go vulnerability tooling, govulncheck binary mode.
1. Prerequisites
This guide is part of a series and builds upon a secure Docker environment. Before you begin, you must have a fully functional Traefik v3 and CrowdSec stack.
| ⚠️ HARD REQUIREMENT |
The following steps will not work correctly without the Traefik stack running as described in the prerequisite guide.
|
You will also need:
- Docker and Docker Compose installed on your server.
- A dedicated subdomain for the signaling server (e.g.,
signaling.your-domain.com) pointed to your server’s IP address. sudoor root access.- The
gitutility installed (sudo apt install git). - Firewall ports
80,443,3478/tcp,3478/udpopen. The ports3478are required by the Coturn (TURN) server.
1.1. Firewall Configuration (UFW Example)
If you are using ufw (Uncomplicated Firewall), you can open the required ports with the following commands:
sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
# Example with media range 20000–20100 (choose any suitable range)
sudo ufw allow 20000:20100/udp # match your Janus media range
# Only if you enable TURNS (section 3.4):
sudo ufw allow 5349/tcp # required for TURNS
# sudo ufw allow 5349/udp # optional; enable only if you need UDP on 5349Note: Choose a media port range that is allowed by your provider/network. The exact same range must be configured in both docker-compose.yml (Janus service ports) and janus/janus.jcfg (media min/max).
| ℹ️ SECURITY NOTE & CROWDSEC INTEGRATION |
The TURN ports ( Collecting Coturn logs is not sufficient to detect attacks. This guide does not install a Coturn-specific CrowdSec parser or scenario, and a Keep TURN authentication, quotas and relay restrictions enabled independently. If you add Docker log acquisition, avoid granting unrestricted Docker-socket access to the log collector: a read-only socket mount does not make the Docker API read-only. Use a restricted API proxy or a dedicated rotating log directory. The standard HTTP middleware does not protect TURN ports, and a firewall bouncer installed only in the host’s INPUT chain does not automatically cover Docker’s forwarded traffic. Verify the actual enforcement path before adding rules that might interrupt calls. See Docker firewall and packet-filtering behavior. If you use the CrowdSec Firewall Bouncer (iptables/nftables), ensure the Janus media UDP range (e.g., |
1.2. Resource Planning
The High-Performance Backend, especially the Janus MCU, can be resource-intensive during video calls. For a small group of users (e.g., 3-5 concurrent participants in a call), plan for at least 1-2 dedicated CPU cores and 2-4 GB of RAM for the HPBE stack. For larger deployments, monitor your resource usage and scale accordingly. To keep an eye on performance, regularly check resource usage with tools like htop, docker stats, or a more comprehensive monitoring stack like Prometheus and Grafana.
2. Directory Structure and Download
First, we will clone the official repository, which contains all the necessary Docker configurations.
# Navigate to your main containers directory
cd /opt/containers/
# Clone the repository
sudo git clone https://github.com/strukturag/nextcloud-spreed-signaling.git nextcloud-hpbe
# Enter the new directory
cd nextcloud-hpbeBefore creating live configuration, extend .dockerignore so source builds do not copy credentials or certificates into an intermediate image layer:
sudo tee -a .dockerignore > /dev/null << 'EOF'
/server.conf
/gnatsd.conf
/janus/*.jcfg
/certs/
/.env
/*.env
/docker-compose*.yml
EOFThe signaling Dockerfile copies the build context. A read-only runtime mount does not protect a secret that was already copied during the build. Keep rollback archives and exported database files outside this build context. See Docker build-context exclusions.
3. Configuration Files
This setup uses two main configuration files: docker-compose.yml for defining the services and server.conf for the signaling server itself. All configuration values will be hardcoded directly into these files for simplicity and clarity.
3.1. Docker Compose File (docker-compose.yml)
Create the docker-compose.yml file. This version uses a hardcoded configuration and is optimized for Traefik v3.
sudo tee docker-compose.yml > /dev/null << 'EOF'
services:
spreedbackend:
build:
context: .
dockerfile: docker/server/Dockerfile
platforms:
- "linux/amd64"
container_name: spreedbackend
depends_on:
- nats
- janus
- coturn
volumes:
- ./server.conf:/config/server.conf:ro
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# Router for certificate acquisition (Host-only rule)
- "traefik.http.routers.hpbe-cert.rule=Host(`signaling.your-domain.com`)"
- "traefik.http.routers.hpbe-cert.entrypoints=web,websecure"
- "traefik.http.routers.hpbe-cert.tls.certresolver=tls_resolver"
- "traefik.http.routers.hpbe-cert.priority=1"
# Router for the actual service (Host + Path rule)
- "traefik.http.routers.hpbe.rule=Host(`signaling.your-domain.com`) && PathPrefix(`/standalone-signaling`)"
- "traefik.http.routers.hpbe.entrypoints=websecure"
- "traefik.http.routers.hpbe.tls=true"
- "traefik.http.routers.hpbe.priority=100"
# Middlewares: Set X-Forwarded-Proto and strip the path prefix
- "traefik.http.middlewares.hpbe-headers.headers.customRequestHeaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.hpbe-strip.stripprefix.prefixes=/standalone-signaling"
- "traefik.http.routers.hpbe.middlewares=hpbe-headers@docker,hpbe-strip@docker"
# Internal service port
- "traefik.http.services.hpbe.loadbalancer.server.port=8080"
nats:
image: nats:2.14.3
container_name: nats
command: ["-c", "/config/gnatsd.conf"]
volumes:
- type: bind
source: ./gnatsd.conf
target: /config/gnatsd.conf
read_only: true
restart: unless-stopped
networks:
- proxy
janus:
# Build Janus from source using the provided Dockerfile
build:
context: docker/janus
args:
JANUS_VERSION: "1.4.2"
container_name: janus
command: ["janus", "--full-trickle"]
restart: unless-stopped
networks:
- proxy
ports:
- "20000-20100:20000-20100/udp"
# Optional only if you enabled ice_tcp=true (see section 3.3)
# - "20000-20100:20000-20100/tcp"
volumes:
- ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
coturn:
image: coturn/coturn:4.14.0-r0
container_name: coturn
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
command:
# Replace this documentation address with the host public IPv4.
- "--external-ip"
- "203.0.113.10"
- "--realm"
- "signaling.your-domain.com"
- "--static-auth-secret"
- "PASTE-A-STRONG-RANDOM-32-CHAR-HEX-SECRET-HERE"
- "--no-stdout-log"
- "--log-file"
- "stdout"
- "--stale-nonce=600"
- "--use-auth-secret"
# Using shared-secret auth only; lt-cred-mech omitted to avoid mixed-auth warning
- "--fingerprint"
- "--no-software-attribute"
- "--no-multicast-peers"
- "--min-port"
- "30000"
- "--max-port"
- "30100"
- "--cert"
- "/certs/fullchain.pem"
- "--pkey"
- "/certs/privkey.pem"
- "--tls-listening-port"
- "5349"
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "5349:5349/udp"
- "30000-30100:30000-30100/udp"
volumes:
- ./certs:/certs:ro
restart: unless-stopped
networks:
proxy:
external: true
EOFNote: Before launching, create janus/janus.jcfg as described in section 3.3 (set nat_1_1_mapping and the min_port/max_port media port range).
Note: On hosts with a direct public IP, you typically do not need to set Coturn’s --listening-ip, --relay-ip, or --external-ip. Relying on defaults avoids common binding errors (e.g., “Cannot assign requested address”, errno=99). Only set --external-ip PUBLIC_IP/PRIVATE_HOST_IP (and optionally --listening-ip/--relay-ip to the private host IP) if your host is behind NAT.
| ⚠️ SERVER MIGRATION |
When moving this stack to a new server, update Janus’ |
3.2. Signaling Server Config (server.conf)
This file configures the core logic of the HPBE. Create server.conf, paste the template below, and replace the placeholder values with your own secrets and URLs.
sudo tee server.conf > /dev/null << 'EOF'
[http]
listen = 0.0.0.0:8080
[app]
debug = false
[sessions]
# Use 'openssl rand -base64 16' to generate these
hashkey = PASTE-A-RANDOM-BASE64-KEY-HERE
blockkey = PASTE-A-RANDOM-BASE64-KEY-HERE
[backend]
backends = backend-1 #, backend-2
allowall = false
timeout = 10
connectionsperhost = 8
[backend-1]
url = https://cloud.your-domain.com
# Use 'openssl rand -hex 16' to generate this
secret = PASTE-A-RANDOM-HEX-SECRET-HERE
# To add a second backend, add it to the 'backends' list and create a new section
# [backend-2]
# url = https://another-cloud.com
# secret = PASTE-ANOTHER-RANDOM-HEX-SECRET-HERE
[nats]
url = nats://nats:4222
[mcu]
type = janus
url = ws://janus:8188
[turn]
# Use 'openssl rand -base64 16' to generate this
apikey = PASTE-A-RANDOM-BASE64-KEY-HERE
# This secret MUST be identical to the '--static-auth-secret' in docker-compose.yml
secret = PASTE-THE-SAME-STRONG-SECRET-AS-IN-DOCKER-COMPOSE
servers = turn:signaling.your-domain.com:3478?transport=udp,turn:signaling.your-domain.com:3478?transport=tcp
EOF| ⚠️ SYNCHRONIZE TURN SECRET |
The |
The signaling container runs as UID/GID 850, as defined in the upstream Dockerfile and entrypoint. Allow that group to read the configuration while keeping its secrets private from other local users:
sudo chown root:850 server.conf
sudo chmod 640 server.conf
# docker-compose.yml also contains the TURN shared secret.
# The guide runs Compose with sudo, so root-only access is sufficient.
sudo chown root:root docker-compose.yml
sudo chmod 600 docker-compose.ymlKeep the read-only configuration mount from section 3.1. If you change the image or enable Docker user namespace remapping, verify the effective UID/GID and its host mapping before applying these ownership values.
3.2.1 Replace placeholders and generate secrets
Before launching, perform these steps so your setup works on your domain and with strong secrets.
- Replace the domain placeholder
We use signaling.your-domain.com as a placeholder. Replace it with your real domain in both files:
cd /opt/containers/nextcloud-hpbe
sudo sed -i 's/signaling\.your-domain\.com/signaling.example.com/g' docker-compose.yml server.confReplace signaling.example.com with your actual TURN/signaling domain.
- Generate the required secrets with openssl
- Sessions keys (base64) for
server.conf[sessions]:
openssl rand -base64 16 # paste as sessions.hashkey
openssl rand -base64 16 # paste as sessions.blockkey- Backend shared secret (hex) for each Nextcloud in
server.conf[backend-*]:
openssl rand -hex 16 # paste as backend-1.secret (and for backend-2, backend-3, ...)- TURN API key (base64) for
server.conf[turn] apikey:
openssl rand -base64 16 # paste as turn.apikey- TURN static secret (hex) used in BOTH places:
server.conf[turn] secret- docker-compose
coturncommand--static-auth-secret
openssl rand -hex 16 # paste into both places, values must be identical- Open firewall ports (host level)
sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
sudo ufw allow 20000:20100/udp # Janus media range
sudo ufw allow 30000:30100/udp # Coturn relay range
# The main docker-compose includes TURNS by default:
sudo ufw allow 5349/tcp # required for TURNS
# sudo ufw allow 5349/udp # optional; some clients might use it3.2.2 Example: Multiple Nextcloud backends
If you operate more than one Nextcloud that should use the same signaling backend, model your [backend] section like this. Replace the example domains and secrets with your own.
[backend]
backends = backend-1, backend-2, backend-3
allowall = false
timeout = 10
connectionsperhost = 8
[backend-1]
url = https://cloud1.example.com
secret = PASTE-A-RANDOM-HEX-SECRET-HERE
[backend-2]
url = https://cloud2.example.com
secret = PASTE-A-RANDOM-HEX-SECRET-HERE
[backend-3]
url = https://cloud3.example.com
secret = PASTE-A-RANDOM-HEX-SECRET-HERENote: The secret for each backend must match the “Shared secret” you configure in that specific Nextcloud under Admin -> Talk.
3.2.3 Optional: Bind the HTTP listener to the service name
By default this guide uses listen = 0.0.0.0:8080 in [http], which is simple and works well behind Traefik. If you prefer to bind strictly to the Docker service name, you can set:
[http]
listen = spreedbackend:8080Both are valid in a single Docker network; choose the variant that fits your operational preference.
3.3. Janus NAT and Media Ports (required for working audio/video)
WebRTC media flows (DTLS/SRTP) are carried on a dynamic UDP port range on the Janus SFU. If these ports are not reachable from the internet, calls will not establish and you will see errors like “publisher not sending yet” or repeated “requestoffer: context deadline exceeded” in the signaling logs.
Do the following:
- Create a minimal Janus config that sets public IP mapping and a fixed media port range
Create ./janus/janus.jcfg with the following content. Replace PUBLIC.IP.OR.DNS with your server’s public address or a DNS name that resolves to it (no CDN/Orange-Cloud in front):
# Minimal but complete Janus config for operation behind Docker
# with a fixed UDP port range and correct 1:1 NAT mapping.
general: {
configs_folder = "/usr/local/etc/janus"
# Keep logging at "info" (default), set "debug = true" if needed
}
at: {
# Important: public IP or DNS of the host that clients should see
nat_1_1_mapping = "signaling.your-domain.com"
# SRFLX candidates (optional, but doesn't hurt)
stun_server = "signaling.your-domain.com"
stun_port = 3478
# ICE Lite is fine for SFU operation and reduces complexity
ice_lite = true
}
media: {
# Fixed, small port range: must match docker-compose (ports:)
min_port = 20000
max_port = 20100
# Prefer UDP (TCP disabled as it's often problematic/unnecessary)
ice_tcp = false
# (Optional) Enforce RTCP-MUX – common default, can help
rtcp_mux = true
}
# Websockets (HPBE communicates internally via ws://janus:8188)
# Defaults are fine, no extra port publishing needed as it's in the same Docker network.
websockets: {
ws = true
ws_port = 8188
ws_interface = "0.0.0.0"
}
# REST & Admin disabled by default – not needed
admin: {
admin_http = false
admin_secret = "changeit"
}- Ensure your main docker-compose includes the Janus media ports and config mount
In section 3.1, the janus service already contains the required ports and volumes lines. Verify that your configuration matches:
ports: "20000-20100:20000-20100/udp"(optional TCP only ifice_tcp=true)volumes: ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
- Open the firewall for the media range (host level)
sudo ufw allow 20000:20100/udp
# Adjust to the exact range you configured in janus.jcfg and docker-compose
# Only if you enabled ice_tcp=true
# sudo ufw allow 20000:20100/tcp| ℹ️ CAPACITY PLANNING FOR MEDIA PORTS |
With RTCP-MUX/BUNDLE (Janus default), each active PeerConnection typically uses about one UDP port on the server.
|
- Apply the changes
- If you have not launched the stack yet, skip this step. The changes will take effect when you start the stack in section 4.
- If Janus is already running, apply the updated config now:
sudo docker compose up -d --force-recreate janus- Verify during a call attempt
- In Firefox
about:webrtcor Chromechrome://webrtc-internalscheck that the remote ICE candidates from Janus show your public IP with ports in20000–20100and that the ICE state becomesconnected/completed. - On the server, you should see traffic on those ports while a call is setting up:
sudo tcpdump -ni any udp port 3478
sudo tcpdump -ni any udp portrange 20000-20100If you exclusively rely on shared-secret auth for TURN, you can still keep section 3.4 (TURNS) as-is; TURN helps with client NAT traversal but cannot replace opening Janus’ own media ports.
3.4. Enable TURN over TLS (5349)
Enabling turns: adds TLS encryption to TURN traffic on port 5349. This often helps in restrictive networks and hides credentials from passive observers.
Steps:
- Create the certs directory and export PEMs from Traefik’s ACME store
Since this guide builds on the prerequisite Traefik v3 and CrowdSec tutorial, your certificates are stored in Traefik’s ACME database at /opt/containers/traefik-stack/traefik/certs/acme.json. Export the certificate and key for your TURN domain into ./certs.
cd /opt/containers/nextcloud-hpbe
# Domain used for TURN/TURNS (anonymized placeholder)
TURN_DOMAIN="signaling.your-domain.com"
ACME="/opt/containers/traefik-stack/traefik/certs/acme.json"
# Ensure tools are available
sudo apt-get update && sudo apt-get install -y jq
# Create output directory
mkdir -p ./certs
# (Directory permissions are set in step 1.1 below)
# Extract certificate and key for the TURN domain from acme.json
sudo jq -r --arg d "$TURN_DOMAIN" '
.. | objects
| select(has("domain") and (.domain.main==$d or ((.domain.sans // []) | index($d))))
| .certificate
' "$ACME" | base64 -d | sudo tee ./certs/fullchain.pem > /dev/null
sudo jq -r --arg d "$TURN_DOMAIN" '
.. | objects
| select(has("domain") and (.domain.main==$d or ((.domain.sans // []) | index($d))))
| .key
' "$ACME" | base64 -d | sudo tee ./certs/privkey.pem > /dev/nullNote: The certificate must match the TURN realm/domain you advertise (e.g., signaling.your-domain.com).
With Docker bridge networking, Coturn must also advertise the reachable public relay address. Set --external-ip to the host’s public IPv4 and publish the same relay-port range one-to-one. Without this setting, a successful authenticated allocation can still return a private Docker address that external clients cannot reach. For deployments with multiple relay interfaces or additional NAT, review Coturn’s public/private address-mapping syntax. Verify the returned relay candidate as well as TLS; see the official Coturn configuration.
1.1 Set Certificate and Directory Permissions
Coturn does not run as root inside the container, but as the nobody:nogroup user. Without the correct permissions on both the directory and the files, Coturn cannot read the certificates and the turns: connection will fail (often shown as red in Nextcloud Talk).
# In your project directory (e.g., /opt/containers/nextcloud-hpbe)
# Detect the group ID used by Coturn inside the container (commonly 65534 for 'nogroup')
GID=$(docker compose exec -T coturn sh -c 'id -g' | tr -d '\r')
# Ensure the certs directory is accessible to that group
sudo chgrp "$GID" ./certs
sudo chmod 750 ./certs
# Set group ownership and restrictive permissions on the files
sudo chgrp "$GID" ./certs/privkey.pem ./certs/fullchain.pem
sudo chmod 640 ./certs/privkey.pem
sudo chmod 644 ./certs/fullchain.pem
# Restart Coturn to load the updated certificates
sudo docker compose up -d --force-recreate coturnNote: If your image uses the default nobody:nogroup, you may alternatively use sudo chgrp nogroup ... instead of the detected GID.
Traefik renews its ACME certificate, but Coturn does not automatically receive that renewal: these PEM files are separate copies. Schedule a root-owned export task, for example with a systemd timer, that validates the matching domain, expiry and certificate/key pair before atomically replacing both files. Reapply the directory/group permissions above on every export and restart Coturn only when the certificate changes. Retain the previous files if extraction or validation fails. Never overwrite a working certificate with an empty export.
After each export, verify the actual TURN TLS listener, rather than only the HTTPS reverse proxy:
openssl s_client -connect signaling.your-domain.com:5349 \
-servername signaling.your-domain.com \
-verify_hostname signaling.your-domain.com -verify_return_error </dev/nullCheck certificate verification and validity, then perform an authenticated TURN relay test. A successful TLS handshake proves the certificate/listener; it does not prove that media can cross the advertised relay ports. See Coturn configuration and Traefik ACME storage.
- TLS is already configured in the main
docker-compose.ymlin this guide. After creating/exporting the certificates in step 1, simply recreate Coturn so it picks up the TLS files.
Note: If you exclusively use shared-secret auth (--use-auth-secret with --static-auth-secret), you can omit --lt-cred-mech to avoid a warning.
- Update your
server.confserverslist to include theturns:endpoint:
[turn]
servers = turn:signaling.your-domain.com:3478?transport=udp,turn:signaling.your-domain.com:3478?transport=tcp,turns:signaling.your-domain.com:5349?transport=tcp- Open the firewall for port
5349:
sudo ufw allow 5349/tcp # required for TURNS
# sudo ufw allow 5349/udp # optional; enable only if you need UDP on 53494. Launch the Stack
With the configuration complete, you can now build and start the services.
# From within the /opt/containers/nextcloud-hpbe directory
sudo docker compose up --build -dThe --build flag is important because it builds the Janus and the spreedbackend images from their Dockerfiles. The Janus build, in particular, can take several minutes.
The Janus build argument explicitly selects 1.4.2, which includes fixes for recording paths, protocol allowlists and plugin validation. An old Dockerfile default can otherwise rebuild a vulnerable release even when the signaling repository is current. Review the Janus security changelog before each upgrade.
The signaling image also embeds the Go standard library used at build time. Update the builder to a supported patched Go release and rebuild when Go publishes security fixes; updating only the Alpine runtime does not replace that embedded code. Check the resulting nextcloud-spreed-signaling -version output against the Go release history. On a shared host, bound the build worker’s memory and CPU and reduce compiler parallelism; a limit on the Docker CLI alone does not constrain a separate Docker build daemon.
You can monitor the logs to ensure everything starts correctly:
sudo docker compose logs -f4.1 Verify TURN/TURNS endpoints
If nc is not installed, install it first:
sudo apt-get update && sudo apt-get install -y netcat-openbsdRun these quick checks from a client or your server to verify connectivity:
# 1) Check TCP reachability (expect "succeeded")
nc -vz signaling.your-domain.com 3478
nc -vz signaling.your-domain.com 5349 # only if TURNS enabled
# 2) Verify TLS on 5349 (should show certificate details; only if TURNS is enabled)
openssl s_client -connect signaling.your-domain.com:5349 -servername signaling.your-domain.com -brief < /dev/null
# 3) Check Coturn runtime logs
docker compose logs -f coturnIf you have turnutils_uclient available (from the coturn package), you can perform an end-to-end TURN allocation test as an advanced check.
5. Configure Nextcloud
The final step is to tell your Nextcloud instance to use the new High-Performance Backend.
- Log in to Nextcloud as an administrator.
- Navigate to Administration Settings -> Talk.
- Scroll down to the “Signaling server” section.
- Check “Enable custom signaling server”.
- In the “Signaling server URL” field, enter the full path to your HPBE:
https://signaling.your-domain.com/standalone-signaling. - In the “Shared secret” field, paste the corresponding shared secret for this specific Nextcloud instance (e.g., the value of
NEXTCLOUD_1_SHARED_SECRETfor your first instance). - Click “Save changes”.
Nextcloud will verify the connection. If everything is correct, you’re all set!
Clarifying Secret Mappings
To ensure all components communicate securely, it’s crucial to map the secrets correctly. Here is a quick reference:
server.conf Section & Key | Maps to… | Purpose |
|---|---|---|
[backend] -> secret | Nextcloud Admin -> Talk -> Shared secret | Authenticates Nextcloud with the signaling server. |
[turn] -> apikey | Used by the signaling server to generate time-limited TURN credentials for clients. Janus does not use this key in this setup. | Allows signaling server to get TURN credentials. |
[turn] -> secret | coturn service -> --static-auth-secret command argument | Authenticates TURN users (generated by signaling). |
6. Talk Recording
Nextcloud Talk recording is a separate service. It is not provided automatically just because HPB, Janus, and Coturn are running.
Create a separate stack, for example /opt/containers/nextcloud-talk-recording/docker-compose.yml:
services:
nextcloud-talk-recording:
image: ghcr.io/nextcloud-releases/aio-talk-recording:latest
container_name: nextcloud-talk-recording
restart: unless-stopped
shm_size: "4gb"
environment:
- TZ=Europe/Berlin
- NC_DOMAIN=cloud.your-domain.com
- HPB_DOMAIN=signaling.your-domain.com
- RECORDING_SECRET=PASTE-A-STRONG-RECORDING-SECRET-HERE
- INTERNAL_SECRET=PASTE-A-STRONG-INTERNAL-SECRET-HERE
volumes:
- ./recording.conf:/conf/recording.conf
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.nextcloud-talk-recording.rule=Host(`recording.your-domain.com`)"
- "traefik.http.routers.nextcloud-talk-recording.entrypoints=websecure"
- "traefik.http.routers.nextcloud-talk-recording.tls.certresolver=tls_resolver"
- "traefik.http.routers.nextcloud-talk-recording.middlewares=security-headers@file"
- "traefik.http.services.nextcloud-talk-recording.loadbalancer.server.port=1234"
networks:
- proxy
networks:
proxy:
external: trueDo not mount recording.conf read-only. Current recording images generate or update /conf/recording.conf from environment variables at startup. A read-only bind mount can cause startup warnings or broken configuration.
If DNS still points signaling.your-domain.com to an old server during a migration, add a temporary extra_hosts entry so the recording container reaches the new HPB stack. Remove it after DNS cutover:
extra_hosts:
- "signaling.your-domain.com:YOUR_NEW_SERVER_IP"In Nextcloud’s Talk admin settings, configure the recording server URL and recording secret to match this stack.
| ℹ️ RECORDING VALIDATION |
A |
7. Troubleshooting & Monitoring
If you encounter issues, here are a few steps to diagnose the problem:
Verify the Signaling Server is Reachable
You should get a
200 OKresponse from the/welcomeendpoint. This confirms that Traefik is routing requests correctly.curl -i https://signaling.your-domain.com/standalone-signaling/api/v1/welcomeCheck the Container Logs
The logs are the best source for identifying errors. Pay close attention to messages about secrets, WebSocket connections, or backend timeouts.
# From within the /opt/containers/nextcloud-hpbe directory sudo docker compose logs -fCommon Errors & Fixes
ERROR: the sessions block key must be...orhash key should be...: This error occurs when the keys inserver.confare missing or have the wrong format. Useopenssl rand -base64 16to generate new keys and paste them into the[sessions]section.- TURN Secret Mismatch: If calls fail to connect, verify that the
secretin the[turn]section ofserver.confis exactly the same as the--static-auth-secretin thecoturncommand indocker-compose.yml. - “failed to establish signaling connection”: This is a classic error.
- Check that the URL in Nextcloud is exactly
https://.../standalone-signaling. - Ensure your Traefik labels are correct (especially the
PathPrefixandstripPrefixrules). - Verify that the
Shared secretin Nextcloud matches the one inserver.conf. - Media range not reachable: If ICE repeatedly fails or you see “publisher not sending yet”, ensure your chosen Janus media port range is open end-to-end (firewall/CrowdSec), and that the exact same range is configured in both
docker-compose.yml(Janusports) andjanus/janus.jcfg.
- Check that the URL in Nextcloud is exactly
- Calls work for 2 people but fail with 3+: This often points to a problem with Janus (the MCU) or Coturn (the TURN server). Check their logs specifically.
- No video/audio from external networks: This is a typical TURN server issue. Ensure ports
3478(TCP/UDP) are open on your firewall and correctly forwarded to the Coturn container. turn:works, butturns:fails (is red in Nextcloud): This is almost always a certificate permission issue. Coturn cannot read the TLS certificate or key. Verify that Coturn can access the files withdocker compose exec coturn ls -l /certs. If you see permission errors, re-run the permission-setting steps in section 3.4. You can also test externally with:openssl s_client -connect signal.example.com:5349 -servername signal.example.com.
7.1 Diagnose ICE/RTP issues quickly
- Check remote candidates in the browser: Use
about:webrtc(Firefox) orchrome://webrtc-internals(Chrome). You should see server-reflexive (srflx) and relayed (relay) candidates, and remote candidates from Janus on the public IP in the configured media range. - Look for ICE state:
iceConnectionStateshould reachconnected/completed. If it stays inchecking/failed, open ports or NAT/public IP mapping are missing. - Server-side packet capture: While attempting a call, run
sudo tcpdump -ni any udp portrange 20000-20100to confirm media packets hit the host. - TURN sanity: Use the Trickle ICE demo with your TURN URIs to confirm you get
relaycandidates; verify your Coturnrealmand static secret match the values inserver.confanddocker-compose.yml.
8. Maintenance and Updates
Prepare the new source in a separate directory, preserve all custom files, and build the images while the existing services are still running. Schedule the final replacement outside active calls: recreating the signaling or Janus container interrupts ongoing calls.
Step 1: Back Up the Current Installation
Run the following steps in the same Bash session. The paths assume the layout used in this guide. Keep the existing Compose project name throughout the update; the default for this directory is nextcloud-hpbe. If your installation uses another project name, replace it in the commands below.
set -euo pipefail
cd /opt/containers/
HPB_UPDATE_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
HPB_STAGE="/opt/containers/nextcloud-hpbe-update-${HPB_UPDATE_STAMP}"
HPB_BACKUP="/opt/containers/nextcloud-hpbe-backup-${HPB_UPDATE_STAMP}"
sudo install -d -m 700 "$HPB_BACKUP"
# The archive contains secrets and private certificate keys.
sudo install -m 600 /dev/null "$HPB_BACKUP/installation.tar.gz"
sudo tar -czpf "$HPB_BACKUP/installation.tar.gz" nextcloud-hpbe
sudo gzip -t "$HPB_BACKUP/installation.tar.gz"
sudo sha256sum "$HPB_BACKUP/installation.tar.gz" | sudo tee "$HPB_BACKUP/SHA256SUMS" > /dev/null
sudo sha256sum -c "$HPB_BACKUP/SHA256SUMS"
# Retain the current locally built images under separate rollback tags.
for HPB_SERVICE in spreedbackend janus; do
HPB_IMAGE_ID=$(sudo docker inspect --format '{{.Image}}' "$HPB_SERVICE")
sudo docker image tag "$HPB_IMAGE_ID" "nextcloud-hpbe-${HPB_SERVICE}:rollback-${HPB_UPDATE_STAMP}"
doneStep 2: Clone the New Version and Preserve Configuration
Clone the official repository into the staging directory. Restore the Compose file, signaling and NATS configuration, Janus configuration, and certificates. Include .env and override files if your installation uses them. cp -a preserves existing ownership and permissions, including those of certificate keys.
sudo git clone https://github.com/strukturag/nextcloud-spreed-signaling.git "$HPB_STAGE"
for HPB_FILE in docker-compose.yml server.conf gnatsd.conf janus certs .env \
docker-compose.override.yml docker-compose.override.yaml compose.override.yml compose.override.yaml; do
if sudo test -e "/opt/containers/nextcloud-hpbe/$HPB_FILE"; then
sudo cp -a "/opt/containers/nextcloud-hpbe/$HPB_FILE" "$HPB_STAGE/"
fi
done
sudo chown root:850 "$HPB_STAGE/server.conf"
sudo chmod 640 "$HPB_STAGE/server.conf"
sudo chown root:root "$HPB_STAGE/docker-compose.yml"
sudo chmod 600 "$HPB_STAGE/docker-compose.yml"
if sudo test -f "$HPB_STAGE/.env"; then
sudo chown root:root "$HPB_STAGE/.env"
sudo chmod 600 "$HPB_STAGE/.env"
fiIf your Compose file references additional files or directories, copy those too. The upstream repository does not supply your local Janus configuration or TLS certificates. Check that every bind mount exists before deployment. Copying only docker-compose.yml and server.conf can lose the media, NAT, NATS, or TURN/TURNS settings needed for working calls.
Step 3: Build Before Replacing Services
Review upstream changes and the preserved configuration, then validate and build from the staging directory. Using the existing project name prevents Compose from treating the staging directory as a second installation. config --quiet checks the configuration without printing expanded secrets. See the Docker documentation for configuration validation and image builds.
cd "$HPB_STAGE"
sudo docker compose -p nextcloud-hpbe config --quiet
sudo docker compose -p nextcloud-hpbe build --pullThe build updates the services built from source. Pinned NATS and Coturn image tags stay at their configured versions; review those releases separately before changing the tags. Complete this step successfully before replacing the installation directory.
Step 4: Replace the Installation and Verify Calls
Move the old directory into the protected backup and put the prepared source at the original path. Start Compose from that path so relative bind mounts point to the correct files. up -d recreates services when their image or configuration changed; see the Compose documentation.
cd /opt/containers/
sudo mv nextcloud-hpbe "$HPB_BACKUP/installation"
sudo mv "$HPB_STAGE" nextcloud-hpbe
cd nextcloud-hpbe
sudo docker compose -p nextcloud-hpbe up -d
sudo docker compose -p nextcloud-hpbe ps
# Replace this hostname with your public signaling hostname.
curl --fail --silent --show-error https://signaling.your-domain.com/standalone-signaling/api/v1/welcomeConfirm the reported signaling version, check the connection in Nextcloud’s Talk admin settings, and make a real call with several participants, including one on a mobile network. Verify TURN/TURNS and recording if used. Keep the backup and rollback image tags until these checks pass; avoid pruning images during the update.
If you need to roll back, restore the old directory to /opt/containers/nextcloud-hpbe and use a Compose override that sets spreedbackend.image and janus.image to the saved nextcloud-hpbe-<service>:rollback-<timestamp> tags. Start with up -d --no-build so those retained images are used. Keep NATS/Coturn configuration and image versions consistent with the restored installation.
Migration Checklist
When moving an existing HPB stack to a new server:
- Keep the public signaling hostname stable if possible.
- Open the TURN, TURNS, Janus media, and Coturn relay ports before DNS cutover.
- Update
janus/janus.jcfgsonat_1_1_mappingpoints to the new public IP or DNS name. - Reissue or export fresh certificates for the TURN/TURNS hostname after DNS points to the new server.
- Restart
coturnafter replacing certificate files. - If recording is used, make sure the recording container resolves the HPB hostname to the new server.
- Test with a real Talk room, including a recording test.
If your TURN certificate is exported from Traefik’s acme.json, repeat that export after the new server has successfully obtained the certificate:
cd /opt/containers/nextcloud-hpbe
sudo docker compose up -d --force-recreate coturnConclusion
Congratulations! You have successfully deployed a Nextcloud Talk High-Performance Backend. Your users will now experience more stable and performant video calls, especially in group settings. This powerful, containerized setup integrates perfectly with a modern Traefik proxy, providing a scalable and secure solution for your communication needs.
Sources
- Janus security changelog
- Go release history
- Docker build-context exclusions
- Official signaling server repository
- Signaling container Dockerfile and entrypoint
- Docker Compose project names
- Docker Compose configuration validation, build, and up





