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.

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: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 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