Fail-closed WireGuard confinement for selected NixOS systemd services.
vpn-confinement places selected services into dedicated network namespaces
with namespace-local nftables policy, generated resolver configuration, and
systemd lifecycle wiring so tunnel or namespace loss propagates cleanly to
confined workloads.
The project currently targets NixOS unstable.
It is intended for the common NixOS case where only specific services should use VPN egress while the host and other workloads remain on normal networking.
- Docs site: https://ianhollow.github.io/vpn-confinement/
- Architecture:
site/src/content/docs/architecture.md - Threat model:
site/src/content/docs/threat-model.md - Generated options reference:
site/src/content/docs/reference/options-generated.md
Canonical docs live in site/src/content/docs/. Root community docs are synced
into the docs site during docs builds.
- Confine only the services that should use the tunnel.
- Keep host networking unchanged for non-confined workloads.
- Apply DNS and firewall policy at the namespace boundary.
- Prefer fail-closed teardown when the namespace or tunnel disappears.
Add the module and opt specific services into confinement:
{inputs, ...}:
{
imports = [ inputs.vpn-confinement.nixosModules.default ];
services.vpnConfinement = {
enable = true;
namespaces.vpnapps = {
enable = true;
wireguard.interface = "wg0";
dns = {
mode = "strict";
servers = [ "10.64.0.1" ];
};
ipv6.mode = "disable";
egress.mode = "allowAllTunnel";
};
};
networking.wireguard.interfaces.wg0 = {
privateKeyFile = "/run/keys/wg.key";
ips = [ "10.0.0.2/32" ];
peers = [
{
publicKey = "...";
endpoint = "198.51.100.10:51820";
allowedIPs = [ "0.0.0.0/0" ];
}
];
};
systemd.services.my-service = {
serviceConfig.DynamicUser = true;
vpn = {
enable = true;
namespace = "vpnapps";
};
};
}By default, namespace selection is explicit. Set vpn.namespace on each
confined service or socket, or configure
services.vpnConfinement.defaultNamespace if you intentionally want one shared
default.
For exact option names and defaults, start with the generated options reference
in site/src/content/docs/reference/options-generated.md.
- Opt-in model per service or socket (
systemd.services.<name>.vpn.enable,systemd.sockets.<name>.vpn.enable). - The trust boundary is the namespace
(
one namespace = one DNS/firewall policy). - Namespace-local nftables uses deny-by-default tunnel policy.
dns.mode = "strict"blocks classic DNS-like leak ports (53,853,5353,5355) and pins resolver configuration.- IPv6 is fail-closed by default.
- Tunnel or namespace loss propagates teardown to dependent units.
Profiles:
balanced: secure defaults with explicit compatibility escape hatches.highAssurance: the strict preset with stronger service hardening defaults and stricter secret handling.
highAssurance requires:
dns.mode = "strict"egress.mode = "allowList"- non-empty
egress.allowedCidrs - non-root service execution by default (
DynamicUser = trueor non-rootUser), unless explicitly opted out withsystemd.services.<name>.vpn.allowRootInHighAssurance = true - literal WireGuard endpoints (hostname endpoints rejected)
allowedIPsAsRoutes = true- no inline
networking.wireguard.interfaces.<if>.privateKey
WireGuard endpoint pinning is available with
services.vpnConfinement.namespaces.<name>.wireguard.endpointPinning.enable. It
requires literal peer endpoint IPs and enforces in the effective WireGuard
socket birthplace namespace (init by default, or custom
wireguard.socketNamespace when configured). See
site/src/content/docs/threat-model.md for scope and caveats.
highAssurance rejects inline WireGuard private keys because they land in the
world-readable Nix store. Use privateKeyFile or generatePrivateKeyFile
instead.
- Format:
nix fmt - Validate:
nix flake check --show-trace --system <x86_64-linux|aarch64-linux> - Regenerate options reference:
bash scripts/generate-options-doc.sh x86_64-linux - Build docs site:
bun run --cwd site build
- Contributing guide:
CONTRIBUTING.md - Code of Conduct:
CODE_OF_CONDUCT.md - Security reporting:
site/src/content/docs/security.md
MIT
