> ## Documentation Index
> Fetch the complete documentation index at: https://docs.onerep.life/llms.txt
> Use this file to discover all available pages before exploring further.

# Run OneRep Behind a Reverse Proxy with TLS

> OneRep is three origins, not one, and one of them is a WebSocket. Give each a hostname, forward the upgrade headers, then re-run the installer so the app is rebuilt against the new URLs.

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

| Service             | Container port | What the browser uses it for                                                                  |
| ------------------- | -------------- | --------------------------------------------------------------------------------------------- |
| App                 | `8081`         | The static bundle: HTML, JavaScript, icons.                                                   |
| Convex client API   | `3210`         | The live query WebSocket at `/api/<version>/sync`. Everything you see on screen arrives here. |
| Convex HTTP actions | `3211`         | Sign-in, the REST API, MCP, webhooks, file uploads.                                           |

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.

<Warning>
  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.
</Warning>

## Set it up

<Steps>
  <Step title="Point three hostnames at the machine">
    Create three A (or CNAME) records. Any names will do; these are the ones used throughout this page:

    | Hostname                      | Proxies to       |
    | ----------------------------- | ---------------- |
    | `app.your-domain.tld`         | `127.0.0.1:8081` |
    | `convex.your-domain.tld`      | `127.0.0.1:3210` |
    | `convex-site.your-domain.tld` | `127.0.0.1:3211` |

    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.
  </Step>

  <Step title="Configure the proxy">
    <Tabs>
      <Tab title="Caddy">
        Caddy handles the WebSocket upgrade and the certificates itself, which is why it is the short one:

        ```caddyfile Caddyfile theme={null}
        app.your-domain.tld {
            reverse_proxy 127.0.0.1:8081
        }

        convex.your-domain.tld {
            reverse_proxy 127.0.0.1:3210
        }

        convex-site.your-domain.tld {
            reverse_proxy 127.0.0.1:3211
        }
        ```
      </Tab>

      <Tab title="nginx">
        nginx strips hop-by-hop headers unless told otherwise, so the `map` block and the three `proxy_set_header` lines are the whole ballgame. Without them the sync socket is rejected before it opens.

        ```nginx /etc/nginx/conf.d/onerep.conf theme={null}
        map $http_upgrade $connection_upgrade {
            default upgrade;
            ''      close;
        }

        server {
            listen 443 ssl;
            server_name app.your-domain.tld;

            ssl_certificate     /etc/letsencrypt/live/app.your-domain.tld/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/app.your-domain.tld/privkey.pem;

            location / {
                proxy_pass http://127.0.0.1:8081;
                proxy_set_header Host              $host;
                proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
            }
        }

        server {
            listen 443 ssl;
            server_name convex.your-domain.tld;

            ssl_certificate     /etc/letsencrypt/live/convex.your-domain.tld/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/convex.your-domain.tld/privkey.pem;

            location / {
                proxy_pass http://127.0.0.1:3210;

                # The live query socket. Drop these three lines and the app
                # loads, looks fine, and never shows a single workout.
                proxy_http_version 1.1;
                proxy_set_header Upgrade    $http_upgrade;
                proxy_set_header Connection $connection_upgrade;

                proxy_set_header Host              $host;
                proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;

                # Default is 60s, which closes an idle socket while you are
                # resting between sets.
                proxy_read_timeout 3600s;
                proxy_send_timeout 3600s;

                # Photo uploads for food logging.
                client_max_body_size 25m;
            }
        }

        server {
            listen 443 ssl;
            server_name convex-site.your-domain.tld;

            ssl_certificate     /etc/letsencrypt/live/convex-site.your-domain.tld/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/convex-site.your-domain.tld/privkey.pem;

            location / {
                proxy_pass http://127.0.0.1:3211;
                proxy_set_header Host              $host;
                proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
                proxy_read_timeout 300s;
                client_max_body_size 25m;
            }
        }
        ```
      </Tab>

      <Tab title="Traefik">
        Traefik proxies WebSockets without configuration. Add labels to `selfhost/docker-compose.yml` and attach the containers to your Traefik network:

        ```yaml theme={null}
        services:
          app:
            labels:
              - traefik.enable=true
              - traefik.http.routers.onerep-app.rule=Host(`app.your-domain.tld`)
              - traefik.http.routers.onerep-app.tls.certresolver=letsencrypt
              - traefik.http.services.onerep-app.loadbalancer.server.port=3000

          backend:
            labels:
              - traefik.enable=true
              - traefik.http.routers.onerep-convex.rule=Host(`convex.your-domain.tld`)
              - traefik.http.routers.onerep-convex.tls.certresolver=letsencrypt
              - traefik.http.routers.onerep-convex.service=onerep-convex
              - traefik.http.services.onerep-convex.loadbalancer.server.port=3210
              - traefik.http.routers.onerep-convex-site.rule=Host(`convex-site.your-domain.tld`)
              - traefik.http.routers.onerep-convex-site.tls.certresolver=letsencrypt
              - traefik.http.routers.onerep-convex-site.service=onerep-convex-site
              - traefik.http.services.onerep-convex-site.loadbalancer.server.port=3211
        ```

        Note the app's container port is `3000`, not the `8081` published on the host.
      </Tab>
    </Tabs>

    Reload the proxy and confirm all three hostnames answer before going any further.
  </Step>

  <Step title="Tell OneRep its own addresses">
    The app bundle is static, so its backend URLs are compiled in at build time. Edit `selfhost/.env`:

    ```sh selfhost/.env theme={null}
    CONVEX_CLOUD_ORIGIN=https://convex.your-domain.tld
    CONVEX_SITE_ORIGIN=https://convex-site.your-domain.tld
    APP_URL=https://app.your-domain.tld
    ```

    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.
  </Step>

  <Step title="Re-run the installer">
    ```sh theme={null}
    cd onerep/selfhost
    ./install.sh
    ```

    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.
  </Step>
</Steps>

## Check your work

From any machine that can reach the proxy:

```sh theme={null}
# 1. The backend answers.
curl https://convex.your-domain.tld/version

# 2. The WebSocket upgrade survives the proxy. This is the one that matters.
curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://convex.your-domain.tld/api/1.43.0/sync
```

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

<Accordion title="The app loads but stays empty, or spins forever">
  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.
</Accordion>

<Accordion title="CORS errors, or sign-in fails with no obvious reason">
  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:

  ```sh theme={null}
  cd onerep
  export CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
  export CONVEX_SELF_HOSTED_ADMIN_KEY=<the key install.sh printed>

  bunx convex env get SITE_URL
  ```

  If it is wrong, re-running `./install.sh` fixes it along with everything else. To set it by hand:

  ```sh theme={null}
  bunx convex env set SITE_URL https://app.your-domain.tld
  ```

  Lost the admin key? Regenerate one with `docker compose exec backend ./generate_admin_key.sh`.
</Accordion>

<Accordion title="Sign-in bounces you to 127.0.0.1">
  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.
</Accordion>

<Accordion title="Mixed content warnings, or requests blocked in the console">
  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`.
</Accordion>

<Accordion title="It works for a minute, then disconnects and reconnects forever">
  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.
</Accordion>

<Accordion title="Can I serve all of it from one hostname?">
  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.
</Accordion>

<Accordion title="Cookies, SameSite, and other things you do not need to configure">
  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.
</Accordion>

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

<Note>
  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](/selfhost/mobile-apps).
</Note>
