A Raspberry Pi that only needs to protect its own outbound traffic has two realistic designs: let the network manager own the tunnel, or keep a script that owns it. Both can carry traffic. They fail in different places, and the failure you can diagnose remotely at two in the morning is the one worth choosing. Compare them on ownership, on what full tunnel means once IPv6 and DNS are involved, and on what happens the moment the tunnel drops.

Two owners for one tunnel

Design A is a profile inside the system's network manager: an imported OpenVPN profile or a native WireGuard connection, activated at boot by the same service that brings up Ethernet and Wi-Fi. Design B is a unit of your own that runs the client directly, edits the resolver configuration and installs its own routes and firewall rules. Both are legitimate designs; they differ in who is allowed to change the routing table.

The failures differ because the owners differ. With A, a profile that will not activate leaves the interface up and the tunnel down, and the manager's logs say why. With B, a script that half-succeeds leaves routes behind that survive the process that created them. Running both is the worst outcome: two processes writing the default route, two resolver edits racing at boot, and two sets of secrets to rotate.

Pick one owner per tunnel and disable the other before you test anything. Two owners also means two places to look when the tunnel is up and nothing resolves. That single rule prevents most of the confusing states described in Pi VPN threads.

What an import creates, and what it does not

On Raspberry Pi OS Bookworm and later the default network configuration tool is NetworkManager, so a profile is the natural home for a client tunnel. Import needs the matching plugin installed; without it the import fails with a message about an unsupported connection type, which is the honest signal to stop and install the plugin.

nmcli connection import type openvpn file ./provider.ovpn
nmcli connection show
nmcli --ask connection up id provider-name

Importing stores a profile. It does not prove the far side is reachable, and it does not guarantee that every directive in the file survived the conversion. Read the resulting connection back and check the parts that carry identity and trust: the certificate, the remote host and port, and whether a password is asked for or stored.

Activation with --ask keeps a passphrase out of shell history on a shared box. For an unattended Pi you eventually want the profile to come up at boot, which is a separate decision from getting it up once by hand.

Full tunnel, compared across both designs

A full tunnel is a claim about routes, not about intent, and the two designs express it differently. The profile approach asks the manager to install a default route and its resolver settings; the script approach does whatever the script says, including nothing. Neither design covers IPv6 unless it is told to, and an untouched IPv6 default route will happily carry traffic outside the tunnel while IPv4 looks perfect.

ip -4 route get 198.51.100.23
ip -6 route show default
resolvectl query example.org

Use a documentation address for the route lookup and substitute a host you are allowed to probe. The first command shows which interface the Pi would use for an ordinary destination; the second shows whether an IPv6 default still exists; the third proves which resolver answered. Read all three before believing any claim about a full tunnel.

If the provider's instructions promise a full tunnel but the IPv6 default survives, the Pi is leaking. Disable IPv6 on the uplink or ask the profile to claim it, and decide deliberately rather than discovering it later.

Fail-open against fail-closed

When the tunnel drops, a routing table that loses its default route simply falls back to the physical uplink, and traffic continues in the clear. That is fail-open, and it is the default behaviour of both designs unless something prevents it. Most people discover it by reading a firewall log weeks later.

Fail-closed costs you remote access when the tunnel is down, so it needs a deliberate exception for the management path. One way is a rule that permits only the VPN peer's address outside the tunnel:

sudo nft add table inet vpnkill
sudo nft 'add chain inet vpnkill out { type filter hook output priority 0; policy accept; }'
sudo nft add rule inet vpnkill out oifname != "tun0" ip daddr != 203.0.113.24 reject

Compare the operational cost honestly. A profile cannot usually express this rule, so fail-closed pushes you toward the script design or toward a small unit that installs the rule independently of the tunnel. Either way, log the rejects for a week: a silent reject looks identical to a broken service, and you will want the counter when a user reports that one site stopped loading.

Choosing for a headless Pi

If the image already runs NetworkManager, the tunnel needs nothing exotic, and you want it to follow the uplink automatically, take the profile. If you need policy routing, several tunnels selected per destination, or a fail-closed rule that must exist before the tunnel comes up, take the script and own every part of it, including boot order and cleanup. Mixing the two designs costs more than either one alone.

Migrating between them is mostly a matter of order: disable the old owner, confirm no stale route or resolver entry survives, then enable the new one and reboot twice. A single reboot can hide a race that a second one exposes. Finish by testing the Pi rather than the tunnel: resolve a name, fetch something over IPv4, fetch over IPv6 if you kept it, then drop the tunnel and confirm the behaviour you chose actually happens. An interface in ip link is the start of the test, not the end.

Pennadoù liammet