Thanks for sponsoring this project
Yundera : yundera.com - Easy to use cloud server for open source container applications. NSL.SH : nsl.sh - free domain for opensouce project.
CasaIMG - providing easy management of docker images and compatible with mesh router.
MeshRouter is a WireGuard-based VPN tunneling solution that enables secure routing of traffic between a provider (public server) and requesters (private PCS instances).
┌─────────────────────────────────────────────────────────────────────┐
│ PCS Instance │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ mesh-router │───▶│ Caddy │───▶│ Services (with labels) │ │
│ │ (port 80) │ │ (port 80) │ │ - casaos:8080 │ │
│ │ │ │ │ │ - app1:3000 │ │
│ │ WireGuard │ │ Docker │ │ - app2:8080 │ │
│ │ Tunnel ▲ │ │ Labels │ │ │ │
│ └──────────────┘ └─────────────┘ └─────────────────────────┘ │
│ │ │
└──────────────│───────────────────────────────────────────────────────┘
│
▼
┌─────────────┐
│ Provider │
│ (nsl.sh) │
└─────────────┘
- Mesh Router (Requester): Establishes WireGuard tunnel to provider and forwards all incoming traffic to Caddy
- Caddy: Uses caddy-docker-proxy to discover services via Docker labels and routes traffic accordingly
- Services: Containers with Caddy labels define their own routing rules
- External Request: User requests
https://app.user.nsl.sh - Provider: nsl.sh receives request, looks up user's VPN IP, forwards via WireGuard
- Mesh Router: Receives request on port 80, forwards to Caddy
- Caddy: Matches request against container labels, proxies to appropriate service
- Service: Container handles request and returns response
The tunnel preserves the original request scheme (HTTP vs HTTPS) throughout the entire flow:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SCHEME PRESERVATION FLOW │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ENTRY (Gateway) TUNNEL EXIT (Requester → Caddy) │
│ │ │ │ │
│ HTTP request │ │ │
│ (port 80) │ │ │
│ │ │ WireGuard │ http:// │
│ ├──────────────────────►│◄════════════════════════════►│───────────────► │
│ │ X-Forwarded-Proto: │ (encrypted) │ caddy:80 │
│ │ http │ │ │
│ │ │ │ │
│ HTTPS request │ │ │
│ (port 443, SSL terminated) │ │ │
│ │ │ WireGuard │ https:// │
│ ├──────────────────────►│◄════════════════════════════►│───────────────► │
│ │ X-Forwarded-Proto: │ (encrypted) │ caddy:443 │
│ │ https │ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
How it works:
- Gateway terminates SSL for HTTPS requests and sets
X-Forwarded-Proto: httpsheader - Tunnel entry (provider) receives HTTP on port 80 and preserves the
X-Forwarded-Protoheader - Traffic flows through WireGuard as HTTP (WireGuard provides network-layer encryption)
- Tunnel exit (requester) reads
X-Forwarded-Protoand speaks the correct protocol to Caddy:X-Forwarded-Proto: http→http://caddy:80X-Forwarded-Proto: https→https://caddy:443(nginx speaks TLS to Caddy)
Why this design:
- Caddy can keep standard HTTP/HTTPS configuration without special proxy trust settings
- The tunnel handles protocol translation transparently
- WireGuard encrypts all internal traffic regardless of application-layer protocol
- Scheme-aware routing ensures correct behavior for redirects, secure cookies, and HSTS
The mesh-router operates in two modes:
- Provider Mode: Runs on public servers, accepts incoming connections and routes traffic to registered requesters via WireGuard
- Requester Mode: Runs on PCS instances, establishes VPN tunnel to provider and forwards traffic to Caddy for local routing
<name> : Name proposed by the user on registration (if accepted by the provider)
<domain> : The domain of the provider (eg nsl.sh)
Full format: <service>.<name>.<domain> or <service>-<name>.<domain>
Example: nextcloud.mynas.nsl.sh or nextcloud-mynas.nsl.sh
| Variable | Default | Description |
|---|---|---|
PROVIDER |
- | Provider connection string: <url>,<userId>,<signature> (for single provider setup) |
ROUTING_TARGET_HOST |
caddy |
Target container hostname for traffic forwarding |
ROUTING_TARGET_PORT_HTTPS |
443 |
Target container port for HTTPS traffic |
ROUTING_TARGET_PORT_HTTP |
80 |
Target container port for HTTP traffic |
For advanced setups with multiple providers, use a YAML configuration file mounted at /app/config/config.yml:
providers: - provider: https://nsl.sh,userId,signature defaultService: casaos - provider: http://custom-provider.com,userId2 defaultService: myapp services: myapp: defaultPort: '3000'
The configuration file is watched for changes and will automatically reconnect to providers when modified.
The requester automatically monitors WireGuard handshakes every 5 minutes. If a connection becomes stale (no handshake within 5 minutes), it will:
- Log the connection issue
- Tear down the WireGuard interface
- Re-register with the provider
- Re-establish the tunnel
This ensures resilient connections without manual intervention.
services: mesh-router: image: ghcr.io/yundera/mesh-router-tunnel:latest cap_add: - NET_ADMIN - SYS_MODULE sysctls: - net.ipv4.ip_forward=1 - net.ipv4.conf.all.src_valid_mark=1 environment: - PROVIDER=https://nsl.sh,<userId>,<signature> - ROUTING_TARGET_HOST=caddy networks: - pcs depends_on: - caddy caddy: image: lucaslorentz/caddy-docker-proxy:ci-alpine volumes: - /var/run/docker.sock:/var/run/docker.sock:ro environment: - CADDY_INGRESS_NETWORKS=pcs networks: - pcs casaos: image: casa-img:latest labels: caddy: ":80" caddy.reverse_proxy: "{{upstreams 8080}}" networks: - pcs networks: pcs: driver: bridge name: pcs
Labels define how Caddy routes traffic to each container:
labels: caddy: ":80" # Match all requests on port 80 caddy.reverse_proxy: "{{upstreams 8080}}" # Proxy to container port 8080
This template function:
- Resolves to the container's IP address on the ingress network
- Uses the specified port as the upstream port
- Example:
{{upstreams 8080}}→172.20.0.2:8080
Route all traffic to a service:
labels: caddy: ":80" caddy.reverse_proxy: "{{upstreams 3000}}"
Route by hostname:
labels: caddy: "myapp.example.com" caddy.reverse_proxy: "{{upstreams 8080}}"
Route with path matching:
labels: caddy: ":80" caddy.0_matcher: "path /api/*" caddy.0_reverse_proxy: "{{upstreams 8080}}"
Provider mode is used on public servers to accept incoming VPN connections.
| Variable | Default | Description |
|---|---|---|
PROVIDER_ANNONCE_DOMAIN |
- | Domain to announce (e.g., nsl.sh) - presence of this variable enables provider mode |
AUTH_API_URL |
- | URL for user authentication API (optional) |
VPN_IP_RANGE |
10.77.0.0/16 |
IP range for VPN clients |
VPN_PORT |
51820 |
WireGuard listen port |
VPN_ENDPOINT_ANNOUNCE |
- | Public endpoint for VPN connections (IP or hostname) |
SSL |
false |
Enable HTTPS with self-signed certificate on port 443 |
TUNNEL_FORWARD_FROM |
- | Comma-separated IPs/CIDRs (the gateways on this host) allowed to reach the peers' tcp/443 through wg0 (routed tunnel); masqueraded to the provider's address |
PROVIDER_API_TLS_PORT |
- | Also serve the registration API (/api/) over TLS (self-signed) on this port, for a provider without a reverse proxy in front, e.g. on a second gateway's host. Port 80 is unchanged. Requesters reach it through the backend's TUNNEL_PROVIDERS list (https://<host>:<port>) |
The provider exposes an internal API on port 3000 (used by Nginx for routing):
| Endpoint | Method | Description |
|---|---|---|
/api/ping |
GET | Health check - returns ok |
/api/get_ip/<host> |
GET | Resolves domain to backend VPN IP for routing |
/api/register |
POST | Peer registration endpoint for requesters |
Registration Request Body:
{
"userId": "username",
"vpnPublicKey": "WireGuard public key",
"authToken": "signature or auth token"
}Registration Response:
{
"wgConfig": { "interface": {...}, "peers": [...] },
"serverIp": "10.77.0.1",
"serverDomain": "nsl.sh",
"domainName": "username",
"domain": "username.nsl.sh"
}services: routing: image: ghcr.io/yundera/mesh-router-tunnel:latest ports: - "80:80" - "443:443" - "51820:51820/udp" environment: - PROVIDER_ANNONCE_DOMAIN=domain.com - VPN_ENDPOINT_ANNOUNCE=x.x.x.x # Use direct IP (not behind Cloudflare) cap_add: - NET_ADMIN - SYS_MODULE sysctls: - net.ipv4.ip_forward=1 - net.ipv4.conf.all.src_valid_mark=1 volumes: - ./config.lua:/etc/nginx/lua/config.lua # optional
It is recommended to set up Cloudflare for TLS management and DDoS protection. The provider container will use a self-signed certificate for end-to-end encryption.
Tailscale Funnel Cloudflare Tunnels https://github.com/hintjen/selfhosted-gateway : Docker native self-hosted alternative to Cloudflare Tunnels, Tailscale Funnel, ngrok and others.
To start development, use the scripts in ./dev-scripts/windows/simple folder,
which contains everything needed to run mesh-router in a basic environment.
For detailed instructions, see simple example.