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

DateChange
2026-10-11Fixed secure builds, TURN renewal/NAT checks and customer middleware defaults.
2026-09-30Maintenance: Secured secrets and preserved all custom files during updates.
2026-07-01Recording Update: Added migration notes, pinned helper versions, Janus IP checks, TURN cert refresh, and Talk recording guidance.
2025-09-21Initial 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.
  • sudo or root access.
  • The git utility installed (sudo apt install git).
  • Firewall ports 80, 443, 3478/tcp, 3478/udp open. The ports 3478 are 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 5349

Note: 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 (3478) are exposed directly and are not protected by the Traefik CrowdSec Bouncer. Securing them should be done at the host level.

Collecting Coturn logs is not sufficient to detect attacks. This guide does not install a Coturn-specific CrowdSec parser or scenario, and a type: coturn acquisition label cannot create one. Before claiming CrowdSec protection, identify a maintained parser/scenario compatible with your Coturn log format, install it, and verify both parsing and detection with an isolated fixture. The CrowdSec Hub documentation explains the distinction between acquisition, parsers and scenarios.

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., 20000–20100/udp), Coturn relay range (30000–30100/udp), and TURN ports (3478/tcp, 3478/udp and, if enabled, 5349/tcp [required], 5349/udp [optional]) are explicitly allowed and not blocked by bouncer rules.

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-hpbe

Before 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
EOF

The 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
EOF

Note: 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’ nat_1_1_mapping before testing calls. If it still advertises the old public IP address, signaling may work while media fails because clients receive unreachable ICE candidates.

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 secret in the [turn] section of this file must be identical to the --static-auth-secret value used in the coturn service in your docker-compose.yml file.

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.yml

Keep 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.

  1. 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.conf

Replace signaling.example.com with your actual TURN/signaling domain.

  1. 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 coturn command --static-auth-secret
openssl rand -hex 16     # paste into both places, values must be identical
  1. 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 it

3.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-HERE

Note: 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:8080

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

  1. 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"
}
  1. 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 if ice_tcp=true)
  • volumes: ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
  1. 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.

  • Plan roughly 1 port per active participant (conservatively 2 if features like separate screen-share/recording open additional PeerConnections).
  • Examples (illustrative):
    • Range 40000–40050 (51 ports) → about 25–50 concurrent PeerConnections (small groups).
    • Range 40000–40199 (200 ports) → more headroom for spikes and larger meetings.
  • Always match your firewall (UFW) and CrowdSec rules to the same UDP range you configured in janus.jcfg and in the janus service ports. The above examples use 40000-based ranges; choose the range you actually configured (e.g., 20000–20100).
  1. 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
  1. Verify during a call attempt
  • In Firefox about:webrtc or Chrome chrome://webrtc-internals check that the remote ICE candidates from Janus show your public IP with ports in 20000–20100 and that the ICE state becomes connected/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-20100

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

  1. 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/null

Note: 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 coturn

Note: 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/null

Check 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.

  1. TLS is already configured in the main docker-compose.yml in 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.

  1. Update your server.conf servers list to include the turns: 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
  1. 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 5349

4. 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 -d

The --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 -f

4.1 Verify TURN/TURNS endpoints

If nc is not installed, install it first:

sudo apt-get update && sudo apt-get install -y netcat-openbsd

Run 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 coturn

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

  1. Log in to Nextcloud as an administrator.
  2. Navigate to Administration Settings -> Talk.
  3. Scroll down to the “Signaling server” section.
  4. Check “Enable custom signaling server”.
  5. In the “Signaling server URL” field, enter the full path to your HPBE: https://signaling.your-domain.com/standalone-signaling.
  6. In the “Shared secret” field, paste the corresponding shared secret for this specific Nextcloud instance (e.g., the value of NEXTCLOUD_1_SHARED_SECRET for your first instance).
  7. 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 & KeyMaps to…Purpose
[backend] -> secretNextcloud Admin -> Talk -> Shared secretAuthenticates Nextcloud with the signaling server.
[turn] -> apikeyUsed 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] -> secretcoturn service -> --static-auth-secret command argumentAuthenticates 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: true

Do 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 404 on https://recording.your-domain.com/ only proves that Traefik reaches the Flask service. The real validation is starting a recording from an actual Nextcloud Talk room and confirming that the recording is produced.

7. Troubleshooting & Monitoring

If you encounter issues, here are a few steps to diagnose the problem:

  1. Verify the Signaling Server is Reachable

    You should get a 200 OK response from the /welcome endpoint. This confirms that Traefik is routing requests correctly.

    curl -i https://signaling.your-domain.com/standalone-signaling/api/v1/welcome
  2. Check 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 -f
  3. Common Errors & Fixes

    • ERROR: the sessions block key must be... or hash key should be...: This error occurs when the keys in server.conf are missing or have the wrong format. Use openssl rand -base64 16 to generate new keys and paste them into the [sessions] section.
    • TURN Secret Mismatch: If calls fail to connect, verify that the secret in the [turn] section of server.conf is exactly the same as the --static-auth-secret in the coturn command in docker-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 PathPrefix and stripPrefix rules).
      • Verify that the Shared secret in Nextcloud matches the one in server.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 (Janus ports) and janus/janus.jcfg.
    • 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, but turns: 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 with docker 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) or chrome://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: iceConnectionState should reach connected/completed. If it stays in checking/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-20100 to confirm media packets hit the host.
  • TURN sanity: Use the Trickle ICE demo with your TURN URIs to confirm you get relay candidates; verify your Coturn realm and static secret match the values in server.conf and docker-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}"
done

Step 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"
fi

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

The 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/welcome

Confirm 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.jcfg so nat_1_1_mapping points 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 coturn after 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 coturn

Conclusion

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

📚OFFICIAL HPBE REPOSITORY