Port forward to a WireGuard peer behind NAT: DNAT plus SNAT
DNAT delivers the request, but the reply leaves from the wrong address. Add a matching SNAT rule and place both after the tunnel is configured.
A browser requests https://203.0.113.10:8443, the proxy accepts the connection, and the service on the WireGuard peer never answers. The DNAT rule is doing its job; the reply is the part that fails. Follow the packet through both translations and the missing rule becomes obvious. The fix is one more NAT statement in the right hook, not a new tunnel.
Follow one request end to end
The request arrives on the proxy's public interface with the client's public address as its source and the proxy address as its destination. In nat PREROUTING, the DNAT rule rewrites the destination to the peer's tunnel address, 10.90.0.2. The routing decision then sends the packet out through wg0, and the forward path is complete.
On the peer, the packet now carries the original public source address, because nothing has rewritten it. The service accepts the connection and sends its reply to its own default gateway, which is the home router in front of the peer. That router has no state for a flow it never saw and either drops the packet or translates it to its own public address, which the client will not accept.
The asymmetry is the whole problem: the request goes through the proxy, and the reply tries to go directly. The proxy's connection tracking only helps if the reply returns through the proxy, and it cannot return there while the source address still points at the original client.
Add the return translation
Rewrite the source of the forwarded flow to the proxy's tunnel address before it leaves for the peer. The service then replies to 10.90.0.1, the proxy receives it, and connection tracking reverses both translations before the packet goes back to the client.
iptables -t nat -A PREROUTING -p tcp -m multiport --dports 8080,8443 -j DNAT --to-destination 10.90.0.2
iptables -t nat -A POSTROUTING -d 10.90.0.2 -o wg0 -p tcp -m multiport --dports 8080,8443 -j SNAT --to-source 10.90.0.1The POSTROUTING rule is deliberately narrow: it matches the translated destination and the tunnel interface, so it does not rewrite unrelated traffic that happens to leave through wg0. A broad masquerade rule would also hide the client address, but it would do so for every flow and make the NAT table harder to audit.
Place the rules where the tunnel exists
Rules that reference the tunnel address or interface must run after wg-quick has configured it. The interface is created before PreUp, but addresses, MTU, DNS and routes are applied after, and PostUp runs once all of that is in place. Put the additions in PostUp and the deletions in PreDown.
PostUp = iptables -t nat -A PREROUTING -p tcp -m multiport --dports 8080,8443 -j DNAT --to-destination 10.90.0.2
PostUp = iptables -t nat -A POSTROUTING -d 10.90.0.2 -o wg0 -p tcp -m multiport --dports 8080,8443 -j SNAT --to-source 10.90.0.1
PreDown = iptables -t nat -D PREROUTING -p tcp -m multiport --dports 8080,8443 -j DNAT --to-destination 10.90.0.2
PreDown = iptables -t nat -D POSTROUTING -d 10.90.0.2 -o wg0 -p tcp -m multiport --dports 8080,8443 -j SNAT --to-source 10.90.0.1The deletions must match the additions token for token, including the multiport and interface matches. A deletion that differs by one match removes nothing, and the next reconnect adds a second copy of the rule. After a few reconnects the table contains duplicates that are hard to attribute to a configuration line.
Check the result with iptables -t nat -S after a reconnect. If the same rule appears twice, the PreDown line is not matching and the cleanup is silently failing.
Forwarding, filtering and the other NAT
Enable IPv4 forwarding persistently, then allow the forwarded flow in the filter table. DNAT happens before the FORWARD chain, so the filter rule matches the translated destination rather than the public address. If UFW owns the firewall, express the allowance as a route rule; a plain host rule does not cover forwarded traffic.
Two other NAT layers can still break the path even when the proxy rules are correct. On a home connection, the public address belongs to the router, and opening the port in its firewall is not the same as creating a port-forward entry that sends that port to the proxy. A port that looks open from outside while nothing answers is often missing exactly that mapping.
On a NAT VPS, the provider maps a public port to a private address. The mapping has to agree with the port you advertise, and a mismatch looks identical to a broken tunnel. Confirm the provider's mapping before changing the WireGuard configuration.
Verify the return path and accept the cost
Connect from a network that is not the proxy's LAN, then inspect the translation pair while the connection is open:
conntrack -L -p tcp --dport 8080
tcpdump -ni any tcp port 8080The conntrack entry should show the original tuple, the translated tuple and the reply direction. If the reply is missing, the peer is not sending through the tunnel, which usually means the SNAT rule did not apply or the peer's own firewall dropped the flow.
Reboot once to prove PostUp reapplies the rules, then stop the tunnel and confirm PreDown removed them. Dead rules that survive a disconnect are the reason a later change behaves unpredictably.
Finally, accept what the SNAT costs. The service sees every request as coming from the proxy, so access logs, per-client rate limits and address allowlists no longer distinguish users. The hairpin case needs its own source translation for clients on the proxy's LAN. If the peer needs real client addresses, the alternative is a return route to the client network, which is more work but preserves the information.