Someone asks for the browser to use the tunnel, and the request comes back as a firewall rule matching destination ports 80 and 443. It looks precise and it is not. A port match selects packets by their transport header, not by which program opened the socket, and modern browsing does not restrict itself to those two ports on TCP. The rule will miss some traffic you meant to include and capture traffic you never intended to touch. Before writing it, decide which of three different boundaries you actually want.

The rule describes a transport class, not an application

On Linux, a destination-port match inspects the TCP header and nothing above it. Marking those packets and steering them with a policy route works exactly as documented, and none of that machinery knows what the payload means or which process sent it. The mark is a number attached to a packet, not an identity attached to an application.

nft add rule inet mangle output tcp dport { 80, 443 } meta mark set 0x1
ip rule add fwmark 0x1 table 100
ip route add default dev wg0 table 100

Two consequences follow immediately. A software updater that contacts TCP 443 gets the same treatment as the browser, which may be fine or may be an outage waiting for the next update cycle. And a web service deliberately published on another port, say 8443, falls outside the rule entirely while still being the web traffic the user cared about.

The numbers are legitimate selectors when the requirement really is about those ports. They become a fiction when "browser" is written in the ticket and a port range is implemented in the ruleset. The gap is not a documentation nicety. It is the difference between a rule you can defend in a review and one nobody can test.

HTTP/3 quietly leaves your TCP rule behind

HTTP/3 runs over QUIC, and QUIC is carried in UDP. A rule that matches TCP only cannot see it, so the same browser that satisfies your test page can send a later request over UDP and bypass the tunnel without anyone changing a setting. RFC 9114 describes HTTP/3 as a QUIC-based protocol, and QUIC uses UDP with a default port of 443 in the common deployment.

Whether a given browser tries HTTP/3 depends on its configuration, on the site, and on whether a previous attempt succeeded. That makes it a poor thing to reason about from memory. Ask the tool instead:

curl --http3 -sS -o /dev/null -w '%{http_version} %{time_total}\n' https://example.com
ss -tunp | grep -E ':443\b'

The curl line prints the version negotiated, so a 3 confirms the UDP path was used. The socket listing then shows whether that connection is bound to the tunnel interface or to the physical one. If HTTP/3 is not available in your curl build, the flag fails loudly rather than silently falling back, which is its own small mercy.

DNS is a separate decision you have to make

A port rule for web traffic says nothing about name resolution. The browser may use the system resolver, a resolver configured inside the browser, or one pushed by whatever connected last. Each of those can send queries over a different path than the one your rule selects, which produces the classic split: the page loads, the name is resolved somewhere you did not expect, and the internal site resolves to a public address or not at all.

Decide it explicitly. If internal names must resolve through the tunnel, the resolver has to be inside it, and the rule set has to permit DNS to that resolver while keeping other queries out. If the browser uses DNS over HTTPS to a public provider, the query bypasses your resolver entirely and the port rule cannot help. Write the choice into the same change record as the routing rule, because the two are read together by whoever debugs the next failure.

Then verify with the resolver rather than with the page:

resolvectl query intranet.example.internal
resolvectl status | grep -A3 'Current DNS Server'

A missing answer is a resolution problem even when the browser's error message blames the network. Those two failures look identical to a user and are completely different tickets.

Three boundaries, three different mechanisms

Most of the confusion disappears once you name the boundary you want. They are not interchangeable, and each one has a different failure mode during a tunnel outage.

What you actually wantMechanism that fitsWhat it does not cover
These transport ports use this exitFirewall mark plus policy routeApplications, HTTP/3 over UDP, DNS
This browsing session stays separateApplication-aware VPN, or a separate network namespace plus a proxyOther applications in the same session
Only these private sites are reachableDestination-based routes with private DNSPublic sites on the same address family

A separate browser profile is the trap in the middle row. It separates cookies and history, and it changes nothing about the host's routing table, so both profiles take the same path. Isolation requires something the kernel enforces, which means a namespace, a container, or an application that owns its own tunnel interface.

Whichever row applies, write down what should happen when the tunnel drops. A mark-based route that disappears with the interface usually falls back to the physical default route, which is fail-open behaviour. That may be acceptable for browsing and unacceptable for anything else, and it is a decision, not an implementation detail.

Test the flows you claim to cover

The acceptance test has to follow the boundary you wrote down, not the port numbers that were convenient. Pick one flow that must go through the tunnel, one that must not, and one that uses a transport you did not think about:

ip route get 203.0.113.40
curl -sS -o /dev/null -w 'v4 %{http_version} %{remote_ip}\n' https://example.com
nft list ruleset | grep -A2 'dport'

The route lookup tells you which interface a destination actually uses, which is the fact most reports get wrong. The curl line confirms the negotiated version and the address family. The ruleset dump catches the rule you added during testing and forgot to scope, which is how a temporary mark turns into permanent fail-open routing.

Record all three outputs with a timestamp and keep them with the change. When someone later reports that "the VPN broke the intranet", the difference between the intended boundary and the implemented one will be visible in that record, and the fix will be a rule change rather than an argument.

Tënë sô zo