Gateway Routes on Layer 4
Since v1.42.0, L4 routes with target_kind=gateway work end-to-end.
This lets you publish any TCP or UDP port of a LAN device behind
the gateway container through the public server — regardless of the
application-layer protocol.
Typical use cases:
- RDP on port 3389 (Windows server, workstation)
- SSH on port 22 (Linux server, switches)
- Databases (MySQL 3306, PostgreSQL 5432, MSSQL 1433)
- Mail servers (IMAPS 993, SMTP 587)
- Proprietary protocols (MQTT 1883, Modbus 502, industrial controllers)
- Game servers (Minecraft 25565, Valheim 2456, etc.)
- Media servers (Plex 32400, Jellyfin 8096 via TCP instead of reverse proxy)
- Anything that is not HTTP and still needs to reach the LAN
Data flow
Internet client
│
├─ TCP connect → server public IP:3389
▼
Caddy Layer-4 plugin (VPS)
│
│ Layer-4 proxy (no HTTP parsing, just a byte stream)
│ Upstream: gateway-tunnel-IP:3389
▼
WireGuard tunnel
│
▼
Gateway container TcpProxyManager (home network, tunnel_ip:3389)
│
│ Forwarded to target_lan_host:target_lan_port
▼
LAN device (e.g. 192.168.2.100:3389 = Windows RDP)
The listen port (on which the server listens on the internet) and the port the gateway container opens on the tunnel IP are identical — 1:1 mapping. The LAN host and LAN port behind it can be chosen freely and may differ (e.g. listen 2222 → LAN 192.168.2.100:22 if you don't want to occupy the server's SSH port 22).
Prerequisites
- Pro license: The feature flag
gateway_tcp_routingis set tofalsein the community fallback. Without a Pro key, the API handler blocks route creation with403 gateway_tcp_routing not licensed. - Listen port must not be on the blocklist. Default:
80, 443, 2019, 3000, 51820(HTTP, HTTPS, Caddy admin API, Node app, WireGuard). Configurable via envGC_L4_BLOCKED_PORTS. - Listen port must not be used twice with the same TLS setting.
Conflicts are detected in
buildCaddyConfig()and thrown as errors (route creation rolls back). - The gateway container must be reachable — meaning WG tunnel is up and the handshake is active. No tunnel, no L4 forwarding.
Creating in the Admin UI
- Routes → New route (or edit an existing one)
- Switch Type to
L4(the HTTP/L4 toggle at the top of the create form) - Set Target type to
Home Gateway (LAN) - Choose a Home Gateway (the gateway peer through which traffic is routed)
- Enter the LAN host (the IP of the target device on the home network, e.g.
192.168.2.100) - Enter the LAN port (e.g.
3389for RDP) - Choose the L4 protocol:
TCPorUDP - Enter the L4 listen port. Usually identical to the LAN port (e.g.
3389). It can differ, however, if the server port is already in use or you deliberately want to expose a different port externally. - L4 TLS mode:
- None: Caddy forwards the stream without any TLS handling. Suitable for RDP, SSH, DB ports, anything that does not natively speak TLS OR handles its own TLS termination.
- Passthrough: Caddy reads only the SNI from the Client Hello for routing and forwards the entire TLS handshake. Suitable when the LAN target does HTTPS and you want to keep the cert on the target device. Requires a domain.
- Terminate: Caddy terminates TLS on the server and speaks plain TCP to the gateway. ACME cert via DNS/HTTP-01. Requires a domain.
- Domain (only for TLS modes ≠ None): FQDN used for SNI-based
routing, e.g.
rdp.example.com. - Save
The gateway container receives the change via config sync instantly
(push notification from server → /api/config-changed → gateway polls
→ TcpProxyManager.setRoutes() starts the new listener).
Port ranges
If you need a contiguous port range (e.g. FTP passive mode, or
multiple consecutive game server ports), enter the range as the
L4 listen port:
L4 listen port: 2000-2099
LAN port: 2000
Caddy then opens all 100 ports on the server and forwards each one 1:1 to the gateway. The gateway starts a single listener that forwards all incoming connections to the LAN target address.
Example game server with many worker ports:
L4 listen port: 27015-27020
LAN host: 192.168.2.50
LAN port: 27015
L4 protocol: UDP
Wildcard "all ports of a device" is not possible
This is a deliberate design decision:
- The server itself needs its own ports (TLS, admin UI, SSH, WireGuard). A "swallow everything" would make the server machine itself unreachable.
- Port conflicts with other L4 routes (e.g. to a second gateway) would no longer be resolvable.
- DDoS exposure would be enormous.
Instead: explicitly enumerate the ports you actually need. Each port = one route = equivalent to one firewall rule.
Multiple gateways, same port
If you have two gateways that both want to expose, for example, SSH (port 22), you must assign different listen ports:
gateway-a.domain: listen port 2222 → Gateway-A tunnel-IP:22 → LAN-A:22
gateway-b.domain: listen port 2223 → Gateway-B tunnel-IP:22 → LAN-B:22
For HTTP/S routes, this works via SNI (domain-based routing on 443). For L4 without a TLS mode, Caddy has no way to tell the streams apart — which is why exactly one target per server listen port is allowed.
Alternatively: L4 with TLS mode passthrough or terminate — then
Caddy can distinguish between multiple services on the same listen
port via SNI (e.g. rdp1.example.com and rdp2.example.com
both on 3389, but the SNI distinguishes them).
Wake-on-LAN
L4 gateway routes support the WoL flag just like HTTP gateway routes.
If the gateway receives ECONNREFUSED from the LAN target on a
connect attempt and the WoL flag is set on the route, it sends a
magic packet to the configured MAC. Combined with the client's
automatic retry (TCP SYN retransmission), the connection usually
succeeds on the second attempt — the target device has then woken up
from standby.
Blocked ports
Server ports that cannot be used as an L4 listen port (because the server uses them itself or they are security-critical):
| Port | Purpose |
|---|---|
| 22 | SSH daemon of the VPS (almost always in use) |
| 53 | systemd-resolved / host-dnsmasq (shared with network_mode: host) |
| 80 | HTTP auto-HTTPS (Caddy) |
| 443 | HTTPS (Caddy) |
| 2019 | Caddy admin API (internal) |
| 3000 | Node app (internal admin API) |
| 51820 | WireGuard server endpoint |
Overridable via env: GC_L4_BLOCKED_PORTS="22,53,80,443,2019,3000,51820".
Practical tip: If you have a LAN service on a blocked standard
port (e.g. SSH on 22), pick a different listen port externally (e.g.
2222) and keep the LAN port the same:
Listen port: 2222 (external, freely chosen)
LAN port: 22 (SSH on the target device)
The client then calls ssh -p 2222 user@example.com.
Softening the defaults is not recommended — it would cause port
conflicts with your own infrastructure.
TLS modes in detail
none (TCP passthrough)
Simplest case. Caddy is a pure byte forwarder. No TLS handling. Domain field optional (no SNI routing). Only one target per listen port allowed (no ambiguity to resolve).
Client ──TCP──▶ Server:3389 ──TCP──▶ Gateway:3389 ──TCP──▶ LAN:3389
passthrough (SNI sniffing without termination)
Caddy reads the Client Hello to extract the SNI and routes based on it. The full TLS handshake + encrypted traffic goes to the LAN target. The TLS cert lives on the LAN device.
Multiple targets on the same listen port are possible if they use different domains.
Client ──TLS+SNI──▶ Caddy (reads SNI, byte-forwards the rest) ──▶ LAN
terminate (TLS termination on the server)
Caddy terminates TLS and forwards the decrypted stream to gateway/LAN. The ACME cert is issued on the server via DNS-/HTTP-01 challenge.
Client ──TLS──▶ Caddy (decrypt) ──TCP──▶ Gateway ──TCP──▶ LAN
Suitable when the LAN target cannot speak HTTPS or you want to keep cert management centralized.
Compat / hashing
L4 routes have been part of the config hash since the first gateway version. The bugfix in v1.42.0 only changes the Caddy config on the server — the gateway itself receives the same L4 route payload as before. No hash bump, no breaking changes, no migration required.
Relevant files
Server
src/services/l4.js— builds Caddy Layer-4 config (buildL4Servers,buildL4Route,validatePortConflicts)src/services/caddyConfig.js— pre-processing: for gateway L4 routes setstarget_ip = <gateway-tunnel-ip>andtarget_port = <l4_listen_port>beforebuildL4Serversrunssrc/services/gateways.js—getGatewayConfig()deliversl4_routes[]to the gatewaysrc/routes/api/routes.js— license gategateway_tcp_routingin the POST + PUT handler
Gateway
src/proxy/tcp.js—TcpProxyManagerstarts listeners on tunnel_ip:listen_port and forwards to target_lan_host: target_lan_port, dual-bind overlap on port changessrc/bootstrap.js—tcpMgr.setRoutes(cfg.l4_routes)in the config-change event handler
Tests
tests/caddyConfig_gateway.test.jspins the new behavior: "L4 gateway route forwards to gateway-peer-ip:listen_port (not the 127.0.0.1 placeholder)"tests/gateways_getConfig.test.jscovers the L4 sync payload- Gateway:
tests/proxy_tcp.test.jstests the TcpProxyManager
Troubleshooting
Route shows gateway_tcp_routing not licensed on save
You have no Pro license or the community fallback is active. The feature is Pro-only.
Client gets connection refused
- Gateway offline? Check
docker psandsudo docker logs gatecontrol-gateway - WG tunnel down? On the server
wg show wg0 | grep handshake— the line for the gateway peer should be <2 minutes old - LAN target offline? The gateway does not check this in advance — it only attempts the TCP connection on client connect. Enable WoL on the route if this is about waking up the target.
"Port conflict" on save
Another L4 route is already using the same listen port. The server lists both IDs in the error. Either: pick a different listen port, or — if both routes use TLS — make sure they have different domains via SNI.
Connection works from outside but not from within the VPN
The client is already in the same WG subnet as the gateway — use the LAN path directly to the target device instead of the detour through the public Caddy. Split-horizon DNS + direct WG peering exist for exactly this.
"tls: first record does not look like a TLS handshake"
You set TLS mode to terminate, but the LAN target wants to terminate
TLS itself (i.e. it only speaks HTTPS). Switch to passthrough or
none.