The request is always a version of the same sentence: keep the VPN for browsing, but let SSH or a backup job use the normal connection. The advice that circulates for this, forwarding the port with iptables NAT rules, does not do it. A NAT rule rewrites addresses and ports inside a flow; it never chooses which interface the packet leaves by. That decision belongs to the routing lookup, and the routing lookup needs help to see a port.

Why a plain route cannot match a port

Linux picks a route by looking up the destination address in a table, after filtering candidate tables through policy rules. Those rules can match a firewall mark, a source address, an incoming interface or a UID. A route entry itself has no port field, because the route is chosen before the transport header matters.

Current iproute2 and kernels widen the rule selector: ipproto, sport and dport are accepted, so ip rule add ipproto tcp dport 22 table 100 expresses the intent in one line. Run ip rule add help on the actual host to see whether your build lists dport. If it does, you are done in one command; if it does not, the mark path below is the equivalent.

Either way the rule is only a pointer. Table 100 still needs a route, and that is the piece people forget.

Three pieces, or one

The modern form is the single rule above plus a route in the alternate table. The classic form adds netfilter in the middle: mark the packet, then match the mark in a rule. Both end at the same lookup; they differ in where the port is read.

The mark approach exists because netfilter sees the transport header and the routing code historically did not. That is the whole mechanism, and it explains why the fallback is three cooperating pieces rather than one clever command.

The three pieces in order

First, mark the flows you want to divert, in a chain that runs early enough for the mark to exist before the route decision:

nft add table inet vpnexempt
nft 'add chain inet vpnexempt out { type route hook output priority mangle; }'
nft add rule inet vpnexempt out tcp dport 22 meta mark set 0x1

Second, send marked packets to a separate table:

ip rule add fwmark 0x1/0x1 table 100

Third, give that table a default route through the physical link. The addresses are illustrative; substitute your own:

ip route add default via 192.168.1.1 dev wlan0 table 100

Skip the third step and the marked packet finds no route in table 100, so it fails. The symptom looks like the rule broke networking, when in fact it removed the only path the packet had left.

What keeps it working after the first packet

Marks apply to outbound packets. The reply arrives on the physical interface with the address that link assigned, and the main table may still prefer the tunnel for that destination. Two mechanisms keep the reply path consistent: a source-based rule for the link address, and connection tracking that preserves the decision for the life of the flow. Saving the mark into conntrack and restoring it on reply is the usual way to make a stateful exception stable.

Reverse-path filtering is the other silent killer. If the kernel validates the return path strictly and the reply arrives on an interface the main table would not choose, the packet is dropped without a message. rp_filter defaults differ by distribution and by whether the interface is forwarded, so read the effective value instead of assuming it is off.

Do not mark the tunnel's own transport. A WireGuard or UDP OpenVPN tunnel is not TCP 22, so the recipe above is safe, but an exception written as everything except the tunnel destination can capture the encrypted packets and break the tunnel in a way that reads like a routing loop.

Check it instead of assuming

Ask the kernel which path it would take, for the destination and for the port, then run the real transfer:

ip route get 203.0.113.10
ip rule show
nft list ruleset

If the mark exists but the counters never move, the rule is not matching. Counters are the difference between a configuration you wrote and a policy the kernel is applying.

When this is the wrong tool

Port-based exceptions are brittle by nature. Services move ports, HTTP/3 arrives as UDP 443, and one application can open several flows of different kinds. If the real requirement is this program uses the normal link, the routing layer is the wrong place; a network namespace or a per-application proxy fits better.

If the requirement is this subnet uses the normal link, skip the marks entirely and add a more specific route. It is simpler, it is visible in one table, and the next person can audit it without reading a ruleset.

Write down which of the three requirements you actually have before touching the firewall. The mechanism above is precise, and precision applied to the wrong layer is just a longer outage.

Amanqephe ahlobene