# Nextcloud Whiteboard: Full Setup, Real-Time Collaboration, and the CSP WebSocket Fix
**Applies to:** Nextcloud 29+, Whiteboard app 1.x, Apache 2.4, Cloudflare proxy
**Tested on:** Nextcloud 33.0.3, Whiteboard app 1.5.8, Ubuntu 24.04
**Symptom:** Whiteboard opens but shows “offline” or times out connecting — real-time collaboration never
starts
## Overview
Nextcloud’s Whiteboard app splits into two parts:
1. **The Nextcloud app** (PHP, installed via the app store) — handles file storage, permissions, and
embedding the whiteboard UI.
2. **A separate Node.js WebSocket server** (Docker container) — handles real-time collaboration: cursor
sync, drawing state, presence.
When the whiteboard opens in the browser, it immediately tries to establish a persistent WebSocket
connection (`wss://`) to that Node.js server. If that connection fails for any reason, the whiteboard
loads but shows an “offline” banner and no one can collaborate in real time. Drawing still works locally,
but changes are not synced to other users.
There are two independent root causes that can produce this symptom. Both were encountered and resolved
across two separate Nextcloud instances. This article documents the full picture.
## Architecture
```
Browser
│
├─ HTTPS ──► Cloudflare edge ──► Apache :80 ──► PHP-FPM (Nextcloud)
│ (cloud.the-forge.design)
│
└─ WSS ───► Cloudflare edge ──► whiteboard Node.js server :3002
(whiteboard.the-forge.design)
```
- **Cloudflare** sits in front of both domains (orange-cloud proxy).
- **Apache** serves Nextcloud on port 80; Cloudflare’s flexible SSL provides HTTPS to browsers.
- **The whiteboard Node.js server** runs in Docker on port 3002 and is exposed publicly via a separate DNS
record (`whiteboard.the-forge.design`). - **AppAPI / HAProxy** (on port 8780 internally) is a completely separate subsystem for external apps and
is not involved in the whiteboard data path. - **JWT authentication** ties the two parts together: Nextcloud issues a signed JWT when a user opens a
whiteboard file; the Node.js server validates it.
## Root Cause 1 — The Docker Container Is Not Running
The Node.js collaboration server is not part of the Nextcloud installation. It is a standalone Docker
container that must be deployed and kept running independently.
### Check
```bash
docker ps -a | grep whiteboard
```
If the container is absent or in an exited/restarting state, collaboration is completely broken.
### Fix — Create the Docker Compose File
Get the two required values from Nextcloud first:
```bash
sudo -u www-data php /var/www/nextcloud/occ config:app:get whiteboard collabBackendUrl
sudo -u www-data php /var/www/nextcloud/occ config:app:get whiteboard jwt_secret_key
```
Create `/dockercontainers/whiteboard/docker-compose.yml`:
```yaml
services:
nextcloud-whiteboard-server:
image: Package whiteboard · GitHub
container_name: nextcloud-whiteboard-server
ports:
- “3002:3002”
environment:
NEXTCLOUD_URL: https://cloud.the-forge.design
JWT_SECRET_KEY:
MAX_UPLOAD_FILE_SIZE: 20971520 # 20 MB in bytes
restart: unless-stopped
```
Then start it:
```bash
cd /dockercontainers/whiteboard && docker compose up -d
docker ps | grep whiteboard # confirm “healthy”
```
**JWT must match exactly.** If the secret in the container differs from what Nextcloud has, the server
will reject all connection attempts with a 401. The `occ` command above gives the canonical value; use
that verbatim.
Verify the Server Is Reachable
```bash
HTTP check
curl https://whiteboard.the-forge.design/
Should return: Nextcloud Whiteboard Collaboration Server
socket.io polling check
curl “https://whiteboard.the-forge.design/socket.io/?EIO=4&transport=polling”
Should return a JSON handshake: {“sid”:“…”,“upgrades”:[“websocket”],…}
```
Root Cause 2 — The CSP Blocks WebSocket Connections (The Subtle One)
This is the issue that remains even after the container is running and reachable. It is not obvious
because:
- The whiteboard UI loads correctly.
- The whiteboard server responds to HTTP and to socket.io polling.
- The CSP already contains `connect-src *`, which looks like it permits everything.
- Browser devtools may show the error only briefly or bury it in a noisy network tab.
Why `connect-src *` Is Not Enough
Per the [Content Security Policy Level 3 specification](https://www.w3.org/TR/CSP3/), the `*` wildcard in
`connect-src` does **not** cover WebSocket schemes:
_“URLs with a scheme of `ws:` and `wss:` are considered live-connect sources and are matched only by
source expressions that include those schemes explicitly.”_
This means a browser enforcing CSP will silently block `wss://whiteboard.the-forge.design` even when the
header says `connect-src *`. The wildcard covers `https://` and `http://` sources, but `ws://` and
`wss://` must be listed by name.
Nextcloud generates its own CSP header via PHP. On this server the result looks like:
```
Content-Security-Policy: default-src ‘self’;
script-src ‘self’ ‘nonce-…’;
style-src ‘self’ ‘unsafe-inline’;
frame-src *;
img-src * data: blob:;
font-src ‘self’ data:;
media-src *;
connect-src *; ← looks permissive, but wss:// is NOT covered
object-src ‘none’;
base-uri ‘self’;
```
The whiteboard app ships a PHP hook that is supposed to inject `wss://` into the CSP, but this does not
work reliably in all configurations (particularly when the Nextcloud instance is behind a reverse proxy or
when the app hook fires before the header is fully assembled).
Why Fix It at the Apache Level
Apache processes response headers after PHP has finished, so an Apache `Header` directive is the last word
on what the browser sees — it overrides or amends whatever PHP emitted. This makes it the most reliable
place to patch the CSP.
The Fix
Add one line to the active Apache VirtualHost. On this server that is
`/etc/apache2/sites-available/cloud.the-forge.design.conf`:
```apache
Header always edit Content-Security-Policy "connect-src " "connect-src https://whiteboard.the-forge.design
wss://whiteboard.the-forge.design "
```
**How this works:**
`Header always edit` performs a regex substitution on the named response header. The pattern `connect-src
` (note the trailing space) matches the start of the connect-src directive inside the existing CSP string.
It is replaced with `connect-src https://whiteboard.the-forge.design wss://whiteboard.the-forge.design `
— the two explicit origins are prepended, and the original remainder (including the `*`) is preserved
unchanged. The resulting header becomes:
```
connect-src https://whiteboard.the-forge.design wss://whiteboard.the-forge.design *;
```
Now `wss://whiteboard.the-forge.design` is listed explicitly, satisfying the CSP spec, and browsers will
allow the WebSocket connection.
**`mod_headers` must be enabled.** Check with `apache2ctl -M | grep headers`. Enable if missing:
`a2enmod headers && systemctl reload apache2`.
Apply and Verify
```bash
apache2ctl configtest # must say “Syntax OK”
systemctl reload apache2
Confirm the header is present in the live response:
curl -s http://127.0.0.1/index.php -D - -o /dev/null | grep content-security-policy
```
Expected output will now include:
```
connect-src https://whiteboard.the-forge.design wss://whiteboard.the-forge.design *
```
Full Working Apache VirtualHost
For reference, the complete `/etc/apache2/sites-available/cloud.the-forge.design.conf` after applying the
fix:
```apache
<VirtualHost *:80>
ServerAdmin [email protected]
ServerName cloud.the-forge.design
#Notify-push-start - Please don't remove or change this line
ProxyPass /push/ws ws://localhost:7867/ws
ProxyPass /push/ http://localhost:7867/
ProxyPassReverse /push/ http://localhost:7867/
#Notify-push-end - Please don't remove or change this line
<FilesMatch "\\.php$">
SetHandler "proxy:unix:/run/php/php8.3-fpm.nextcloud.sock|fcgi://localhost"
</FilesMatch>
LogLevel warn
CustomLog ${APACHE_LOG_DIR}/access.log combined
ErrorLog ${APACHE_LOG_DIR}/error.log
DocumentRoot /var/www/nextcloud
<Directory /var/www/nextcloud>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
Satisfy Any
Include /var/www/nextcloud/.htaccess
</Directory>
<Directory /mnt/ncdata>
Require all denied
</Directory>
<Directory /var/www/nextcloud/config/>
Require all denied
</Directory>
<IfModule mod_dav.c>
Dav off
</IfModule>
<Files ".ht\*">
Require all denied
</Files>
SetEnv HOME /var/www/nextcloud
SetEnv HTTP_HOME /var/www/nextcloud
TraceEnable off
RewriteEngine On
RewriteCond %{REQUEST_METHOD} ^TRACK
RewriteRule .\* - \[R=405,L\]
<IfModule mod_reqtimeout.c>
RequestReadTimeout body=0
</IfModule>
SetEnv proxy-sendcl 1
# Whiteboard CSP fix: wss:// is not covered by connect-src \* per the CSP spec
Header always edit Content-Security-Policy "connect-src " "connect-src
https://whiteboard.the-forge.design wss://whiteboard.the-forge.design "
```
Optional: Set Maximum File Size
The whiteboard stores its data as a file in Nextcloud. The defaults are conservative. Two settings must
match (one is in bytes, the other in megabytes):
```bash
Nextcloud side (in MB)
sudo -u www-data php /var/www/nextcloud/occ config:app:set whiteboard max_file_size --value=20
Container side (in bytes, set in docker-compose.yml environment)
MAX_UPLOAD_FILE_SIZE: 20971520 # 20 * 1024 * 1024
```
If these do not match, large whiteboards will fail to save with an unhelpful error.
Cloudflare Considerations
WebSocket Support
Cloudflare’s orange-cloud (proxy) mode supports WebSocket connections on all plans, including free. No
special configuration is required. However, there is one requirement: the origin server must respond to
the WebSocket upgrade within Cloudflare’s upstream timeout. If the Node.js server is not running,
Cloudflare will return a 524 (origin timeout) rather than a meaningful error, which can make the root
cause harder to identify.
SSL/TLS Mode
This setup uses **Flexible** SSL mode: Cloudflare terminates TLS from the browser and connects to the
Apache origin on port 80 (plain HTTP). Apache therefore only needs to handle HTTP; there is no SSL
certificate on the server itself for the Nextcloud domain. The CSP `Header always edit` directive is
placed in the `*:80` VirtualHost for exactly this reason — that is the socket that actually receives
requests.
If you switch to **Full** or **Full Strict** mode, you will need a real certificate on the origin (e.g.
via Certbot) and a `*:443` VirtualHost, and the `Header always edit` line must be moved there.
Cloudflare and the Whiteboard Subdomain
`whiteboard.the-forge.design` is proxied through Cloudflare just like the main Nextcloud domain.
Cloudflare handles TLS termination for the whiteboard subdomain too and forwards connections to the
Node.js server on port 3002. Because port 3002 is not a standard Cloudflare proxied port, the DNS record
for `whiteboard.the-forge.design` should either:
- Use a Cloudflare Worker or tunnel to map port 443 → 3002, or
- Use Cloudflare’s non-standard proxied ports list, or
- Be set as **DNS-only** (grey cloud) if TLS termination for this subdomain is handled elsewhere
Verify external access is working regardless of how Cloudflare is configured:
```bash
curl https://whiteboard.the-forge.design/socket.io/?EIO=4&transport=polling
```
Diagnostic Checklist
Use this in order when the whiteboard shows “offline”:
| Step | Command | Expected result |
|---|---|---|
| 1. Container running? | `docker ps | grep whiteboard` | `Up … (healthy)` |
| 2. Server responds? | `curl http://localhost:3002/` | `Nextcloud Whiteboard Collaboration Server` |
| 3. External URL works? | `curl https://whiteboard.the-forge.design/` | same |
| 4. socket.io handshake? | `curl | |
| “https://whiteboard.the-forge.design/socket.io/?EIO=4&transport=polling”` | JSON with | |
| `“upgrades”:[“websocket”]` | ||
| 5. JWT matches? | compare `docker inspect … | grep JWT` with `occ config:app:get whiteboard | |
| jwt_secret_key` | identical strings | |
| 6. CSP has wss://? | `curl -s http://127.0.0.1/index.php -D - -o /dev/null | grep | |
| content-security-policy` | `connect-src` includes `wss://whiteboard.the-forge.design` |
If steps 1–5 pass but step 6 fails, apply the Apache `Header always edit` fix above.
Why the PHP-Level Fix Is Unreliable
The Whiteboard app includes a PHP class that hooks into Nextcloud’s CSP builder to inject the whiteboard
server URL. In practice this fails silently in several scenarios:
- The hook fires before the final CSP assembly, so a later Nextcloud middleware overwrites it.
- Opcode caching or Nextcloud’s app bootstrap order prevents the hook from registering in time.
- When running behind a reverse proxy, the CSP header may be emitted before Apache’s `Header` processing
stage runs — in that case Apache never sees it.
The Apache `Header always edit` directive runs unconditionally after all PHP output, regardless of app
hooks, middleware order, or caching. It is the safer and more predictable fix.
Quick Reference: Key Commands
```bash
Start or restart the whiteboard container
cd /dockercontainers/whiteboard && docker compose up -d
Check whiteboard Nextcloud config
sudo -u www-data php /var/www/nextcloud/occ config:app:get whiteboard collabBackendUrl
sudo -u www-data php /var/www/nextcloud/occ config:app:get whiteboard jwt_secret_key
sudo -u www-data php /var/www/nextcloud/occ config:app:get whiteboard max_file_size
Set max file size (MB)
sudo -u www-data php /var/www/nextcloud/occ config:app:set whiteboard max_file_size --value=20
Test and reload Apache after config changes
apache2ctl configtest && systemctl reload apache2
Verify the live CSP header
curl -s http://127.0.0.1/index.php -D - -o /dev/null | grep content-security-policy
View whiteboard container logs
docker logs nextcloud-whiteboard --tail 50 -f
```