Caddy TCP Reverse Proxy and SSH Proxy with caddy-l4
Core Caddy only proxies HTTP. For raw TCP and SSH, build Caddy with the caddy-l4 layer4 app, forward ports, or share port 443 between SSH and HTTPS by protocol.
Short answer: standard Caddy cannot proxy raw TCP or SSH, because its `reverse_proxy` only speaks HTTP. To forward TCP you build Caddy with the caddy-l4 module (github.com/mholt/caddy-l4) using xcaddy, then configure its `layer4` app to listen on a port and proxy connections, optionally choosing the upstream by protocol, so SSH and HTTPS can share port 443.
caddy-l4 is a separate module, not part of the default Caddy distribution, and its README still describes it as a work in progress whose config can change between versions. This guide uses JSON config, which maps directly to the module's documented structure. If you only need HTTP or HTTPS proxying, the Caddy reverse proxy guide is all you need.
Why core Caddy is HTTP-only
Caddy's built-in apps are `http`, `tls` and `pki`. The `reverse_proxy` handler lives inside the HTTP app, so it needs a parsed HTTP request before it can route anything. An SSH client, a PostgreSQL client or a game protocol never sends one, so Caddy would answer with a TLS or HTTP error.
caddy-l4 adds a separate `layer4` app that works on raw connections. It can match the first bytes of a connection (SSH banner, TLS ClientHello with SNI, HTTP request line, PostgreSQL startup and more), then proxy, terminate TLS or hand the stream to another handler. Because it is an app like `http`, both can run in the same process.
| Need | Core Caddy | Caddy + caddy-l4 |
|---|---|---|
| HTTPS reverse proxy with auto TLS | Yes | Yes |
| Plain TCP port forwarding | No | Yes (`proxy` handler) |
| UDP forwarding | No | Yes (listen on `udp/:port`) |
| TLS passthrough by SNI | No | Yes (`tls` matcher with `sni`) |
| SSH and HTTPS on the same port | No | Yes (`ssh` and `tls` matchers) |
| Caddyfile support | Full | Partial; JSON is the reference format |
Build Caddy with the layer4 module
xcaddy compiles Caddy with extra modules. Alternatively, select `mholt/caddy-l4` on the official Caddy download page and download a ready binary. Either way, verify with `list-modules` that `layer4` modules are listed before writing config.
# Current Caddy needs a recent Go toolchain; distro golang-go packages are
# often too old, so install the latest release from https://go.dev/dl/
# and make sure /usr/local/go/bin is in PATH, then:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# Build Caddy with the layer4 app
~/go/bin/xcaddy build --with github.com/mholt/caddy-l4
# Confirm the modules are present
./caddy list-modules | grep layer4Replace the packaged binary safely
If Caddy was installed from the official apt repository, the documented way to swap in a custom build is `dpkg-divert` plus `update-alternatives`, so package upgrades do not overwrite your binary. Remember that you now have to rebuild to get Caddy updates.
sudo dpkg-divert --divert /usr/bin/caddy.default --rename /usr/bin/caddy
sudo mv ./caddy /usr/bin/caddy.custom
sudo update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.default 10
sudo update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.custom 50
sudo systemctl restart caddyExample 1: plain TCP reverse proxy
This config listens on port 5432 and forwards every connection to a PostgreSQL server on a private address, such as the far end of a WireGuard tunnel. No matcher means every connection takes the route. Upstreams accept several `dial` addresses for failover, and the `proxy` handler supports load balancing and health checks similar to HTTP `reverse_proxy`.
The backend sees Caddy's IP, not the client's. If the backend understands the PROXY protocol, set `"proxy_protocol": "v2"` on the proxy handler to pass the original address.
{
"apps": {
"layer4": {
"servers": {
"postgres": {
"listen": [":5432"],
"routes": [
{
"handle": [
{
"handler": "proxy",
"upstreams": [{"dial": ["10.8.0.2:5432"]}]
}
]
}
]
}
}
}
}
}Example 2: SSH and HTTPS multiplexed on port 443
SSH clients send their version banner (`SSH-2.0-...`) as soon as they connect, so caddy-l4 can recognize SSH before any server response. The config below sends SSH to the local sshd, TLS for `git.example.com` to a service that terminates its own TLS, and all other TLS to Caddy's HTTP app listening on 8443. Because layer4 now owns port 443, the HTTP app must not bind it too: set `https_port 8443` in the Caddyfile global options, or the equivalent `listen` address in JSON, and keep port 80 open for HTTP-01 challenges. With `https_port 8443`, Caddy's automatic HTTP-to-HTTPS redirect would point to port 8443, so set `auto_https disable_redirects` and add your own `http:// { redir https://{host}{uri} permanent }` site.
Protocols where the server speaks first, like SMTP, FTP or MySQL, cannot be detected this way, because the client waits silently. Give them their own port.
{
"apps": {
"layer4": {
"servers": {
"mux443": {
"listen": [":443"],
"routes": [
{
"match": [{"ssh": {}}],
"handle": [
{"handler": "proxy", "upstreams": [{"dial": ["127.0.0.1:22"]}]}
]
},
{
"match": [{"tls": {"sni": ["git.example.com"]}}],
"handle": [
{"handler": "proxy", "upstreams": [{"dial": ["127.0.0.1:3443"]}]}
]
},
{
"match": [{"tls": {}}],
"handle": [
{"handler": "proxy", "upstreams": [{"dial": ["127.0.0.1:8443"]}]}
]
}
]
}
}
}
}
}Run and test it
- 01.Save the JSON as `/etc/caddy/caddy.json` and validate it.
- 02.Stop the service and run Caddy in the foreground to watch the log while you connect.
- 03.Point the systemd unit at the JSON file with a drop-in override, then `sudo systemctl daemon-reload` and `sudo systemctl start caddy`.
- 04.Connect with `ssh -p 443` and open the HTTPS site in a browser to confirm both routes work.
# Validate and test in the foreground first
sudo caddy validate --config /etc/caddy/caddy.json
sudo systemctl stop caddy
sudo caddy run --config /etc/caddy/caddy.json
# Make the service use the JSON file: sudo systemctl edit caddy
[Service]
ExecStart=
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/caddy.json
ExecReload=
ExecReload=/usr/bin/caddy reload --config /etc/caddy/caddy.json --force
# Client side
ssh -p 443 [email protected]When to use HAProxy or sslh instead
caddy-l4 is a good fit when you already run Caddy and want one process for HTTPS and a few TCP routes. For a TCP-heavy setup, mature alternatives in the Debian and Ubuntu repositories may be simpler to operate: HAProxy in `mode tcp` routes by SNI with `req.ssl_sni` and has detailed stats and health checks, while sslh is a small daemon built only to share one port between SSH, TLS, OpenVPN and others. Both are installed with apt and updated with the rest of the system, with no custom build to maintain.
Exposing SSH on 443 helps on networks that only allow web ports, but it is not a security feature. Keep key-only authentication from the SSH hardening guide. On a NoctisNode VPS you have full root, so any of these tools can bind the ports you need; for certificate behavior on the HTTPS side see Caddy automatic HTTPS and, for containers, Caddy as a Docker reverse proxy.
| Tool | Strengths | Trade-offs |
|---|---|---|
| Caddy + caddy-l4 | One process for auto HTTPS and TCP, protocol matchers, JSON API | Custom build, module still evolving |
| HAProxy | Mature TCP/HTTP proxy, SNI routing, rich health checks and stats | More config, no built-in ACME client |
| sslh | Tiny, purpose-built port sharing for SSH/TLS/VPN | Only multiplexes; no TLS termination or load balancing |
FAQ
QCan Caddy proxy TCP traffic?
ANot out of the box. Core Caddy proxies HTTP only; for raw TCP or UDP you need a Caddy build that includes the third-party caddy-l4 (layer4) module.
QHow do I proxy SSH through Caddy?
ABuild Caddy with caddy-l4, add a layer4 server that matches `ssh` and proxies to `127.0.0.1:22`, then connect with `ssh -p <port>`. On port 443 you can combine it with a `tls` route so HTTPS still works.
QCan SSH and HTTPS share port 443?
AYes. caddy-l4, sslh and HAProxy can all inspect the first bytes of a connection and route SSH and TLS to different backends on the same port.
QDoes caddy-l4 support the Caddyfile?
AIt has Caddyfile support through a `layer4` global option, but JSON remains the most complete and best-documented format. Check the module's README for the version you build.