Skip to main content
PathMon

Chapter 3 of 15

Reverse Proxy Examples

Updated Read the full guide

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:

  1. Terminate TLS on the public hostname (patchmon.example.com) and forward to the server on plain HTTP.
  2. 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
  3. Forward the original protocol via X-Forwarded-Proto: https. The server reads this header to know the connection is secure and to construct correct wss:// URLs for agents.
  4. 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.

Real client IP: which proxies PatchMon trusts

PatchMon uses the client IP for rate limiting, login lockout and audit logs. With TRUST_PROXY=true (the default) it reads X-Forwarded-For, but only when the connection comes from a proxy it trusts:

  • TRUSTED_PROXY_RANGES empty (default): the header is trusted from loopback (127.0.0.0/8, ::1), private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), link-local and CGNAT (100.64.0.0/10, which includes Tailscale addresses) peers. This covers Nginx Proxy Manager, Traefik or Caddy in Docker, and a proxy on the same host or LAN, with no configuration.
  • TRUSTED_PROXY_RANGES set: the header is trusted only from peers in that list. The built-in private ranges no longer apply, so list your own reverse proxy's address as well as any proxies in front of it.

A client that connects to PatchMon directly from any other address cannot change its IP by sending its own X-Forwarded-For; PatchMon uses the connecting address and logs a warning, once for each such address (up to 10): ignoring X-Forwarded-For from an untrusted peer.

When the default is not enough: with TRUSTED_PROXY_RANGES empty, anything that reaches PatchMon from a private address is trusted to send X-Forwarded-For. Some setups make every client look private, so a client could still set its own IP: rootless Podman (slirp4netns or pasta), Docker's userland proxy for IPv6 published ports, Kubernetes Services that SNAT traffic, and users on the same LAN reaching PatchMon's port directly without going through the proxy. In those setups, set TRUSTED_PROXY_RANGES to your reverse proxy's exact address (for example 172.18.0.5/32), or only publish PatchMon's port to the proxy.

Proxy on a public IP, or a CDN connecting directly to PatchMon. If the proxy that connects to PatchMon has a public address (for example Cloudflare or another CDN pointing straight at your server's port, or a proxy on a different public host), its header is ignored until you list it. Set TRUSTED_PROXY_RANGES to that proxy's addresses, for example your CDN's published IP ranges. If you see the warning above naming your proxy's address, this is the fix.

Chained proxies (CDN in front of Nginx Proxy Manager). List every hop, including Nginx Proxy Manager's own address on the Docker network, for example TRUSTED_PROXY_RANGES=172.16.0.0/12,<your CDN's ranges>. PatchMon then walks past the trusted hops to the original client.

X-Forwarded-Proto handling is not affected by these settings. See the Environment Variables Reference for TRUST_PROXY and TRUSTED_PROXY_RANGES.


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, compliance scan
    # results) through the proxy. The server also enforces its own limits: see
    # JSON_BODY_LIMIT, AGENT_UPDATE_BODY_LIMIT and COMPLIANCE_BODY_LIMIT.
    client_max_body_size 40m;

    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:3000 with 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-Proto and X-Forwarded-For by 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 idleConnTimeout and a zeroed responseHeaderTimeout are the pieces that make it behave.

Traefik automatically:

  • Terminates TLS at the websecure entry point.
  • Forwards X-Forwarded-Proto, X-Forwarded-For, and X-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

  1. In NPM, create a new Proxy Host pointing at the PatchMon container (scheme http, hostname patchmon or the host IP, port 3000).
  2. On the Details tab, enable:
    • Block Common Exploits
    • Websockets Support
  3. Attach your SSL certificate on the SSL tab and enable Force SSL and HTTP/2 Support.
  4. 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;
  1. 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 or compliance scan result client_max_body_size 40m; (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