-
Notifications
You must be signed in to change notification settings - Fork 4
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.
- Terminate TLS for the console hostname and forward to the container on 8080.
- Pass WebSocket upgrades through on
/ws/idand/ws/relay. - Send
X-Forwarded-ForandX-Forwarded-Proto. - Be in the app's trusted-proxy list, or those headers are ignored.
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.
| 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.
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.
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-stoppedIf 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-stoppedNo 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.
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 nginxmap 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.comIf 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.
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_ipis 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.
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 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.
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.
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.
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.