Skip to content

Reverse Proxy and TLS

marcpope edited this page Jul 27, 2026 · 2 revisions

Reverse proxy and TLS

The container serves plain HTTP on 8080. It does not terminate TLS and does not manage certificates — that is the proxy's job, and it stays that way so the image does not have to know anything about your certificate authority, your renewal schedule, or your load balancer.

This page has copy-pasteable configs for Caddy, Traefik and nginx, plus the two things people get wrong: the WebSocket paths, and trusted proxies.

If you are running without Docker, the proxy is your web server and the setup is different — see Install on a VM.

What the proxy has to do

  1. Terminate TLS for the console hostname and forward to the container on 8080.
  2. Pass WebSocket upgrades through on /ws/id and /ws/relay.
  3. Send X-Forwarded-For and X-Forwarded-Proto.
  4. Be in the app's trusted-proxy list, or those headers are ignored.

The topology

browser ──https/wss──► your proxy ──http──► cortendesk:8080
                                                │
                                    /ws/id      ├──► hbbs  <host>:21118
                                    /ws/relay   └──► hbbr  <host>:21119

The container's own nginx already bridges those two paths to the RustDesk server's WebSocket ports — 21118 on hbbs, 21119 on hbbr. The upstream host comes from RUSTDESK_WS_HOST, or the host part of CORTENDESK_ID_SERVER if you do not set it. Your proxy never needs to know those ports exist.

What it does need to do is not break the upgrade: forward Upgrade and Connection and give the connection a long read timeout. Caddy and Traefik do this with no configuration; nginx needs three lines and a longer timeout than its 60-second default, which otherwise cuts an idle session.

Both ports must be reachable from inside the container. If hbbs/hbbr run on the Docker host rather than a routable address, set RUSTDESK_WS_HOST to an address the container can actually reach.

App settings that go with it

Setting Value
APP_URL https://console.example.com — the public URL, with the scheme
SESSION_SECURE_COOKIE true
CORTENDESK_NATIVE_WEBCLIENT true to enable the in-browser client
CORTENDESK_WS_ID_URL wss://console.example.com/ws/id
CORTENDESK_WS_RELAY_URL wss://console.example.com/ws/relay
TRUSTED_PROXIES see below — usually leave unset

The CORTENDESK_WS_*_URL values are what the browser connects to, so they are the proxy's hostname, not the container's. Left unset they fall back to wss://<host of CORTENDESK_ID_SERVER>/ws/…, which is only right if the console and the RustDesk server share a hostname. Set them explicitly.

Caddy

The shortest path for a single hostname. Caddy gets and renews a certificate over HTTP-01 by itself, sets X-Forwarded-For and X-Forwarded-Proto by default, and proxies WebSockets without being asked.

Requirements: console.example.com resolves to this host, and ports 80 and 443 are open inbound. Port 80 is not optional — that is where the challenge is answered.

Caddy on the host, container published on 8080:

console.example.com {
    reverse_proxy 127.0.0.1:8080
}

That is the entire file. Caddy in Compose alongside CortenDesk:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    restart: unless-stopped

  cortendesk:
    image: marcpope/cortendesk
    # no ports: — only Caddy needs to reach it
    environment:
      APP_URL: https://console.example.com
      SESSION_SECURE_COOKIE: "true"
      CORTENDESK_NATIVE_WEBCLIENT: "true"
      CORTENDESK_WS_ID_URL: wss://console.example.com/ws/id
      CORTENDESK_WS_RELAY_URL: wss://console.example.com/ws/relay
      CORTENDESK_ID_SERVER: hbbs.example.com:21116
      CORTENDESK_RELAY_SERVER: hbbs.example.com:21117
      CORTENDESK_PUBLIC_KEY: "<contents of id_ed25519.pub>"
    volumes:
      - cortendesk-data:/data
    restart: unless-stopped

volumes:
  caddy-data:
  caddy-config:
  cortendesk-data:
console.example.com {
    reverse_proxy cortendesk:8080
}

Keep the caddy-data volume. It holds the certificates and the ACME account key; losing it means re-issuing on every restart, which will hit rate limits.

Drop the ports: mapping on the CortenDesk service once Caddy is in front. Leaving 8080 published keeps a plain-HTTP door open to the console.

Traefik

For people already running Traefik. Labels on the CortenDesk service, assuming a websecure entrypoint and an ACME resolver named le in your static config:

services:
  cortendesk:
    image: marcpope/cortendesk
    networks: [proxy]
    environment:
      APP_URL: https://console.example.com
      SESSION_SECURE_COOKIE: "true"
      CORTENDESK_NATIVE_WEBCLIENT: "true"
      CORTENDESK_WS_ID_URL: wss://console.example.com/ws/id
      CORTENDESK_WS_RELAY_URL: wss://console.example.com/ws/relay
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.cortendesk.rule=Host(`console.example.com`)"
      - "traefik.http.routers.cortendesk.entrypoints=websecure"
      - "traefik.http.routers.cortendesk.tls.certresolver=le"
      - "traefik.http.services.cortendesk.loadbalancer.server.port=8080"
    volumes:
      - cortendesk-data:/data
    restart: unless-stopped

If you do not already have one, a minimal Traefik service:

  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.le.acme.httpchallenge=true
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
      - --certificatesresolvers.le.acme.email=admin@example.com
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
    ports:
      - "80:80"
      - "443:443"
    networks: [proxy]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

No WebSocket middleware and no forwarded-header middleware: Traefik proxies upgrades transparently and sets X-Forwarded-* itself. There is nothing to add for /ws/id and /ws/relay — they are ordinary paths on the same router.

nginx

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name console.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name console.example.com;

    ssl_certificate     /etc/letsencrypt/live/console.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/console.example.com/privkey.pem;

    client_max_body_size 64m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        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;
    }

    # Same upstream, but sessions must survive a quiet period. nginx defaults
    # to a 60s read timeout, which drops an idle web-client connection.
    location ^~ /ws/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host              $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 3600s;
        proxy_send_timeout 3600s;
    }
}
sudo nginx -t && sudo systemctl reload nginx

map belongs at http level, not inside server — put it above the server blocks in the same file, or in nginx.conf.

Certificates are your problem here; certbot's nginx plugin is the usual answer:

sudo certbot --nginx -d console.example.com

If nginx runs in a container next to CortenDesk, replace 127.0.0.1:8080 with cortendesk:8080 and add a resolver if the upstream is a variable.

Trusted proxies

Once a proxy is in front, every request arrives from the proxy's address. The app has to be told which peers are allowed to say "the real client was somewhere else" via X-Forwarded-For — otherwise anyone who can reach the app directly could forge their address into your audit logs.

Two things break concretely when forwarding is not trusted:

  • Device last_online_ip is wrong. Every device in the list shows the proxy's address instead of where it actually connected from, and searching devices by IP returns nothing useful.
  • The login rate limiter treats everyone as one address. Sign-ins are limited per account-and-address (5 failures/minute) and per source address (20 failures/minute). Collapse all users onto one address and a few people fat-fingering passwords exhausts the address budget for the whole console — everyone gets locked out for a minute at a time, and Logs → Alarms fills with password-spraying alarms from your own proxy.

X-Forwarded-Proto matters for a different reason: without it the app believes the request was plain HTTP, generates http:// asset URLs on an HTTPS page (blocked as mixed content), and the login redirect loops.

What is trusted by default

127.0.0.1, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16

Loopback and the private ranges — which is every normal deployment. A proxy in a Docker network reaches the app from that network's gateway or the proxy container's own address, and Docker allocates those out of 172.16.0.0/12, so it is already covered. A proxy on the same host reaching a published port is also covered.

Do not "fix" this by setting TRUSTED_PROXIES=127.0.0.1. With a containerised proxy the peer is never 127.0.0.1 — it is the Docker bridge address — and narrowing the list to loopback is exactly how you turn a working setup into the broken one described above.

When you do need to set it

When the proxy reaches the app from a public address: Cloudflare, an external load balancer, a proxy on a different machine over the internet.

environment:
  TRUSTED_PROXIES: "203.0.113.7,198.51.100.0/24"

Comma-separated, CIDR notation allowed. * trusts the immediate peer whatever it is — only safe when nothing but the proxy can reach the app's port.

The values are read from config, so php artisan config:cache does not lose them. In the container this is just the environment variable; on a VM install it goes in .env.

Checking it works

Sign in and look at Logs → Logins. If the IP column shows your own address, forwarding is trusted. If it shows the proxy's address for everyone, it is not.

Plain HTTP breaks the web client

The console works fine over plain HTTP. The in-browser client does not, and it cannot be made to: crypto.subtle and VideoDecoder are only available in a secure context, so the handshake fails before the session opens. Getting a trusted certificate is the entire fix.

Details and the options for internal networks that have no public DNS: Web client and HTTPS.

Troubleshooting

502 from the proxy. The proxy cannot reach 8080. From a Compose setup, docker compose exec <proxy> wget -qO- http://cortendesk:8080/login — if that fails the two services are not on the same network.

Console loads, web client will not connect. Open the browser console. A failed wss:// handshake means the proxy is not forwarding /ws/id and /ws/relay, or CORTENDESK_WS_*_URL points somewhere the browser cannot reach. A 502 on those paths means the container can't reach hbbs/hbbr on 21118/21119 — check RUSTDESK_WS_HOST and the firewall on the RustDesk server.

Web-client sessions drop after about a minute of inactivity. The proxy's read timeout. nginx defaults to 60s; raise proxy_read_timeout on /ws/.

Sign-in redirects back to the login page. X-Forwarded-Proto is not getting through or the proxy is not trusted. Confirm APP_URL is https:// and SESSION_SECURE_COOKIE=true.

Every device shows the same IP, or logins are rate-limited constantly. The proxy is not in the trusted list — see Trusted proxies.

Certificate issuance fails. For HTTP-01, port 80 must be open from the internet and DNS must already point here. If neither is possible, use a DNS-01 challenge instead — covered in Web client and HTTPS.

Clone this wiki locally