Skip to content

Trusted proxies

Paron limits the requests and the connections from each client address.

When a proxy is in front of Paron, all connections come from the address of the proxy. Paron then counts all users as one client, and the requests of one user can use the limit of all of them.

No proxy User 1 User 2 User 3 Paron server one limit for each user Proxy, not trusted User 1 User 2 User 3 Proxy Proxy Paron server one limit for all users Trusted proxy User 1 User 1 User 2 User 2 User 3 User 3 Proxy Paron server one limit for each user

To prevent this, tell Paron which proxies to trust. Paron then gets the address of each client from the proxy.

A TCP proxy passes the TLS connection to Paron and does not read it.

Browser 203.0.113.5 TCP proxy 10.0.0.7 does not end TLS PROXY 203.0.113.5 Paron server ends TLS

To give Paron the address of the client, the proxy must send the PROXY protocol header at the start of each connection. Paron reads version 1 and version 2 of this header. Give Paron the address of the proxy:

Terminal window
paron server run --trusted-proxies 10.0.0.7,192.168.0.0/24
Sender PROXY header Result
In --trusted-proxies Yes Paron uses the client address from the header.
In --trusted-proxies No Paron uses the address of the sender.
Not in --trusted-proxies Yes The connection fails.
Not in --trusted-proxies No Paron uses the address of the sender.

When --trusted-proxies has a value, Paron logs the warning not a trusted L4 proxy, PROXY protocol forwarding is ignored for the first connection from an address that is not in the list. The warning shows one time, with or without a PROXY header.

No other setting is necessary. Paron gets its certificate and accepts tunnels through the proxy.

docker-compose.yml
services:
haproxy:
image: haproxy:lts-alpine
restart: unless-stopped
depends_on:
- paron-server
configs:
- source: haproxy
target: /usr/local/etc/haproxy/haproxy.cfg
ports:
- "443:443/tcp"
networks:
- paron
paron-server-init:
image: ghcr.io/nicolabovolato/paron:latest
command: ["server", "bootstrap"]
volumes:
- paron-server-data:/data
network_mode: none
paron-server:
image: ghcr.io/nicolabovolato/paron:latest
command: ["server", "run"]
restart: unless-stopped
stop_grace_period: 30s
depends_on:
paron-server-init:
condition: service_completed_successfully
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
volumes:
- paron-server-data:/data
# The proxy carries only TCP, so a tunnel uses TLS over TCP. To give tunnels QUIC, publish the
# UDP port from Paron itself:
# ports:
# - "443:8443/udp"
networks:
- paron
environment:
PARON_PUBLIC_URL: https://paron.example.com
PARON_LETSENCRYPT: production
PARON_ACME_CONTACT: admin@example.com
# The network of the proxy. Paron reads the PROXY protocol header only from these addresses.
PARON_TRUSTED_PROXIES: 172.30.0.0/24
networks:
paron:
ipam:
config:
- subnet: 172.30.0.0/24
volumes:
paron-server-data:
configs:
haproxy:
content: |
defaults
mode tcp
# HAProxy needs these three. Paron sends a keep-alive on each terminal and each tunnel
# every 30 seconds, so 2 minutes with no data means that the connection is dead.
timeout connect 5s
timeout client 2m
timeout server 2m
frontend https
bind :443
default_backend paron
backend paron
# send-proxy-v2 adds the PROXY protocol header.
server paron paron-server:8443 send-proxy-v2

Save the file as docker-compose.yml, set PARON_PUBLIC_URL, and start the services:

Terminal window
docker compose up -d

A tunnel tries QUIC first, and QUIC uses UDP. See Transport. These proxies carry only TCP, so you have two options:

Option Result
Publish only the proxy Paron is not exposed directly. Tunnels use TLS over TCP through the proxy.
Also publish UDP port 443 from Paron Tunnels use QUIC. Paron is exposed directly on that port.

Each example has the lines for the second option as a comment.

An HTTP proxy ends the TLS connection of the browser and opens its own connection to Paron.

  • The browser gets the certificate of the proxy. So the proxy needs the public certificate, and Paron uses its own certificate.
  • Paron gets the address of the proxy. The proxy sends the address of the client in a header.
  • A tunnel is not HTTP, so the proxy cannot end its TLS connection. For tunnels, the proxy must be a TCP proxy.
Proxy Paron server Browser HTTPS HTTP (L7) ends TLS HTTPS X-Forwarded-For Machine Paron tunnel TCP (L4) does not end TLS Paron tunnel PROXY header

The proxy sends connections of both kinds, so give Paron its address in both lists:

Terminal window
paron server run \
--trusted-l7-proxies 10.0.0.7 \
--trusted-proxies 10.0.0.7
Connection The proxy must Reason
Web UI Connect to Paron with HTTPS Paron serves HTTPS only. In a private network, the proxy can accept the certificate of Paron with no check.
Web UI Pass WebSocket connections The terminal is a WebSocket.
Web UI Set or remove the address headers Paron reads the client address from them.
Tunnel Pass the connection as it is, with the PROXY protocol header The machine accepts only the key of Paron.

The proxy finds a tunnel by the protocol name in the first TLS message. A tunnel announces ptp1.

Paron reads the client address from three headers: X-Forwarded-For, Forwarded and X-Real-IP. It reads them only when the connection comes from an address in --trusted-l7-proxies.

A header can have a list of addresses, one for each proxy that the request went through. Paron starts at the end of the list and uses the first address that is not in --trusted-l7-proxies.

docker-compose.yml
services:
nginx:
image: nginx:stable-alpine
restart: unless-stopped
depends_on:
- paron-server
configs:
- source: nginx
target: /etc/nginx/nginx.conf
volumes:
- nginx-acme:/var/cache/nginx/acme
ports:
- "443:443/tcp"
networks:
- paron
paron-server-init:
image: ghcr.io/nicolabovolato/paron:latest
command: ["server", "bootstrap"]
volumes:
- paron-server-data:/data
network_mode: none
paron-server:
image: ghcr.io/nicolabovolato/paron:latest
command: ["server", "run"]
restart: unless-stopped
stop_grace_period: 30s
depends_on:
paron-server-init:
condition: service_completed_successfully
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
volumes:
- paron-server-data:/data
# The proxy carries only TCP, so a tunnel uses TLS over TCP. To give tunnels QUIC, publish the
# UDP port from Paron itself:
# ports:
# - "443:8443/udp"
networks:
- paron
environment:
PARON_PUBLIC_URL: https://paron.example.com
# The network of the proxy. The proxy passes tunnels through with the PROXY protocol header,
# and it sends the web UI as HTTP requests with the X-Forwarded-For header.
PARON_TRUSTED_PROXIES: 172.30.0.0/24
PARON_TRUSTED_L7_PROXIES: 172.30.0.0/24
networks:
paron:
ipam:
config:
- subnet: 172.30.0.0/24
volumes:
paron-server-data:
nginx-acme:
configs:
nginx:
content: |
load_module modules/ngx_http_acme_module.so;
events {}
stream {
upstream tunnels { server paron-server:8443; }
upstream web { server 127.0.0.1:8444; }
# A tunnel announces the protocol ptp1. It goes to Paron as it is.
map $$ssl_preread_alpn_protocols $$target {
~\bptp1\b tunnels;
default web;
}
server {
listen 443;
ssl_preread on;
proxy_pass $$target;
proxy_protocol on;
}
}
http {
map $$http_upgrade $$connection_upgrade {
default upgrade;
"" close;
}
# nginx gets the certificate from Let's Encrypt and renews it. Set your e-mail address in
# "contact", and your domain in "server_name" below.
resolver 127.0.0.11;
acme_shared_zone zone=acme:1M;
acme_issuer letsencrypt {
uri https://acme-v02.api.letsencrypt.org/directory;
contact admin@example.com;
challenge tls-alpn-01;
state_path /var/cache/nginx/acme;
accept_terms_of_service;
}
server {
listen 127.0.0.1:8444 ssl proxy_protocol;
http2 on;
server_name paron.example.com;
acme_certificate letsencrypt;
ssl_certificate $$acme_certificate;
ssl_certificate_key $$acme_certificate_key;
ssl_certificate_cache max=2;
location / {
# Paron serves HTTPS only, with its own certificate.
proxy_pass https://paron-server:8443;
proxy_http_version 1.1;
proxy_set_header Host $$host;
# The terminal is a WebSocket.
proxy_set_header Upgrade $$http_upgrade;
proxy_set_header Connection $$connection_upgrade;
# Paron reads these three headers. Set the real address, and remove the other two.
proxy_set_header X-Forwarded-For $$proxy_protocol_addr;
proxy_set_header Forwarded "";
proxy_set_header X-Real-IP "";
}
}
}

Save the file as docker-compose.yml, set PARON_PUBLIC_URL, and start the services:

Terminal window
docker compose up -d

The UDP port is a comment in these files too, for the same reason as above.

© 2026 Nicola Bovolato. Paron is source-available under the PolyForm Shield License 1.0.0.