Chapter 3 of 15
Reverse Proxy Examples
Overview
PatchMon 2.0 runs as a single Docker container that listens on port 3000 inside the container. The REST API, the embedded React frontend, and all WebSocket endpoints are all served from that one port. In front of it you put a reverse proxy for TLS termination, HTTP/2, and a stable public hostname.
This page has ready-to-use snippets for the most common self-hosted reverse proxies:
All snippets assume the PatchMon container is reachable at http://patchmon:3000 (Docker service name) or http://<host>:3000 (bare-metal / VM). Substitute as appropriate.
What Your Proxy Must Do
Every PatchMon reverse proxy, regardless of flavour, must handle four things correctly:
- Terminate TLS on the public hostname (
patchmon.example.com) and forward to the server on plain HTTP. - Upgrade WebSocket connections. PatchMon uses long-lived WebSockets for:
- Agent control channel:
/api/v1/agents/ws - Browser SSH terminal:
/api/v1/ssh-terminal/{hostId} - Browser RDP tunnel:
/api/v1/rdp/websocket-tunnel - Live patch-run log stream:
/api/v1/patching/runs/{id}/stream
- Agent control channel:
- Forward the original protocol via
X-Forwarded-Proto: https. The server reads this header to know the connection is secure and to construct correctwss://URLs for agents. - Use a long read timeout (86400 seconds / 24 hours). The agent control channel is an idle-tolerant connection that sends pings every 30 seconds; most proxies default to a 60-second idle timeout and will drop the connection before the agent can detect the disconnect.
If you also want the server to pick up the real client IP for logging and rate-limiting (instead of the proxy's IP), set TRUST_PROXY=true in the PatchMon .env. See the Environment Variables Reference for details.
WebSocket endpoints to verify
When you first wire up a proxy, test these four endpoints from a browser or agent and confirm they stay connected. All four require the same upgrade-and-long-timeout treatment:
| Endpoint | Used by | Auth |
|---|---|---|
/api/v1/agents/ws |
PatchMon agent | X-API-ID + X-API-KEY headers |
/api/v1/ssh-terminal/{hostId} |
Browser SSH terminal | Short-lived ticket in ?ticket=... |
/api/v1/rdp/websocket-tunnel |
Browser RDP (Guacamole) | Short-lived ticket |
/api/v1/patching/runs/{id}/stream |
Patch-run live log UI | JWT cookie / bearer |
If your agents show as "connecting" and then drop every few minutes, the read timeout is almost certainly too short.
Nginx
A minimal production block for a single PatchMon instance behind Nginx with TLS from Let's Encrypt:
# /etc/nginx/sites-available/patchmon.conf
#
# Put the WebSocket upgrade map in the http block (e.g. nginx.conf) or at the
# top of this file inside any 'http' context you manage.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name patchmon.example.com;
# Redirect everything to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name patchmon.example.com;
ssl_certificate /etc/letsencrypt/live/patchmon.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/patchmon.example.com/privkey.pem;
# Modern TLS profile; adjust to taste
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Allow large agent reports (packages, Docker inventory) through the proxy.
# The server also enforces its own JSON_BODY_LIMIT / AGENT_UPDATE_BODY_LIMIT.
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3000;
# Required for WebSockets (agent WS, SSH terminal, RDP, patch stream)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Preserve original host + client info
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 X-Forwarded-Host $host;
# Long-lived WebSockets — 24h idle timeout so agent connections
# aren't dropped by the proxy. PatchMon sends its own keepalive pings.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 60s;
# Do not buffer Server-Sent Events or streaming responses
proxy_buffering off;
proxy_cache off;
}
}
Enable and reload:
sudo ln -s /etc/nginx/sites-available/patchmon.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Tip: If PatchMon is on a different host from Nginx, replace
127.0.0.1:3000with the container's reachable address. Put the PatchMon container and Nginx on the same Docker network and use the service name for the cleanest setup.
Caddy
Caddy handles TLS certificates, HTTP/2, and WebSocket upgrades automatically. The entire Caddyfile is usually four lines:
# /etc/caddy/Caddyfile
patchmon.example.com {
reverse_proxy 127.0.0.1:3000 {
# 24h timeout for long-lived agent WebSockets.
# PatchMon sends its own pings; this just keeps Caddy from dropping
# an otherwise-healthy idle connection.
transport http {
read_timeout 86400s
write_timeout 86400s
}
}
}
That's the full configuration. Caddy:
- Fetches and renews the TLS certificate from Let's Encrypt automatically.
- Sets
X-Forwarded-ProtoandX-Forwarded-Forby default. - Upgrades WebSocket connections transparently.
Reload:
sudo systemctl reload caddy
Docker Compose snippet
If you run Caddy in Docker alongside PatchMon:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- patchmon-internal
volumes:
caddy_data:
caddy_config:
And point the Caddyfile at the compose service name:
patchmon.example.com {
reverse_proxy server:3000 {
transport http {
read_timeout 86400s
write_timeout 86400s
}
}
}
Traefik
Traefik works well with Docker Compose because it discovers services via container labels.
docker-compose.yml: minimal
name: patchmon
services:
traefik:
image: traefik:v3
restart: unless-stopped
command:
- "--api.dashboard=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Redirect http -> https
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "--certificatesresolvers.le.acme.email=admin@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- ./letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- patchmon-internal
server:
image: ghcr.io/patchmon/patchmon-server:latest
restart: unless-stopped
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.patchmon.rule=Host(`patchmon.example.com`)"
- "traefik.http.routers.patchmon.entrypoints=websecure"
- "traefik.http.routers.patchmon.tls.certresolver=le"
- "traefik.http.services.patchmon.loadbalancer.server.port=3000"
networks:
- patchmon-internal
depends_on:
- database
- redis
networks:
patchmon-internal:
driver: bridge
The long-read-timeout middleware
Traefik defaults to very short per-request timeouts. For the agent WebSocket to stay alive, add a serversTransport with a 24-hour read timeout and bind it to the service. Put this in a static config (a file referenced by --providers.file or a CLI flag, because you cannot set serversTransport from container labels):
# /etc/traefik/dynamic.yml
http:
serversTransports:
patchmon-longtimeout:
forwardingTimeouts:
dialTimeout: "30s"
responseHeaderTimeout: "0s" # disable response header timeout
idleConnTimeout: "86400s"
services:
patchmon:
loadBalancer:
serversTransport: patchmon-longtimeout
servers:
- url: "http://server:3000"
And tell Traefik to load it:
# in the traefik command block
- "--providers.file.filename=/etc/traefik/dynamic.yml"
# mount it
volumes:
- ./dynamic.yml:/etc/traefik/dynamic.yml:ro
Traefik's default read/write timeouts are fine for normal HTTP, but they will drop the long-lived agent WebSocket. The 24-hour
idleConnTimeoutand a zeroedresponseHeaderTimeoutare the pieces that make it behave.
Traefik automatically:
- Terminates TLS at the
websecureentry point. - Forwards
X-Forwarded-Proto,X-Forwarded-For, andX-Forwarded-Host. - Upgrades WebSocket connections when the client sends
Upgrade: websocket.
Nginx Proxy Manager
Nginx Proxy Manager (NPM) is a popular self-hosted web UI for managing Nginx reverse-proxy entries. It handles most of PatchMon's needs in two toggles, but the default read timeout is too short for long-lived agent WebSockets.
Step-by-step
- In NPM, create a new Proxy Host pointing at the PatchMon container (scheme
http, hostnamepatchmonor the host IP, port3000). - On the Details tab, enable:
- Block Common Exploits
- Websockets Support
- Attach your SSL certificate on the SSL tab and enable Force SSL and HTTP/2 Support.
- On the Advanced tab, paste the following snippet to extend the read timeout for agent WebSockets and for live patch log streaming:
# PatchMon — extend read timeout for long-lived WebSockets
# (agent control channel, SSH terminal, RDP tunnel, patch stream)
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
proxy_request_buffering off;
# Ensure X-Forwarded-Proto is set correctly for HTTPS detection inside the app.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
- Save. Test the URL in a browser and, once the UI loads, enrol an agent and watch it stay online in the Hosts page for longer than a few minutes. That is the real test.
Tip: If you use Cloudflare or any other intermediate proxy, it must also be configured for WebSocket pass-through. Cloudflare has WebSockets enabled by default; other CDNs may need an explicit opt-in.
Verifying Your Setup
Once the proxy is live, verify all four WebSocket endpoints in turn.
1. Browser UI loads over HTTPS
Open https://patchmon.example.com. You should see the PatchMon login page served with a valid certificate and no mixed-content warnings.
2. Agent control channel
Enrol one agent using the install command from Hosts → Add Host → Install. Within a few seconds the host should appear in the hosts list with a green online WebSocket indicator. Leave it running for at least 10 minutes; if it disconnects in that window, your proxy's read timeout is too short.
Check the agent logs on the target server:
sudo journalctl -u patchmon-agent -n 50
You want to see WebSocket connected and no repeated reconnect loops.
3. Live patch streaming
Trigger a dry-run patch on any host from the UI. The output pane should stream stdout/stderr as the command runs. If it hangs with no output and then prints everything at once at the end, the proxy is buffering. Re-check proxy_buffering off (Nginx, NPM) or proxy_request_buffering off (NPM).
4. SSH terminal
Open Hosts → → SSH Terminal. The terminal should connect and echo keystrokes in real time. If the terminal connects and then hangs after 30–60 seconds, the issue is again the read timeout.
Common Pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| Agents reconnect every ~60 seconds | Proxy read timeout too short | Set to 86400s |
| Live patch output arrives all at once | Proxy buffering enabled | proxy_buffering off |
wss:// URLs try http:// inside the agent install script |
X-Forwarded-Proto missing or wrong |
Explicitly set to $scheme (Nginx) / default in Caddy + Traefik |
| Browser console: "CORS policy" errors | CORS_ORIGIN does not match the URL in the address bar |
Set CORS_ORIGIN=https://patchmon.example.com exactly. To allow more than one origin, comma-separate with no spaces, e.g. CORS_ORIGIN=https://patchmon.example.com,https://patchmon.internal.lan |
| Login works but nothing loads | API requests going to a different origin | Send all traffic (API + SPA) to the same hostname/port |
| Sudden 413 Request Entity Too Large | Proxy body limit smaller than agent report | client_max_body_size 20m; (Nginx) or equivalent |
| Agent page shows "offline" but agent logs say connected | Reverse proxy is not sending X-Forwarded-For, or TRUST_PROXY=false was set explicitly |
Ensure the reverse proxy adds X-Forwarded-For and leave TRUST_PROXY at its default of true |
See Also
- Installing PatchMon Server on Docker: the upstream compose file this page sits in front of
- PatchMon Environment Variables Reference: details on
CORS_ORIGIN,TRUST_PROXY,ENABLE_HSTS - WebSockets architecture: how PatchMon uses WebSockets under the hood