Raw ChatGPT ouput
The robust design is:
Selected Home Manager Podman units
↓
podman-novpn.slice
↓
nftables matches the slice cgroup and sets one reserved fwmark bit
↓
ip rule selects a dedicated routing table
↓
traffic leaves through the ordinary physical interface, bypassing VPN routes
It stays rootless, keeps NetworkManager, requires no VM/macvlan/router changes, and does not classify the entire user by UID.
1. Home Manager: dedicated slice and readiness marker
Replace podman-example.service with the actual units generated for the selected containers.
{ pkgs, ... }:
{
systemd.user.slices.podman-novpn = {
Unit.Description = "Rootless containers routed outside host VPNs";
};
# Keeps the slice instantiated even when no selected container is running.
# The marker tells the system-level path unit that the cgroup now exists.
systemd.user.services.podman-novpn-keeper = {
Unit = {
Description = "Keep podman-novpn.slice instantiated";
Before = [
"podman-example.service"
];
};
Service = {
Type = "simple";
Slice = "podman-novpn.slice";
ExecStartPre =
"${pkgs.coreutils}/bin/touch %t/podman-novpn.ready";
ExecStart =
"${pkgs.coreutils}/bin/sleep infinity";
ExecStopPost =
"${pkgs.coreutils}/bin/rm -f %t/podman-novpn.ready";
Restart = "always";
RestartSec = 1;
};
Install.WantedBy = [ "default.target" ];
};
# Apply this override to each selected rootless Podman service.
systemd.user.services.podman-example.Service.Slice =
"podman-novpn.slice";
}
Find the generated Podman unit names with:
systemctl --user list-unit-files 'podman-*.service'
Confirm placement with:
systemctl --user show podman-example.service \
-p Slice \
-p ControlGroup
2. NixOS: account and stable identifiers
Use the actual username and physical interface. Explicitly assigning the UID makes the runtime-directory path deterministic; it will commonly be 1000.
{
users.users.alice = {
uid = 1000;
linger = true;
# Preserve the rest of the user's normal account configuration.
};
}
Lingering keeps the user systemd manager and rootless containers running without an interactive login.
Use named constants for the routing pieces:
let
user = "alice";
uid = config.users.users.${user}.uid;
physicalInterface = "wlp82s0";
routeTableId = 200;
routeTableName = "podman-novpn";
# One reserved bit, rather than replacing the whole packet mark.
routeMark = "0x40000000";
routeMarkMask = "0x40000000";
# Must precede relevant VPN catch-all rules.
rulePriority = 1000;
in
The values are arbitrary local allocations. Keep them centralized and check for conflicts with:
ip rule show
sudo nft list ruleset
Name the routing table:
environment.etc."iproute2/rt_tables.d/podman-novpn.conf".text = ''
${toString routeTableId} ${routeTableName}
'';
3. System reconciler
The reconciler:
- Finds the real
podman-novpn.slice cgroup at runtime.
- Calculates the correct nftables cgroup level.
- Reads the current non-VPN routes from the physical interface.
- Rebuilds the dedicated routing table.
- Replaces the owned nftables table.
- Replaces the matching policy rule.
{ config, lib, pkgs, ... }:
let
user = "alice";
uid = config.users.users.${user}.uid;
physicalInterface = "wlp82s0";
routeTableId = 200;
routeTableName = "podman-novpn";
routeMark = "0x40000000";
routeMarkMask = "0x40000000";
rulePriority = 1000;
cleanupScript = pkgs.writeShellApplication {
name = "cleanup-podman-novpn";
runtimeInputs = [
pkgs.iproute2
pkgs.nftables
];
text = ''
set -u
nft delete table inet podman_novpn 2>/dev/null || true
while ip -4 rule delete \
priority ${toString rulePriority} \
fwmark ${routeMark}/${routeMarkMask} \
table ${toString routeTableId} 2>/dev/null
do
:
done
ip -4 route flush \
table ${toString routeTableId} 2>/dev/null || true
'';
};
syncScript = pkgs.writeShellApplication {
name = "sync-podman-novpn";
runtimeInputs = [
pkgs.coreutils
pkgs.findutils
pkgs.gawk
pkgs.gnugrep
pkgs.iproute2
pkgs.nftables
];
text = ''
set -euo pipefail
readonly iface=${lib.escapeShellArg physicalInterface}
readonly uid=${toString uid}
readonly table_id=${toString routeTableId}
readonly priority=${toString rulePriority}
readonly mark=${lib.escapeShellArg routeMark}
readonly mark_mask=${lib.escapeShellArg routeMarkMask}
readonly nft_family=inet
readonly nft_table=podman_novpn
user_root="/sys/fs/cgroup/user.slice/user-''${uid}.slice/user@''${uid}.service"
cgroup_absolute="$(
find "$user_root" \
-type d \
-name podman-novpn.slice \
-print \
-quit 2>/dev/null || true
)"
if [[ -z "$cgroup_absolute" ]]; then
echo "podman-novpn.slice does not currently exist"
${lib.getExe cleanupScript}
exit 0
fi
cgroup_path="''${cgroup_absolute#/sys/fs/cgroup/}"
cgroup_level="$(
awk -F/ '{ print NF }' <<<"$cgroup_path"
)"
if [[ ! -e "/sys/class/net/$iface" ]]; then
echo "Physical interface does not exist: $iface"
${lib.getExe cleanupScript}
exit 0
fi
if ! ip -4 route show table main default dev "$iface" |
grep -q '^default '; then
echo "No ordinary default route through $iface"
${lib.getExe cleanupScript}
exit 0
fi
echo "Using cgroup: $cgroup_path"
echo "Using interface: $iface"
# Copy the current IPv4 routes associated with the physical interface.
# This picks up DHCP gateway, connected LAN route, preferred source, etc.
ip -4 route flush table "$table_id"
while IFS= read -r route; do
[[ -n "$route" ]] || continue
read -r -a route_argv <<<"$route"
ip -4 route replace \
table "$table_id" \
"''${route_argv[@]}"
done < <(
ip -4 route show table main dev "$iface"
)
# Rebuild our dedicated nftables table.
nft delete table "$nft_family" "$nft_table" \
2>/dev/null || true
nft -f - <<EOF
table $nft_family $nft_table {
chain mark_output {
type route hook output priority mangle; policy accept;
socket cgroupv2 level $cgroup_level "$cgroup_path" \
meta mark set meta mark | $mark \
counter \
comment "Route podman-novpn.slice outside host VPNs"
}
}
EOF
# Replace only our exact policy-routing rule.
while ip -4 rule delete \
priority "$priority" \
fwmark "$mark/$mark_mask" \
table "$table_id" 2>/dev/null
do
:
done
ip -4 rule add \
priority "$priority" \
fwmark "$mark/$mark_mask" \
table "$table_id"
'';
};
in
{
assertions = [
{
assertion = uid != null;
message = "users.users.${user}.uid must be explicitly configured";
}
];
networking.nftables.enable = true;
environment.etc."iproute2/rt_tables.d/podman-novpn.conf".text = ''
${toString routeTableId} ${routeTableName}
'';
systemd.services.podman-novpn-sync = {
description =
"Reconcile non-VPN routing for selected rootless Podman services";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = lib.getExe syncScript;
ExecReload = lib.getExe syncScript;
ExecStop = lib.getExe cleanupScript;
};
};
}
The nftables chain must be a route output chain. That causes route selection to be reconsidered after the packet mark changes.
The mark operation preserves unrelated bits:
meta mark set meta mark | 0x40000000
The ip rule tests only that reserved bit:
fwmark 0x40000000/0x40000000
4. Event-driven cgroup activation
The Home Manager keeper creates:
/run/user/1000/podman-novpn.ready
Watch that exact marker using a system path unit:
systemd.paths.podman-novpn-sync = {
description = "Watch for Podman non-VPN slice readiness";
wantedBy = [ "paths.target" ];
pathConfig = {
PathExists =
"/run/user/${toString uid}/podman-novpn.ready";
Unit = "podman-novpn-sync.service";
};
};
This avoids hard-coding the actual cgroup path or cgroup depth. The sync script discovers both after the marker appears.
Because the sync service uses RemainAfterExit = true, the persistent marker does not cause a repeated activation loop.
5. Event-driven NetworkManager updates
NetworkManager remains responsible for Wi-Fi, Ethernet, DHCP, roaming, and the desktop network.
Install a dispatcher that reconciles when the selected physical interface changes:
environment.etc."NetworkManager/dispatcher.d/90-podman-novpn".source =
pkgs.writeShellScript "podman-novpn-networkmanager-dispatcher" ''
interface="$1"
action="$2"
case "$action" in
up|down|dhcp4-change|connectivity-change)
if [[ "$interface" == ${lib.escapeShellArg physicalInterface} ]]; then
${pkgs.systemd}/bin/systemctl \
reload-or-restart \
--no-block \
podman-novpn-sync.service
fi
;;
esac
'';
There is no polling timer.
The relevant events are:
user slice becomes ready
→ systemd.path starts reconciler
physical route or DHCP changes
→ NetworkManager dispatcher reloads reconciler
NixOS configuration changes
→ systemd unit definitions are updated during rebuild
6. Activation and verification
After applying both NixOS and Home Manager configurations:
systemctl --user status podman-novpn-keeper.service
systemctl --user show podman-novpn.slice -p ControlGroup
Trigger or inspect the system configuration:
sudo systemctl reload-or-restart podman-novpn-sync.service
sudo nft list table inet podman_novpn
ip rule show
ip route show table podman-novpn
Check an individual container unit:
systemctl --user show podman-example.service \
-p Slice \
-p ControlGroup
It should report:
Slice=podman-novpn.slice
With a full-tunnel VPN active:
curl -4 https://ifconfig.me
podman exec example curl -4 https://ifconfig.me
The host request should use the VPN exit, while the selected container should use the ordinary physical-interface route.
Remaining caveat
A VPN kill switch may install firewall rules that drop all direct traffic leaving the physical interface. This setup controls routing, but cannot override a later firewall rejection.
If that happens, the VPN firewall must permit traffic carrying:
0x40000000/0x40000000
Also check ip rule show after connecting the VPN. The podman-novpn rule must appear before any relevant VPN catch-all rule; lower numerical priority values run first.