Skip to content

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#

PathGoes toNotes
/browser/<hash>/cool.htmlo3o-onlineAsks 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 wso3o-onlineAsks 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-onlineNot 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/getMetrics404The 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-to404The editing server's conversion API is not exposed; external systems convert through POST /v1/convert so plan limits apply.
/o3o/o3o-gateExcept /o3o/auth and /o3o/wopi/: 404 from outside.
/v1/o3o-docbuilderproxy_read_timeout 660s, no request buffering; DocBuilder enforces size limits itself.
/302 to /o3o/
Any other path404Nothing outside this table is forwarded.
nginxCore inner proxy configuration (tested with nginx 1.27)
# 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 formThe 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, /hostingForward 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.htmlAsk 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-URI and X-O3O-Stage on 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.
BashChecking the inner proxy from outside
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")"
done

Expected 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#

TLS and WebSocket in front of o3o-proxy
# /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=true

Traefik as the outer layer#

YAMLTraefik v3 static and dynamic configuration
# 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-Proto by itself; no custom middleware for the Upgrade header 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: false so databases or Nextcloud are never exposed by accident.

Common pitfalls#

SymptomCauseFix
WebSocket handshake returns 400, the gate is never askedThe 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 reachedThe 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 404Blocked 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 secondsThe outer proxy's default read timeout.nginx: proxy_read_timeout 36000s; Traefik: readTimeout: 0s.
The editor frame loads from the wrong port or hostThe 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 contentUsers arrive over https but O3O_ONLINE_SSL_TERMINATION=false.Set it to true and make O3O_PUBLIC_URL https.
Uploads or conversions fail with 413The 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 proxyThe 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 frameThe 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 setupA CDN or web application firewall in front has its own timeout.Check that layer's WebSocket support and timeouts.