Add a policy rule with ip rule and it works until the link goes away, at which point it is simply gone. Nothing failed loudly: the kernel state changed, and no configuration owner knew the rule was supposed to exist. That is the whole difference between a rule that survives and one that does not. On a systemd-networkd host, the fix is to give the rule an owner rather than to re-run the command after every reconnect.

A Rule Exists Only If Someone Owns It

Kernel routing policy is state rather than configuration. It lives in the running system, and anything that tears down the interface it depends on can take it along. A shell command changes that state and leaves no record of intent.

Network managers work the other way around: they read configuration, apply it, and re-apply it when a link event invalidates the previous state. A rule declared in their configuration is recreated, while a rule added beside them is a foreign object with no owner.

That distinction has a name in networkd, and it is worth reading before enabling anything. Foreign rules are removed by default on recent versions, which surprises people who expected their manual entries to persist.

Read the Version Before You Trust a Setting

The ownership behaviour is controlled by a setting in networkd.conf, and that setting does not exist in older systemd releases. Check the installed version before designing around it, because an older host behaves differently from the documentation you are reading.

Two values matter. One decides whether rules not declared in networkd configuration are removed, and the other does the same for routes. Both are about ownership rather than about policy content, and both default to the stricter behaviour on current releases. Turning them off globally keeps every foreign rule alive, which is a much broader decision than declaring one intended VPN policy.

systemctl --version | head -1
networkctl --version | head -1
grep -R "ManageForeign" /etc/systemd/ /usr/lib/systemd/ 2>/dev/null

That grep tells you what is set locally and what the vendor default is. Absence is information too, since it means the compiled default applies. Write the default into your notes so the next person does not rediscover it.

Declare the Rule and the Table It Points At

A rule in a .network file names a table, and a name is all it does. It does not create the routes inside that table, and it does not check that they make sense. Declaring the rule without the routes produces a policy that matches traffic and then drops it.

Declare the routes in the same file, with reachable next hops and the right table. Keep the priority in the declared rule so it lands where you expect in the ordered list. A rule that reaches the wrong table earlier than intended changes more traffic than the service you meant to fix. Read the fragment below as a shape rather than as a complete file, since interface ownership and the rest of the connection still have to be present.

[RoutingPolicyRule]
Family=ipv4
IPProtocol=tcp
SourcePort=443
Table=100
Priority=1000

[Route]
Destination=10.80.0.0/24
Gateway=10.80.0.1
Table=100

Then reload and read back the kernel state rather than the file. A configuration that parsed cleanly can still produce a rule you did not intend, and the two views agree only when the declaration was complete.

Pick One Owner for the Interface

Two managers on one tunnel produce a fight that looks like a bug in both. Importing an existing wg-quick setup into networkd is a migration rather than an addition, and running both leaves the interface and its rules under two owners.

Do the migration in one pass: stop the old unit, disable it, declare the interface and its routes in networkd, then reload. Check that the old unit cannot come back at boot. A leftover unit that reactivates after a reboot overwrites the state you just declared. Keep the old configuration file until the new one has survived a reconnect, then retire it.

Mark ownership deserves the same care: if a rule matches a firewall mark, something has to set that mark. A rule referencing a mark nobody sets is dead configuration that looks deliberate.

Rehearse the Lifecycle Events

Configuration is only as good as its behaviour across events. Take the link down, bring it up, restart the service, and renew the address. After each one, read the rule list and the table contents.

sudo networkctl reload
ip rule show
ip route show table 100
sudo systemctl restart systemd-networkd

The intended result is the same owned policy recreated and obsolete state retired, which is more durable than re-adding a shell rule after every reconnect. Keep one line in the change record naming the file that owns the rule and the version that supports it. Then the next engineer can find the policy without searching a shell history. A rule nobody owns is a rule that will be lost again.

Artitgels cun il medem tema