Public services from a residential line, without publishing the home IP
Table of contents
The question: how do you put a self-hosted service on the public internet when it lives on a residential line, without publishing the home IP?
Short answer: a tiny VPS holds the public IP and forwards everything over a WireGuard tunnel to OPNsense at home. The home line never appears in DNS. The VPS hosts none of the actual services.
The naive way is to port-forward 443 on the home router, point DNS at the residential IP, and hope the ISP keeps the lease. It works. It also publishes the home IP, paints a target on the router, and breaks the day the ISP rotates the address.
| Port-forward at home | VPS relay + tunnel | |
|---|---|---|
| Public IP in DNS | residential IP | VPS IP only |
| Survives an ISP lease change | NO, DNS goes stale | yes, DNS never moves |
| Home IP exposed | yes | never |
| Cost | none | ~5 EUR per month |
| Recovery if the public IP burns | wait for DNS to propagate | re-peer a new VPS, twenty minutes |
The two roles separate cleanly. The VPS owns the public IP and forwards. The home side owns the services.
The pieces#
public client
|
v
relay <vps-ip> (1 vCPU VPS, public IP)
nginx :443 (SNI mux + TLS)
wg0 :41820/udp peers <-> 10.10.10.0/24
|
| WireGuard
v
home OPNsense <home-wan-ip> (residential WAN)
wg1 10.10.10.250
iptables NAT + masquerade
|
v
internal proxy01 10.0.0.10 (the actual reverse proxy)
|
v
service VMs and containersAddresses here are examples:
<vps-ip>/<home-wan-ip>are the public IPs you are handed, and the10.xranges are private subnets you pick.
The relay listens on :443 with a tiny nginx that does SNI mux. It does not terminate TLS for most vhosts. It sees the ClientHello, reads the SNI, and forwards the raw TCP stream over the WireGuard tunnel to OPNsense. The TLS handshake completes on the inside. The relay never holds the certificate's private key for those services.
OPNsense receives the inbound packets on wg1, masquerades them to look like they came from 10.10.10.250, and routes them to proxy01 at 10.0.0.10. proxy01 is the real reverse proxy: it terminates TLS, splits to backend services by hostname, runs the certbot dance, and ships the certs back to the relay over NFS for the few vhosts the relay does terminate.
What the home side has to do#
OPNsense is the non-trivial part. WireGuard alone gets you an encrypted tunnel between two boxes. You need three more things on top.
Forward NAT for inbound traffic so a packet arriving on wg1 from 10.10.10.1 can hit proxy01:443 and the response goes back through the tunnel without OPNsense getting confused about reverse-path filtering. The rule shape in pf:
nat on wg1 from 10.10.10.0/24 to 10.0.0.10 -> (wg1)
pass in on wg1 inet proto tcp from any to 10.0.0.10 port 443The nat on wg1 line does the work. It rewrites the source of the proxied request to the OPNsense WireGuard IP. Without it, proxy01 sends the response packet straight to the relay's public IP via the WAN default route, the packet arrives at the relay on the wrong interface, the relay drops it, and the handshake hangs.
Reply-routing for asymmetric paths. OPNsense's reply-to logic needs to know that traffic which arrived on wg1 must exit on wg1, even when the destination's normal route says otherwise. Automatic in newer OPNsense. Older versions need an explicit reply-to on the inbound rule.
Outbound NAT exception for traffic going into the tunnel. By default OPNsense masquerades everything leaving WAN. The tunnel sits behind a different interface, and if you forget to except 10.10.10.0/24 from the outbound NAT rules, return traffic gets double-NATed and stops being routable. Add a hybrid outbound NAT rule that says: do not NAT traffic destined for 10.10.10.0/24.
These three rules live on three different OPNsense pages, which is why this takes longer than it should. Expect a long session with tcpdump -i wg1 the first time.
What the relay side has to do#
Far less. The relay runs:
wg-quick@wg0keeping the tunnel up- nginx with two listener types
- a tiny SNI mux in front
The tunnel is one config file on each end. On the relay, /etc/wireguard/wg0.conf:
[Interface]
Address = 10.10.10.1/24
ListenPort = 41820
PrivateKey = <relay-private-key>
# masquerade so the home side sees the tunnel IP, not each public client
PostUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE
[Peer]
# the home OPNsense
PublicKey = <home-public-key>
Endpoint = <home-wan-ip>:40000
AllowedIPs = 10.10.10.250/32, 10.0.0.0/24
PersistentKeepalive = 25The home side is the mirror: its own [Interface] on 10.10.10.250, and a [Peer] for the relay with AllowedIPs = 10.10.10.0/24 (add 0.0.0.0/0 only if you also want home's outbound to egress through the relay). PersistentKeepalive on at least one end keeps the NAT mapping open from behind the residential line, so the relay can reach back in.
The SNI mux is the interesting part. The relay terminates TLS for archworks.co (this blog, no secrets to hide) and does NOT terminate TLS for nextcloud.archworks.co, element.archworks.co, and the rest. The split lives in one stream block:
stream {
map $ssl_preread_server_name $upstream {
archworks.co 127.0.0.1:8443; # local TLS termination
default 10.10.10.250:443; # forward into the tunnel
}
server {
listen 443;
ssl_preread on;
proxy_pass $upstream;
proxy_protocol on;
}
}ssl_preread lets nginx peek at the ClientHello SNI without doing the TLS handshake itself. The relay does not need the certificate for the forwarded vhosts. They terminate on proxy01 inside the home network.
The proxy_protocol on line matters. Without it, proxy01 sees the source IP as the WireGuard peer IP (10.10.10.1) and every visitor looks like the relay. With it, nginx prepends a small header carrying the real client IP, proxy01 parses it, and the access logs are honest.
What this buys#
The home IP is not in DNS. All A records for *.archworks.co point at the relay's public IP. The home line could rotate every six hours and the public services would not notice. It has rotated a few times without anything public moving.
The home WAN can drop and nothing public moves. If the residential ISP has an outage, the tunnel drops and public requests start failing, but DNS does not flap. Recovery is the tunnel coming back up. No DNS propagation, no monitoring noise about A-record changes, no third party noticing the IP shifted.
The relay is disposable. I run one. I could run two. If a relay gets DDoSed off the internet, the path forward is: spin up a new VPS, install WireGuard, peer it, point DNS at it. The whole role fits on a 5 EUR per month VPS. Multiple relays just become extra peers on the home OPNsense, each owning its own slice of DNS.
The same tunnel works in reverse. From inside the home network I can set a route that pushes outbound traffic through wg1 and out the relay's WAN. Home gets a clean, stable public IP for outbound traffic when I want one. Useful for outbound MTAs, for API integrations that whitelist source IPs, for the occasional region-restricted lookup. The relay is the egress point for anything from home that needs a stable origin IP.
Nothing sensitive sits on the relay. No application data, no databases, no user accounts, no certificates for the services that matter. The relay's threat surface is one line: an attacker can read the TLS traffic for archworks.co, which is the public HTML I just put there.
What it does not do#
The relay is the WAN-side IP for the home network as far as the public is concerned. It is not a privacy hop. Visitor-to-relay is normal TLS, relay-to-home is WireGuard, and the visitor's IP and traffic patterns are visible to the relay exactly as they would be to any reverse proxy.
The relay is a single point of failure unless you run more than one. The home side is a single point of failure unless your services tolerate the backend being gone for a few minutes. For my use case that is fine. For something more serious the pattern extends: two relays in different ASNs, anycast or weighted DNS, two WireGuard tunnels from OPNsense, and the outbound NAT exceptions get a bit longer.
There is one operational cost. certbot can no longer use HTTP-01 challenges for the vhosts the relay does not terminate, since the HTTP challenge would be served from the wrong machine. Switch to DNS-01 and it goes away. The relay runs an acme-dns challenge server with its API reachable only over the tunnel, and certbot uses the certbot-dns-acmedns plugin, so the _acme-challenge TXT record is written automatically at renewal without exposing the main DNS zone.
The rough recipe, if you want to copy it#
- Get a VPS with a stable IP. Hetzner CX23 is what I run, 5 EUR per month.
- Generate a WireGuard keypair on both ends, peer them, give the relay
10.10.10.1and the OPNsense10.10.10.250. - On OPNsense: add the WireGuard interface, add the inbound NAT rule that rewrites the source to the wg IP, add the outbound NAT exception for the wg subnet, allow inbound TCP from the wg subnet to the internal proxy.
- On the relay: nginx with a
streamblock doingssl_prereadSNI mux,proxy_protocol on, default upstream is the tunnel IP, exceptions for any vhost you want the relay to terminate. - On the internal proxy: accept PROXY protocol on the inbound listener.
- Point all public DNS at the relay.
That is the whole path, end to end: a WireGuard config on each end, three NAT rules on OPNsense, one nginx stream block on the relay, DNS-01 for the certs, and public DNS pointing only at the VPS. Each piece has its own upstream docs - OPNsense's WireGuard guide, nginx's stream module - and the glue between them is mostly tcpdump -i wg1 watching the first handshake. The order above is that glue.