Self-hosting
Reverse proxy Beta
Put nginx or Traefik with TLS in front of O3O, keep the editor's WebSockets alive, and avoid the common configuration traps.
This page describes a beta feature: it works but may still change.
On this page
O3O always has TWO proxy layers. The inner one is o3o-proxy, bundled in the Docker stack: it asks the gate before opening an editing session and blocks the editing server's admin, metrics and conversion paths, so it must NOT be removed. The outer one is yours: nginx, Traefik or a load balancer that handles TLS and the domain, then forwards everything to port 8080.
Inner proxy: o3o-proxy#
| Path | Goes to | Notes |
|---|---|---|
/browser/<hash>/cool.html | o3o-online | Asks the gate (page stage); when refused the gate serves the limit page. |
Every WebSocket upgrade to the editing server: /cool/<doc>/ws, /cool/ws?WOPISrc=…, and the forms with a trailing / or extra path segments after ws | o3o-online | Asks the gate (ws stage); WebSocket, proxy_read_timeout 36000s. A WebSocket request the gate cannot parse is unknown: the gate answers 403 and the proxy refuses it instead of letting it through. |
/browser, /cool/, /hosting, /lool/ | o3o-online | Not checked by the gate. The Upgrade header is removed, so no WebSocket can be opened through these paths. |
/browser/dist/admin, /browser/<hash>/admin, /cool/adminws, /cool/getMetrics | 404 | The editing server's admin and metrics are never exposed. They are blocked by prefix, so forms with a trailing / or extra segments (/cool/adminws/, /cool/getMetrics/x) return 404 too. The admin console is also served under the version-hash path, so both must be blocked. |
/cool/convert-to, /lool/convert-to | 404 | The editing server's conversion API is not exposed; external systems convert through POST /v1/convert so plan limits apply. |
/o3o/ | o3o-gate | Except /o3o/auth and /o3o/wopi/: 404 from outside. |
/v1/ | o3o-docbuilder | proxy_read_timeout 660s, no request buffering; DocBuilder enforces size limits itself. |
/ | 302 to /o3o/ | |
| Any other path | 404 | Nothing outside this table is forwarded. |
# Ask the gate; if the gate does not answer or fails with 5xx, let the request through
location = /_o3o_auth {
internal;
proxy_pass http://o3o-gate:8070/o3o/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri; # raw URI, not decoded
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-O3O-Stage $o3o_stage;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
proxy_intercept_errors on;
error_page 500 502 503 504 = /_o3o_failopen; # fail-open, only when the gate fails
}
location = /_o3o_failopen { internal; return 204; }
# EVERY WebSocket upgrade of an editing session goes through here
location ~ ^/cool/(.*/)?ws(/.*)?$ { # catches /cool/ws, a trailing / and segments after ws
set $o3o_stage ws;
auth_request /_o3o_auth;
proxy_pass http://o3o-online:9980;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $http_host;
proxy_http_version 1.1;
proxy_read_timeout 36000s;
}
# Keep the editing server's admin, metrics and conversion API closed
location ^~ /browser/dist/admin { return 404; }
location ~ ^/browser/[^/]+/admin(/|$) { return 404; } # admin console under the version hash
location ^~ /cool/adminws { return 404; } # prefix, not exact match: also blocks trailing-slash forms
location ^~ /cool/getMetrics { return 404; }
location ^~ /cool/convert-to { return 404; } # external conversions go through POST /v1/convert
location ^~ /lool/convert-to { return 404; }
# The rest of /cool/ (and /lool/ likewise): WebSocket upgrades are not forwarded
location /cool/ {
proxy_pass http://o3o-online:9980;
proxy_http_version 1.1;
proxy_set_header Upgrade ""; # drop Upgrade and Connection
proxy_set_header Connection "";
proxy_set_header Host $http_host;
}
# The root goes to the gate; every other path returns 404
location = / { return 302 /o3o/; }
location / { return 404; }The full file is online/nginx/o3o-proxy.conf. If you build your own inner proxy instead of o3o-proxy, keep EVERY blocking line above and pass every item of the checklist below: each missing line exposes that path. o3o-proxy returns 404 for /cool/convert-to and /lool/convert-to; external conversions go through POST /v1/convert. Nextcloud uses convert-to to render document previews: a Nextcloud on the same Docker network sets wopi_url = http://o3o-online:9980 and keeps its previews; a Nextcloud on another host that goes through the proxy's public address gets no document previews. Editing is not affected.
Writing your own inner proxy: checklist#
The editing server accepts several URL forms for the same thing. The inner proxy must catch EVERY form below; otherwise users can open editing sessions without the gate ever being asked (bypassing the connection cap), or reach the admin console and metrics. This table is for anyone replacing o3o-proxy with their own nginx configuration or another proxy; the bundled o3o-proxy already passes all of it.
| URL form | The inner proxy must |
|---|---|
/cool/<doc>/ws?… (standard form, document in the path) | Ask the gate through auth_request, stage ws. |
/cool/ws?WOPISrc=…&access_token=… (document in the query string) | Ask the gate, stage ws, like the standard form. |
/cool/<doc>/ws/ and /cool/<doc>/ws/<any segment> | Ask the gate, stage ws. |
Document part encoded twice (%252F…) | Ask the gate and send the RAW original URI ($request_uri) in X-Original-URI, neither decoded nor rebuilt. The gate decodes it itself and refuses what it cannot parse. |
Every other path under /cool/, /lool/, /browser, /hosting | Forward WITHOUT the Upgrade and Connection: upgrade headers. A WebSocket handshake on these paths must never get 101. |
/cool/adminws, /cool/adminws/, /cool/adminws/<x> | Return 404 (blocked by prefix). |
/cool/getMetrics, /cool/getMetrics/, /cool/getMetrics/<x> | Return 404 (blocked by prefix). |
/browser/dist/admin…, /browser/<hash>/admin… | Return 404. |
/cool/convert-to…, /lool/convert-to… | Return 404; external systems convert through POST /v1/convert. |
/o3o/auth, /o3o/wopi/… | Return 404 when called from outside. |
/browser/<hash>/cool.html | Ask the gate, stage page; on 403 serve the gate's limit page. This stage only warns early; the real enforcement point is the ws stage. |
- Block by prefix (
location ^~ /cool/adminws) or with a regex ending in(/|$), never by exact match (location = …): an exact match lets the trailing-slash forms through. - The proxy ITSELF sets
X-Original-URIandX-O3O-Stageon every request to the gate, overwriting whatever the client sent; never forward these two headers from the client. - Fail open ONLY when the gate does not answer, times out or fails with 5xx. A 403 from the gate is final and is never turned into an allow; unknown WebSocket requests are refused, not failed open. The gate only refuses NEW sessions: open sessions are never cut and saving is never blocked.
- Test with
curl --path-as-is: without it curl normalises the path and you cannot test the exact form that must be blocked. Re-run the checklist on every editing server upgrade, since a new version may accept more URL forms.
BASE=https://office.example.com
# Every line must return 404
for path in /cool/adminws /cool/adminws/ /cool/adminws/x /cool/getMetrics /cool/getMetrics/ /cool/getMetrics/x \
/browser/dist/admin/admin.html /cool/convert-to /cool/convert-to/pdf /lool/convert-to /o3o/auth /o3o/wopi/files/x; do
printf '%-32s %s\n' "$path" "$(curl -s -o /dev/null --path-as-is -w '%{http_code}' "$BASE$path")"
done
# WebSocket handshakes: no line may return 101
WS=(--http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==')
for path in /cool/not-a-url/ws /cool/not-a-url/ws/ /cool/%252Fx/ws /cool/adminws/ /cool/getMetrics/ /cool/clipboard; do
printf '%-32s %s\n' "$path" "$(curl -s -o /dev/null --path-as-is --max-time 5 -w '%{http_code}' "${WS[@]}" "$BASE$path")"
doneExpected result: the first loop is all 404. In the second loop the first three lines return 403 because the gate refuses WebSocket requests it cannot parse; /cool/adminws/ and /cool/getMetrics/ return 404; /cool/clipboard returns an editing server error because the Upgrade header was dropped; no line returns 101. To test the valid forms at the cap, set O3O_GATE_CONNECTION_CAP=1 on a test machine, keep one editing session open, then open a second one with each URL form from the table: every form must be refused (403) or open read-only.
nginx as the outer layer#
# /etc/nginx/conf.d/o3o.conf
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
server {
listen 80;
server_name office.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name office.example.com;
ssl_certificate /etc/letsencrypt/live/office.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/office.example.com/privkey.pem;
client_max_body_size 0; # no limit here; O3O enforces file sizes per edition
location / {
proxy_pass http://127.0.0.1:8080; # o3o-proxy's O3O_PROXY_PORT
proxy_http_version 1.1;
proxy_set_header Host $http_host; # keeps the port too; do not use $host
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 36000s; # idle WebSockets are not cut after 60 seconds
proxy_send_timeout 36000s;
proxy_request_buffering off;
proxy_buffering off;
}
}O3O_PUBLIC_URL=https://office.example.com
O3O_ONLINE_SERVER_NAME=office.example.com
O3O_ONLINE_SSL_TERMINATION=trueTraefik as the outer layer#
# traefik.yml (static configuration)
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: 0s # the 60-second default cuts WebSockets and long uploads
idleTimeout: 3600s
providers:
file:
filename: /etc/traefik/dynamic.yml
certificatesResolvers:
le:
acme:
email: admin@example.com
storage: /acme/acme.json
httpChallenge:
entryPoint: web
---
# dynamic.yml (dynamic configuration)
http:
routers:
o3o:
rule: Host(`office.example.com`)
entryPoints: [websecure]
service: o3o
tls:
certResolver: le
services:
o3o:
loadBalancer:
passHostHeader: true
servers:
- url: http://127.0.0.1:8080 # o3o-proxy's O3O_PROXY_PORT- Traefik forwards WebSockets and adds
X-Forwarded-Protoby itself; no custom middleware for theUpgradeheader is needed. - The ACME HTTP challenge needs port 80 reachable from the Internet; behind a CDN or firewall use the DNS challenge.
- With Traefik's Docker provider, set
exposedByDefault: falseso databases or Nextcloud are never exposed by accident.
Common pitfalls#
| Symptom | Cause | Fix |
|---|---|---|
| WebSocket handshake returns 400, the gate is never asked | The inner proxy has location ^~ /cool/ or ^~ /browser: ^~ makes nginx skip every regex location. | Use plain prefix locations without ^~ (verified). |
| More editing sessions open although the cap is reached | The WebSocket location only catches one URL form (for example ^/cool/(.*)/ws$) while the generic /cool/ location still forwards Upgrade without asking the gate; or X-Original-URI is not the raw URI. | Catch every form with ~ ^/cool/(.*/)?ws(/.*)?$, drop Upgrade in the generic location, send $request_uri; run the checklist above. |
/cool/getMetrics/ or /cool/adminws/ does not return 404 | Blocked by exact match (location = /cool/getMetrics): the trailing-slash form falls into the generic location. | Block by prefix: location ^~ /cool/getMetrics, location ^~ /cool/adminws. |
| Users drop out after about 60 idle seconds | The outer proxy's default read timeout. | nginx: proxy_read_timeout 36000s; Traefik: readTimeout: 0s. |
| The editor frame loads from the wrong port or host | The Host header lost its port ($host instead of $http_host), or O3O_ONLINE_SERVER_NAME is wrong. | Keep Host $http_host; set the correct public name. |
| Blank screen, the browser reports mixed content | Users arrive over https but O3O_ONLINE_SSL_TERMINATION=false. | Set it to true and make O3O_PUBLIC_URL https. |
| Uploads or conversions fail with 413 | The outer proxy's client_max_body_size is below O3O's limit. | Set 0, or at least 300 MB for /v1/ in the enterprise edition. |
| Synchronous conversions return 504 at the outer proxy | The outer proxy's timeout is shorter than O3O's (/v1/ uses 660 seconds). | Raise the timeout; use async = true for heavy files. |
| The embedding page shows no editor frame | The page is on another origin while O3O_ONLINE_FRAME_ANCESTORS is empty, or the outer proxy adds frame-ancestors 'none'. | Set the variable; do not let the outer proxy override Content-Security-Policy. |
| WebSockets are cut periodically despite a correct setup | A CDN or web application firewall in front has its own timeout. | Check that layer's WebSocket support and timeouts. |