Keep your reverse proxy, drop the port forward
Many home labs and small offices have the same shape: one machine runs a reverse proxy, Caddy or Traefik, in front of several services. The proxy sends app. to one service and that name's /api/ path to another, sends admin. to a third, asks for a password on the admin pages and adds a header the admin app trusts. To reach it from outside, the router forwards ports 80 and 443 to the proxy, a dynamic DNS name follows the home's address, and the proxy gets its certificates from Let's Encrypt.
The proxy is the part people have tuned. The port forward is the part they would rather not have. We built the setup with both proxies and replaced only the public entry with nfltr, then checked what changed for the services behind it, with most attention on the thing that usually breaks when one proxy moves behind another: what the services see of the visitor's address.
What you get
- No open port at home. Ports of the home's public address that answer: 80 and 443 before, none after. Inbound rules on the router: 4 to 0. The proxy listens on loopback only.
- The proxy keeps its job. Routes by host and by path, the Basic auth login, the header the proxy adds for the admin app and WebSocket upgrades all worked unchanged, with Caddy and with Traefik.
- The visitor's address still arrives.
X-Forwarded-Forstarts with the visitor's address in both setups, andX-Forwarded-Protoishttps. One trust line in the proxy is required for that. - Forged forwarding headers go. A visitor's own
X-Forwarded-FororX-Real-Ipnever reaches the proxy. - Encrypted end to end. TLS ends on the home server, not on the relay, which forwards bytes it cannot read.
- What it changes. Each site gets a link with a random name on the relay instead of your DNS name. Each visit keeps a link alive for another day, across restarts of
nfltr up; a link nobody opens for a day ends, and the next start prints a new one.
Before
The router forwards ports 80 and 443 to the home server. A DuckDNS name, say myhome.duckdns.org, points at the home's public address, kept current by a job on the server, and its subdomains app. and admin. point there too. The proxy listens on both ports and gets a certificate for each name. In Caddy:
app.{$DDNS_NAME} {
handle /api/* {
reverse_proxy 127.0.0.1:9002
}
handle {
reverse_proxy 127.0.0.1:9001
}
}
admin.{$DDNS_NAME} {
basic_auth {
owner {$ADMIN_HASH}
}
reverse_proxy 127.0.0.1:9003 {
header_up X-Auth-User {http.auth.user.id}
}
}
Traefik's version is the same three routers, a basicAuth middleware with headerField: X-Auth-User, and a certificate resolver. It works, and it leaves ports 80 and 443 of the home open to the whole internet, with the proxy's login as the only thing in front of the admin pages. It also needs a public IPv4 address: behind carrier-grade NAT there is nothing to forward.
After
nfltr up runs on the same server and connects out to the relay. Each site becomes one http: service, and each gets an unlisted HTTPS link:
services:
app-site: { http: "http://app.home.arpa", host_header: rewrite }
admin-site: { http: "http://admin.home.arpa", host_header: rewrite }
TLS ends at nfltr up on the server, so the relay carries encrypted records only. nfltr up then passes each request to the proxy on loopback, with the site's home name as Host, so the proxy's routes match on the name they always did; one line in /etc/hosts points those names at 127.0.0.1. The proxy's site blocks keep their bodies. What changes is the top: no certificates, loopback only, and loopback trusted for the forwarding headers.
{
auto_https off
default_bind 127.0.0.1
servers {
trusted_proxies static 127.0.0.1/32
}
}
http://app.home.arpa {
# the same handle blocks as before
}
In Traefik that is an entry point on 127.0.0.1:80 with forwardedHeaders: { trustedIPs: ["127.0.0.1/32"] }, and routers without tls.
Why http: services and not an end-to-end tcp: one, which would let the proxy keep its own TLS? Because a tcp: service is reached with nfltr on the visitor's side as well, which is no public entry for a browser or a phone. A TCP session also carries no client address at all, so the proxy would see every visitor as 127.0.0.1.
What the services see
This is the part we measured most carefully. Each service answered with what it received, and the visitor's real address was known to the test.
| what the service saw | before | after | after, without the trust line |
|---|---|---|---|
X-Forwarded-For |
the visitor | the visitor, then 127.0.0.1 |
127.0.0.1 |
X-Forwarded-Proto |
https |
https |
http |
X-Real-Ip |
the visitor (Traefik), none (Caddy) | the visitor | the visitor (Caddy), 127.0.0.1 (Traefik) |
X-Forwarded-Host |
the public name | the link's name | the home name |
Host |
the public name | the home name | the home name |
| connection from | 127.0.0.1 (the proxy) |
127.0.0.1 |
127.0.0.1 |
Three things follow. The visitor's address is still the first entry of X-Forwarded-For, and the proxy appends the hop it got the request from; an app that reads the first entry keeps working, one that reads the last entry sees 127.0.0.1. The trust line is not optional: without it both proxies throw away the address nfltr up passed, and apps start building http:// links. And X-Forwarded-Host becomes the link's name, which is the name the visitor used, so absolute URLs built from it point where they should.
We also sent requests with forged X-Forwarded-For, X-Real-Ip, X-Forwarded-Host and X-Forwarded-Proto. After, none of them reached the proxy: nfltr up drops a visitor's forwarding headers and sets its own from the address the relay accepted the connection from. Before, Traefik dropped them too, and Caddy ignored the forged X-Forwarded-* but passed the forged X-Real-Ip to the service unchanged, so an app that trusts X-Real-Ip behind a public Caddy can be told any address.
The rest of the checks
Both setups ran as containers on one Mac: the home server behind a router that adds 10 ms each way, a visitor outside, and in the nfltr setup a private relay outside as well. Each check passed with both proxies, judged by a program's exit code:
- Routing.
app./reached the photos service,app./api/the api service,admin.the admin service. - The login. No password and a wrong password got 401 from the proxy; the right one reached the admin service with
X-Auth-User: owneradded by the proxy. - WebSocket. An upgrade through the proxy, then 100 echoes.
- Latency. 200 kept-alive requests: p50 22.8 ms (Caddy) and 22.5 ms (Traefik) before, 22.9 and 21.9 ms after; WebSocket echoes 22.5 and 22.6 ms before, 22.6 and 21.4 ms after. Most of it is the 20 ms round trip through the home router; the relay sat next to the visitor.
- Nothing open after. Before, ports 80 and 443 of the home's address answered, the proxy listened on every interface and the router had four inbound rules. After, no port answered, nothing on the server listened beyond loopback, the router had no inbound rule, and a link with a guessed name was refused.
- Restarts. The front was killed and started: the proxy before (answering again after 0.4 s for Caddy, 0.6 s for Traefik),
nfltr upafter (0.6 s and 0.8 s, through the same links). Across two runs these ranged from 0.4 to 2.1 s before and 0.6 to 1.6 s after.
What it costs
nfltr is free until further notice. To show what relaying this kind of traffic costs whoever runs the relay, we priced an assumed month with our cost ledger at public cloud list prices: two links' agents connected all month, 30,000 requests and 10 GiB through the links, end-to-end encrypted. That comes to 1.45 USD a month at Google Cloud's list prices, almost all of it the traffic, and 0.013 EUR at Hetzner's.
The conventional setup can cost nothing in money with a public IPv4 address: DuckDNS is free and so is Let's Encrypt; your own domain is about 10.46 USD a year at cost. Behind carrier-grade NAT it needs a rented VM and a tunnel to it, 4 to 6 USD or EUR a month at the list prices we read on October 31, 2026.
Evidence: nfltr in front of Caddy and Traefik
- Setup
- Both setups with both proxies (Caddy 2.10.2 and Traefik 3.5.6, pinned by digest) as Linux containers on one Mac (Apple Silicon), two runs on October 31, 2026; the numbers are from the second, and every header the services saw was the same in both. A home network behind a router with 10 ms of added delay each way, a visitor outside, a private relay outside in the nfltr setup. The services are stand-ins that report what they received. Before, a test CA's certificate stood in for Let's Encrypt and the router's network aliases for DuckDNS; after, a self-signed certificate stood in for the one the hosted relay's agent gets from Let's Encrypt.
- Numbers
- Ports of the home's address answering 2 and 0; router inbound rules 4 and 0; X-Forwarded-For "visitor" before and "visitor, 127.0.0.1" after, "127.0.0.1" without the trust line; X-Forwarded-Proto https, https, http; a forged X-Real-Ip passed through by Caddy before, dropped after; GET p50 22.8 and 22.5 ms before, 22.9 and 21.9 ms after (Caddy, Traefik; n=200); WebSocket echo p50 22.5 and 22.6 ms before, 22.6 and 21.4 ms after (n=100); answering again 0.4 and 0.6 s after a proxy restart, 0.6 and 0.8 s after an nfltr up restart; 1.45 USD a month at Google Cloud list prices and 0.013 EUR at Hetzner's for the assumed month.
- Reproduce
- (a) With the released binaries: run
nfltr upwith the file above on the server that runs your proxy, add the home names to /etc/hosts, move the proxy to loopback with the trust line, and open the linksnfltr upprints. (b) Our check, in the closed nfltr repository, runs all four setups in containers and judges each claim by an exit code. - Limits
- One host, so the only network distance is the delay we added, with no loss; the relay sat next to the visitor. Stand-in services, not real apps. Basic auth only; a forward-auth middleware was not tested. The cost covers relaying only, at list prices, for an assumed month; prices change, and each one names the date it was read.
Is it for you?
Fits if you already run Caddy or Traefik in front of several services at home or in a small office, you want them reachable from browsers and phones anywhere, and you would rather not forward ports, keep a dynamic DNS name current or have a public IPv4 address at all. Your routes, login and headers stay in the proxy, where you already maintain them.
Not ready for serving your sites under your own domain names: each site's link is a random name on the relay's share domain, and a link left unused for a day is replaced by a new one. If your apps must see their public name in Host, or you need the proxy to hold the public TLS certificate itself, this setup changes that. If you have a public IPv4 address and an open port is acceptable to you, the setup you have does the job.
Try next: install nfltr and sign in on the server, then run nfltr up with the file above. The nfltr up reference covers services: and host_header. Part 4 of this series does the same for Home Assistant on its own.