Skip to main content
Putting OneRep behind Caddy, nginx, or Traefik takes two things that are easy to miss:
  1. The browser talks to three separate origins, so each one needs its own hostname. There is no single-port configuration.
  2. The Convex client keeps a WebSocket open to the backend. A proxy that does not forward the upgrade headers leaves you with an app that loads, renders, and never shows any data.
Everything below assumes you have already run ./install.sh once and have a working install on 127.0.0.1.

The three origins

The dashboard on 6791 is a fourth, and it is an admin tool holding a key to your entire database. Leave it on localhost and reach it over an SSH tunnel unless you have a reason not to.
Convex origins must be bare origins — scheme, host, optional port, nothing else. https://convex.your-domain.tld works. https://your-domain.tld/convex does not: the client appends /api/<version>/sync to what you give it, and the backend serves its routes from the root. Use subdomains.

Set it up

1

Point three hostnames at the machine

Create three A (or CNAME) records. Any names will do; these are the ones used throughout this page:If you are on a LAN with no public DNS, three entries in your router’s DNS or in /etc/hosts work the same way — but you will be on plain HTTP, and browsers refuse mixed content, so keep all three on HTTP together or all three on HTTPS together. Never one on each.
2

Configure the proxy

Caddy handles the WebSocket upgrade and the certificates itself, which is why it is the short one:
Caddyfile
Reload the proxy and confirm all three hostnames answer before going any further.
3

Tell OneRep its own addresses

The app bundle is static, so its backend URLs are compiled in at build time. Edit selfhost/.env:
selfhost/.env
No trailing slashes. These three values have to match the hostnames in your proxy config character for character — they end up in the CORS allowlist, and a stray www or a http where you meant https is a rejected request.
4

Re-run the installer

This is not optional, and docker compose up -d --build is not a substitute. The installer does two things nothing else does: it rebuilds the app image against the new origins, and it pushes SITE_URL to the backend so your new domain is trusted. Skip it and you get a bundle pointing at the right place talking to a backend that has never heard of it.Your data and secrets survive. Re-running is idempotent.

Check your work

From any machine that can reach the proxy:
The second command must answer HTTP/1.1 101 Switching Protocols. Anything else — a 400, a 404, a 502 — means the app will hang, and no amount of configuration elsewhere will save it. Then open the app, sign in, and watch the browser console. A clean install logs no errors.

When it does not work

The WebSocket is not getting through. Run the upgrade check above; if it returns 400 InvalidConnectionHeader — Connection header did not include upgrade, your proxy is dropping the Upgrade and Connection headers. On nginx that is the missing map block and proxy_http_version 1.1. On Apache it is mod_proxy_wstunnel, unloaded by default.A 404 instead means the proxy is rewriting the path — check for a trailing slash on proxy_pass, which makes nginx strip the location prefix.
The backend allows exactly the origins it was told about, and it learns your app’s origin from SITE_URL. If the browser console says the request was blocked by CORS policy, SITE_URL on the backend does not match APP_URL in selfhost/.env.Almost always this is a .env edit that was never followed by ./install.sh. Check what the backend actually believes. The Convex CLI needs to be told where your backend is and handed the admin key the installer printed:
If it is wrong, re-running ./install.sh fixes it along with everything else. To set it by hand:
Lost the admin key? Regenerate one with docker compose exec backend ./generate_admin_key.sh.
Same cause, different symptom. Sign-in redirects resolve against SITE_URL, so a stale value sends you back to localhost after the login succeeds. Fix SITE_URL as above.
The app is on HTTPS and at least one Convex origin is still on HTTP. Browsers refuse to let a secure page open an insecure connection, including ws://. All three origins have to be on the same scheme. Check what got baked into the bundle — view source on the app and look for the origins, or just re-read selfhost/.env and confirm every one of the three says https.
Your proxy is timing out the idle WebSocket. nginx defaults proxy_read_timeout to 60 seconds. Raise it to an hour on the convex. server block. Cloudflare’s proxy has its own idle timeout you cannot raise; it will reconnect rather than break, but expect a blink.
No. The Convex origins have to be bare origins, so a path prefix such as /convex will not work, and the two Convex services cannot share a hostname with each other either. Three names is the supported shape. They are free.
Worth knowing before you go hunting: OneRep’s cross-origin auth carries its session in localStorage, not in cross-site cookies. Nothing here needs SameSite=None, a cookie domain, or a shared parent domain. If sign-in is failing, it is CORS or SITE_URL — see above.

Behind Cloudflare or a tunnel

A Cloudflare Tunnel works, with two notes. WebSockets must be enabled on the zone (they are by default on every plan). And the orange-cloud proxy imposes its own request size and idle limits, so large photo uploads and long-idle sockets behave slightly differently than they do on a plain origin. Map the three hostnames to the three local ports exactly as above; nothing else changes.
Once the app is reachable on a real hostname over TLS, it installs as a PWA from the browser and behaves like the native builds. See Mobile apps.