Run behind a reverse proxy
Use this guide when LinkMesh is exposed via a public hostname with a Let’s Encrypt certificate, but Collectors connect through that same hostname over the internet. If your Collectors can reach the LinkMesh VM directly (VPC, VPN, bastion, or flat network), the direct VM install is simpler — skip this page.
LinkMesh speaks two protocols on two ports:
- HTTP API on
:8080— the web UI, REST API, and the OpAMP WebSocket endpoint at/v1/opampthat OpAMP-managed Collectors connect to. Terminate TLS at your proxy. - gRPC on
:50051— the mTLS channellinkmesh-agent-managed Collectors use to connect. Pass through at the TCP layer. Do not terminate.
Two snags catch operators here, both addressed in the nginx config below:
- The default gRPC listener binds
:50051on every interface, so nginx can’t claim that port for itsstreamblock. You’ll move LinkMesh’s internal gRPC listener to a separate port (we use:50061below) and let nginx own the public:50051socket. See step 3. - The
/v1/opampendpoint upgrades to WebSocket. The nginx server block must explicitly forward theUpgrade/Connectionheaders and must not negotiate HTTP/2 on the same listener (HTTP/2 has noConnection: Upgradesemantics, so anhttp2listener returns400 Bad Requestfor every OpAMP handshake). The snippet below handles both.
Three steps
-
Install the package as in the direct VM install.
-
Add your public hostname to the gRPC server cert SANs. Cert subcommands need exclusive database access, so stop the service first; run as the
linkmeshuser so the service can read the new key:Terminal window sudo systemctl stop linkmesh-serversudo runuser -u linkmesh -- linkmesh-server cert init --san dns:linkmesh.example.com -
Tell LinkMesh about its public URLs and which proxies to trust:
Terminal window sudo nano /etc/linkmesh/config.yamlsudo systemctl restart linkmesh-server
The relevant config.yaml snippet
externalUrl: "https://linkmesh.example.com"http: trustedProxies: ["127.0.0.1/32", "10.0.0.0/8"]grpc: port: 50061 externalUrl: "linkmesh.example.com:50051"externalUrlis what LinkMesh prints in enrollment scripts and emails. Must match what users type in their browser.http.trustedProxiestells LinkMesh whichX-Forwarded-*headers to honor. List your proxy’s loopback / VPC ranges.grpc.portis the internal port LinkMesh’s own gRPC listener binds to. Move it off the default50051(anything free works — we use50061) so nginx’sstreamblock can take the public:50051socket. Without this,nginx -treloads cleanly but the service fails to start withbind() to 0.0.0.0:50051 failed (98: Address already in use)because LinkMesh’s gRPC server already owns that port on every interface.grpc.externalUrlis the address agents dial — host and public gRPC port. It must point at nginx’sstreamlistener (:50051, public), not the internalgrpc.portand not the HTTPS API port. The host must match a SAN you added in step 2 (it’s used to verify the server cert) and is printed into bootstrapped agent configs.
Install nginx + issue a Let’s Encrypt certificate
On Debian/Ubuntu, install nginx (its built-in stream module handles the
gRPC pass-through — no extra package needed) and certbot:
-
Install nginx and certbot:
Terminal window sudo apt-get updatesudo apt-get install -y nginx certbot -
Point DNS at the proxy and open the firewall. Add an
A/AAAArecord forlinkmesh.example.com→ the proxy’s public IP, and allow inbound 80, 443, and 50051. Port 80 is only needed for the ACME challenge and the HTTP→HTTPS redirect. -
Issue the certificate with certbot’s standalone server. It binds port 80, so stop nginx for the ~10 seconds it takes:
Terminal window sudo systemctl stop nginxsudo certbot certonly --standalone -d linkmesh.example.com \--agree-tos -m you@example.com --non-interactive \--deploy-hook "systemctl reload nginx"sudo systemctl start nginxThis writes
fullchain.pem+privkey.pemto/etc/letsencrypt/live/linkmesh.example.com/— the exact paths the nginx config below references. Certbot’s systemd timer auto-renews; the--deploy-hookreloads nginx so it picks up each renewed cert.
nginx config — HTTPS terminate for HTTP API + stream pass-through for gRPC
The HTTP API is plain HTTP between nginx and LinkMesh on :8080; nginx
terminates TLS at the edge with your Let’s Encrypt cert. The gRPC channel
on :50051 is passed through at the TCP layer — nginx does not terminate it.
The OpAMP endpoint is a WebSocket upgrade carried over that same HTTPS
listener and gets its own location block with the upgrade headers wired up.
# ── WebSocket upgrade map ────────────────────────────────────# Used by the /v1/opamp location below. Lives at the http {} top level# (outside any server block) — drop it in /etc/nginx/conf.d/ws-upgrade.conf# if you prefer to keep this site file focused on the linkmesh server blocks.map $http_upgrade $connection_upgrade { default upgrade; '' close;}
# ── HTTP API + OpAMP (port 443) — TLS terminated here ────────server { # No `http2` — WebSocket Upgrade is HTTP/1.1 only. HTTP/2 has no # `Connection: Upgrade` semantics and returns 400 for every OpAMP # handshake. If you also serve a heavy web UI from this host and miss # h2 multiplexing, split OpAMP onto its own server_name on a separate # listener instead of re-enabling http2 here. listen 443 ssl; server_name linkmesh.example.com;
ssl_certificate /etc/letsencrypt/live/linkmesh.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/linkmesh.example.com/privkey.pem;
# OpAMP WebSocket — preserves Upgrade headers, long idle timeouts so # the persistent OpAMP connection isn't reaped between heartbeats. location /v1/opamp { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }
# Everything else — REST API, web UI, enrollment endpoints. location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Connection ""; }}
# ── HTTP → HTTPS redirect ────────────────────────────────────server { listen 80; server_name linkmesh.example.com; return 301 https://$host$request_uri;}The stream {} block for gRPC pass-through goes in a different file:
/etc/nginx/nginx.conf at the top level. It can’t live in sites-enabled/
because that directory is included from inside the http {} context, and
stream {} must sit alongside http {}, not inside it.
# Append at the top level (NOT inside http {}).
# ── gRPC (port 50051) — TCP pass-through, NOT terminated ────# Public :50051 → LinkMesh's internal :50061 (grpc.port in config.yaml).stream { server { listen 50051; proxy_pass 127.0.0.1:50061; }}Then validate and reload:
-
Validate, then reload —
nginx -tcatches a missingstream {}block or a bad cert path before you take the proxy down:Terminal window sudo nginx -t && sudo systemctl reload nginx -
Confirm all three surfaces answer from a host that can reach the proxy:
Terminal window # HTTP API → 200/302curl -sI https://linkmesh.example.com/ | head -1# OpAMP WebSocket — must return 401 (auth required), NOT 400/301.# 400 means nginx is negotiating HTTP/2 or stripping Upgrade headers;# 301 means the request hit :80 instead of :443.curl -skI -o /dev/null -w '%{http_code}\n' \-H 'Upgrade: websocket' -H 'Connection: Upgrade' \-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \-H 'Sec-WebSocket-Version: 13' \https://linkmesh.example.com/v1/opamp# gRPC pass-through opennc -vz linkmesh.example.com 50051
A full annotated example config ships with the package at
/usr/share/doc/linkmesh-server/nginx-reverse-proxy.conf.example. It also
documents the SNI-based alternative if you want HTTP API and gRPC to share
port 443.