Reverse Proxy / Load Balancer
MezHub can be deployed behind a TLS-terminating reverse proxy or load balancer. This is the recommended production topology — the load balancer handles TLS certificates, provides DDoS protection, and allows you to scale horizontally with multiple MezHub instances.
Architecture
When deployed behind a load balancer, the traffic flow is:
Client Load Balancer MezHub
| | |
|---[TLS]--- HTTPS :443 --->|---[plaintext]--- :3080 ->| Web UI + API
|---[TLS]--- SSH :3023 ---->|---[TCP]--------- :3023 ->| SSH proxy
|---[TLS]--- Tunnel :3024 ->|---[TCP]--------- :3024 ->| Agent reverse tunnels
|---[TLS]--- gRPC :3025 --->|---[h2c]--------- :3025 ->| Auth gRPC API
The load balancer terminates TLS on the public ports and forwards
cleartext upstream to MezHub. MezHub's gRPC auth listener (port 3025) is
the only port that needs an explicit toggle to accept cleartext: set MEZITE_GRPC_ALLOW_HTTP=true (or
auth.grpc_allow_http: true in YAML) to wrap the gRPC server in
an h2c handler. To skip mTLS on the gRPC port entirely (e.g. when the LB terminates
TLS without re-encrypting), also set
MEZITE_AUTH_H2C=true. The proxy's HTTPS listener (3080) can
be flipped to cleartext via proxy.tls_mode: "off" in YAML. Clients
still see TLS — the LB handles the encryption on the public side.
Why h2c for gRPC?
gRPC requires HTTP/2. When a load balancer terminates TLS, the connection between the load balancer and MezHub is plaintext. Standard HTTP/2 requires TLS (via ALPN negotiation), so plaintext HTTP/2 needs a special mode called h2c (HTTP/2 cleartext).
Two env vars control the gRPC port:
-
MEZITE_GRPC_ALLOW_HTTP=true— wraps the gRPC server in an h2c handler so it accepts h2c prior-knowledge (direct gRPC clients that send HTTP/2 without negotiation) and HTTP/1.1 Upgrade: h2c (reverse proxies that upgrade). mTLS credentials are still loaded. -
MEZITE_AUTH_H2C=true— additionally disables TLS on the gRPC port (the listener becomes plaintext). Use this when the LB terminates TLS and you do not want re-encryption between the LB and MezHub.
For most reverse-proxy deployments, set
MEZITE_GRPC_ALLOW_HTTP=true; add
MEZITE_AUTH_H2C=true only if the LB does not re-encrypt upstream.
MezHub Configuration
cluster_name: my-cluster
data_dir: /var/lib/mezite
database:
driver: sqlite # or: postgres
url: /data/mezhub.db # or: postgres://user:pass@host/mezite
auth:
enabled: true
listen_addr: 0.0.0.0:3025
proxy:
enabled: true
listen_addr: 0.0.0.0:3080
ssh_listen_addr: 0.0.0.0:3023
tunnel_listen_addr: 0.0.0.0:3024
# The public address that clients use to reach this cluster.
# This must match the hostname that resolves to your load balancer.
public_addr: mezite.example.com
# Disable TLS on the proxy — the load balancer handles TLS.
tls_mode: "off" # Enable h2c (HTTP/2 cleartext) on the gRPC auth port (3025).
# This is the targeted option — mTLS credentials stay loaded.
MEZITE_GRPC_ALLOW_HTTP=true
# Add this only if the LB terminates TLS and does NOT re-encrypt
# upstream. It disables TLS on the gRPC port entirely.
# MEZITE_AUTH_H2C=true
To trust an HTTP header injected by the LB (for rate limiting and the
client_ip audit field), set
proxy.trusted_ip_header in mezite.yaml — this knob
is YAML-only, there is no env-var binding for it. The header only applies
to the HTTPS proxy listener; for SSH, tunnel, and any L4 passthrough setup,
use PROXY protocol (next section).
proxy:
trusted_ip_header: X-Forwarded-For # or: Fly-Client-IP, CF-Connecting-IP, etc. Preserving the real client IP — PROXY protocol v2
proxy.trusted_ip_header only works on HTTP listeners: it reads
an HTTP header the load balancer injects. For the SSH, tunnel, and (in single-port
mode) gRPC listeners there is no HTTP layer, so an HTTP header can't carry
the source IP. Any deployment where the load balancer operates in TLS passthrough
/ L4 mode (e.g. AWS NLB with TCP targets, HAProxy in mode tcp) hits the same limit even on the HTTPS port: the LB never sees
cleartext, so it can't add headers.
MezHub's listeners support PROXY protocol v1 and v2 — a binary header the LB prepends to each TCP connection that carries the original client address. It survives TLS passthrough because it sits in front of the TLS handshake rather than inside HTTP. Enable it on the listener and configure your LB to send it on every port you expose through MezHub.
proxy:
proxy_protocol: "on"
# Only peers whose direct TCP source IP falls in one of these CIDRs
# are allowed to send PROXY headers. Keep this as tight as the LB's
# actual egress range — any host inside the CIDR that can reach the
# listener could otherwise forge client_ip in the audit log by
# sending a crafted PROXY header.
proxy_protocol_trusted_cidrs:
- 10.0.0.0/24 # example: the LB's private subnet
- 192.0.2.5/32 # example: a single-IP LB
Peers whose source IP is NOT in the trusted list are rejected if they
send a PROXY header — so an attacker who can reach the port directly
can't spoof client_ip. Trusted peers that send a connection without a PROXY header fall through to the raw TCP address, which means in-cluster
health probes (kubelet, etc.) continue to work when you flip the flag on.
Once enabled, every downstream code path that previously saw the LB's IP
— audit event client_ip, the per-IP connection limiter,
role IP pinning — transparently sees the real caller. No code or query
changes needed.
Port Mapping
Your load balancer needs to forward traffic to MezHub's internal ports. The mapping depends on whether you expose each service on its standard port or consolidate onto fewer external ports.
Standard Port Mapping
| Service | External Port | Internal Port | Protocol | LB Config |
|---|---|---|---|---|
| Web UI + API | 443 | 3080 | HTTPS | TLS termination, forward HTTP |
| SSH Proxy | 3023 | 3023 | TCP+TLS | TLS termination, forward TCP |
| Agent Tunnel | 3024 | 3024 | TCP+TLS | TLS termination, forward TCP |
| gRPC Auth | 3025 | 3025 | gRPC over TLS | TLS termination, forward h2c (HTTP/2 cleartext) |
Agent Configuration
Agents connecting through a TLS-terminating load balancer need
MEZITE_TLS_WRAP=true. This wraps the tunnel connection in
TLS so the load balancer can terminate it, then the SSH tunnel protocol
runs over the plaintext connection inside.
# Connect to the load balancer's tunnel port
MEZITE_PROXY_ADDR=mezite.example.com:3024
# Auth gRPC address (through the load balancer)
MEZITE_AUTH_ADDR=mezite.example.com:3025
# Wrap the tunnel in TLS for the load balancer
MEZITE_TLS_WRAP=true
# Standard agent config
MEZITE_JOIN_TOKEN=<your-join-token>
MEZITE_NODE_NAME=web-server-01
MEZITE_DATA_DIR=/var/lib/mezite
Without MEZITE_TLS_WRAP=true, the agent sends raw SSH
protocol on the tunnel port. The load balancer wouldn't know how to
handle this (it expects TLS on the external port). With TLS wrapping,
the agent sends TLS on the wire, the load balancer terminates it, and
the plaintext SSH tunnel protocol reaches MezHub.
Client Configuration
Clients (msh, mezctl) connect to the load
balancer's external address. No special flags are needed — TLS is
handled by the load balancer, and clients use standard TLS to connect.
# Login (connects to LB on :443, LB forwards to :3080)
msh login --proxy=mezite.example.com
# SSH (connects to LB on :3023, LB forwards to :3023)
msh ssh --login=ubuntu web-server-01
# Admin CLI (connects to LB on :3025, LB forwards h2c to :3025)
mezctl --auth-server=mezite.example.com:3025 nodes list Nginx Example
# HTTPS → MezHub proxy (Web UI + API)
server {
listen 443 ssl;
server_name mezite.example.com;
ssl_certificate /etc/ssl/mezite.crt;
ssl_certificate_key /etc/ssl/mezite.key;
location / {
proxy_pass http://mezhub:3080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (for web terminal)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# gRPC → MezHub auth (port 3025)
server {
listen 3025 ssl http2;
server_name mezite.example.com;
ssl_certificate /etc/ssl/mezite.crt;
ssl_certificate_key /etc/ssl/mezite.key;
location / {
grpc_pass grpc://mezhub:3025;
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# SSH proxy (port 3023) — TCP passthrough with TLS termination
stream {
upstream mezhub_ssh {
server mezhub:3023;
}
server {
listen 3023 ssl;
ssl_certificate /etc/ssl/mezite.crt;
ssl_certificate_key /etc/ssl/mezite.key;
proxy_pass mezhub_ssh;
}
# Agent tunnel (port 3024) — TCP passthrough with TLS termination
upstream mezhub_tunnel {
server mezhub:3024;
}
server {
listen 3024 ssl;
ssl_certificate /etc/ssl/mezite.crt;
ssl_certificate_key /etc/ssl/mezite.key;
proxy_pass mezhub_tunnel;
}
} Troubleshooting
Agent tunnel fails with "SSH handshake: EOF"
The agent connected to the wrong port. Make sure MEZITE_PROXY_ADDR
points to the tunnel port (3024), not the HTTPS port (443).
The HTTPS port serves the Web UI and API — it does not speak the SSH tunnel
protocol.
mezctl returns "connection refused" or TLS errors
mezctl connects directly to the gRPC port (3025). Make sure your
load balancer exposes port 3025 and forwards it as h2c (HTTP/2 cleartext)
to MezHub's internal port 3025.
Agent recording upload fails with 502
The agent uploads recordings via gRPC on port 3025. If your load
balancer does not support HTTP/2 on this port, the upload will fail.
Ensure the load balancer is configured for gRPC/h2c on port 3025 and set MEZITE_GRPC_ALLOW_HTTP=true on MezHub so its gRPC server accepts h2c (add MEZITE_AUTH_H2C=true as well if the LB does not re-encrypt upstream).
Next Steps
- SSH Access — Set up agents and connect via SSH.
- Session Recording — Configure recording modes and storage.
- Configuration Reference — Full list of environment variables and config options.