“Route this one subnet through the VPN, everything else stays on the ISP.” It’s one of the most common requests in networking, and it’s exactly what policy-based routing (PBR) does: routing decisions based on something other than the destination address — usually the source.
If you learned PBR on RouterOS 6, unlearn half of it. In v6, you’d slap a routing-mark on packets in mangle and a routing table would silently spring into existence. In RouterOS 7, tables are explicit objects. Nothing gets created for you, and a mangle rule referencing a table that doesn’t exist marks packets into a void. This is the single most common reason “my PBR stopped working after the upgrade” threads exist on the MikroTik forum.
The v7 model is actually cleaner: create the table, put routes in it, then decide how traffic gets steered into it — with routing rules for simple cases, or mangle when you need firewall-grade matching. Get the order of operations right and PBR on RouterOS 7 is less magic and more engineering.
Step One: The Routing Table
Everything starts in /routing table. A custom table must be created explicitly, and it must have the fib flag if you want its routes actually installed into the kernel’s forwarding table:
# Create a routing table that will hold the alternate default route/routing table add name=via-isp2 fibForget fib and the table exists but never forwards a packet — routes in it stay in the RIB only. There’s no error, no warning. Check what you have:
/routing table print# Flags: D - dynamic; F - fib# 0 F name="main"# 1 F name="via-isp2"The main table is always there. Every VRF also gets a dynamic table (covered later in this series), but for PBR you create your own.
Step Two: Put Routes in the Table
Routes are assigned to a table with routing-table=. A typical dual-WAN setup:
# Default route for normal traffic (main table)/ip route add dst-address=0.0.0.0/0 gateway=192.0.2.1
# Alternate default via ISP2, only visible in via-isp2 table/ip route add dst-address=0.0.0.0/0 gateway=203.0.113.1 routing-table=via-isp2One subtlety worth knowing: gateway resolution in a custom table can point at another table using the @ syntax, e.g. gateway=203.0.113.1@main. You rarely need it for simple PBR, but it matters once tables don’t contain the connected route needed to resolve their own gateway.
A custom table is empty by default. It does not inherit anything from main. If a lookup happens in via-isp2 and there’s no matching route, what happens next depends entirely on the routing rule action — which is the interesting part.
Step Three: Routing Rules
/routing rule is the v7-native way to steer traffic. Rules are evaluated top-down at routing-decision time, before any table lookup, and the first match wins:
# Guest subnet goes out ISP2, no fallback/routing rule add src-address=10.10.20.0/24 action=lookup-only-in-table table=via-isp2
# Server subnet prefers ISP2 but may fall back to main/routing rule add src-address=10.10.30.0/24 action=lookup table=via-isp2The two lookup actions are not interchangeable, and picking the wrong one is a policy bug:
action=lookup— try the table; if no route matches, continue to the next rule and ultimately the main table. Failover-friendly, leak-prone.action=lookup-only-in-table— try the table; if no route matches, the packet is dropped. Strict. This is what you want for “VPN or nothing” policies.
There’s also action=drop and action=unreachable for building policy denials directly in the routing layer.
Rules can match on src-address, dst-address, interface (the incoming interface), routing-mark, and min-prefix. That’s the full list — and that’s the point. Routing rules are cheap and evaluated for every routing decision, including traffic originated by the router itself. If your policy is expressible as “this source / this ingress interface goes to that table,” use rules and stop there.
When You Need Mangle Instead
Routing rules can’t see ports, protocols, connection state, or content. Mangle can. The pattern is: mark the traffic in /ip firewall mangle with action=mark-routing, and the mark’s name selects the routing table.
The critical v7 change: the routing table must exist before the mangle rule references it. In v6, assigning a new-routing-mark implicitly created the table. In v7 it does not — WinBox will even refuse to offer a mark name that isn’t an existing table.
# Table already created above: via-isp2
# Route all outbound TCP/443 from one host via ISP2/ip firewall mangle add chain=prerouting src-address=10.10.30.15 \ protocol=tcp dst-port=443 action=mark-routing \ new-routing-mark=via-isp2 passthrough=noFor anything connection-oriented, mark the connection once and derive the routing mark from it — much cheaper than matching every packet against L4 fields:
/ip firewall mangle add chain=prerouting connection-mark=no-mark \ src-address=10.10.20.0/24 action=mark-connection \ new-connection-mark=guest-conn passthrough=yes/ip firewall mangle add chain=prerouting connection-mark=guest-conn \ action=mark-routing new-routing-mark=via-isp2 passthrough=noTwo gotchas that burn people every time:
- Router-originated traffic never traverses
prerouting. If the router itself must obey the policy (VPN client running on the router, DNS queries, etc.), you need the mark inchain=output— or better, use a routing rule, which applies to local traffic too. - NAT follows the egress interface, not your intent. If policy-routed traffic leaves a different interface, you need a masquerade/src-nat rule for that interface or the packets leave with an un-NATed source address and die.
My rule of thumb: routing rules first, mangle only when the match criteria demand it. Every mangle rule is per-packet firewall work; rules are a short list consulted during route lookup.
The Classic Use Case: VPN Egress for Selected Hosts
The full pattern, fail-closed: a handful of hosts must egress through WireGuard, and if the tunnel dies their traffic must drop rather than leak out the ISP.
# 1. The table/routing table add name=via-vpn fib
# 2. Default route through the WireGuard interface, only in that table# (wg-vpn peer configured with allowed-address=0.0.0.0/0)/ip route add dst-address=0.0.0.0/0 gateway=wg-vpn routing-table=via-vpn
# 3. Steer the chosen hosts — lookup-only-in-table means no leak on failure/routing rule add src-address=10.10.40.0/28 action=lookup-only-in-table table=via-vpn
# 4. NAT out the tunnel/ip firewall nat add chain=srcnat out-interface=wg-vpn action=masqueradeBecause the rule is lookup-only-in-table, removing or breaking the WireGuard route leaves those hosts with no route at all — traffic is dropped at the router. Swap it for action=lookup and you get automatic fallback to the ISP instead; decide which failure mode your policy actually wants. For a privacy VPN, fail-closed is the only defensible answer.
One refinement: exclude local destinations before the VPN rule, otherwise LAN-to-LAN traffic between subnets gets shoved into the tunnel table too:
# Keep RFC1918 destinations on the main table — place ABOVE the VPN rule/routing rule add src-address=10.10.40.0/28 dst-address=10.0.0.0/8 action=lookup table=mainRule order matters. /routing rule print shows them numbered; use move to reorder.
Verification
PBR that isn’t verified is PBR that’s silently broken. Three layers of checking:
1. Is the table populated and active?
/routing route print where routing-table=via-vpn# expect: dst-address=0.0.0.0/0, gateway=wg-vpn, flags A (active)
/ip route print detail where routing-table=via-vpn/routing route print shows everything the routing process knows, including inactive and filtered routes — it’s the v7 debugging view. If your route shows up without the active flag, the gateway isn’t resolving.
2. Are the rules matching?
/routing rule print# check order, src-address, action, table3. Does the path actually differ? Traceroute with a spoofed source is the killer trick — routing rules apply to router-originated traffic, so setting src-address to an IP inside the policy range exercises the rule:
# Path for a policy-routed host/tool traceroute 198.51.100.1 src-address=10.10.40.5
# Path for a normal host/tool traceroute 198.51.100.1 src-address=10.10.30.15First hop differs? Policy works. Same first hop? Your rule isn’t matching — check ordering and masks. For live confirmation, /tool torch on the tunnel interface while a policy-routed host generates traffic shows the flows in real time. For mangle-based setups, also watch the rule counters: /ip firewall mangle print stats — a mangle rule with zero packets is a mangle rule that never matched.
Closing Thoughts
RouterOS 7 turned PBR from implicit voodoo into explicit configuration: table, routes, steering — in that order. The mental checklist:
/routing table add ... fib— nofib, no forwarding.- Routes with
routing-table=— custom tables inherit nothing. /routing rulefor source/interface policies; mangle only when you need ports, connections, or content.lookupvslookup-only-in-tableis a deliberate choice between failover and fail-closed.- Verify with
/routing route print, rule counters, and source-spoofed traceroute.
I covered the VyOS flavor of this in the VyOS Guide — same concepts, different syntax. Later in this series, VRFs take the “multiple routing tables” idea further with full interface-level separation; PBR is the lightweight sibling you’ll reach for far more often.