Isolate by Default: Segmenting a Single-Host Homelab with VyOS

My homelab runs around fifty virtual machines on one physical Proxmox host. Templates and declarative provisioning make it easy to add another service. Until recently, its network was much less deliberate: workloads shared the LAN bridge with my laptop, printer, and infrastructure services.

That was convenient, but it gave a guest more access than most services needed. Protecting a public endpoint with authentication did not stop a compromised backend from reaching other machines on the network.

I wanted new isolated workloads to have their own segment, explicit egress, and a narrow publication path through Traefik. This post explains how I built that boundary with a VyOS router VM, how it fits the existing provisioning workflow, and where its limits remain.

The problem with a shared bridge

A typical Proxmox setup attaches guests to vmbr0, bridged into the physical LAN. Without additional filtering, that means:

  • Guests can reach other guests and devices in the house.
  • Internet access follows the same default route as my workstation.
  • Compromising one service can expose much more than that service.

My earlier access-layer work separates routing, authentication, and publication: Traefik routes requests, Authentik authenticates users, and Vault signs SSH certificates. Those controls still matter, but they do not replace a network boundary around the backend.

Requirements before tools

Before choosing the router, I wrote down the intended boundaries:

  1. Explicit guest policy. An isolated VM gets only the reachability allowed by its policy, rather than inheriting LAN access.
  2. Per-VM egress. Direct internet access or a VPN path is chosen in the declaration that creates the VM.
  3. No default VM-to-VM access. Unrelated workloads should not be able to talk to each other.
  4. Restricted LAN access. Guests should not initiate connections to my workstation, storage, or infrastructure except for explicitly permitted services. Traefik needs access to published backend ports.
  5. A recoverable administration path. Daily SSH administration uses the bastion; the Proxmox console remains available when guest networking is broken. That operational choice is not the same as a bastion-only firewall rule, as I explain below.
  6. The identity stack stays on the LAN. Vault, Traefik, and Authentik remain outside the guest router's failure domain. SSH certificates depend on Vault, so putting it behind that router would make recovery unnecessarily dependent on the component being repaired.

This is a boundary for workloads, not a replacement for the physical network. Keeping the existing infrastructure reachable is part of the recovery design.

Why I chose VyOS

I needed a router that could run as a VM on Proxmox and fit a declarative workflow. VyOS gives me VLAN interfaces, routing, NAT, and named firewall policies in one configuration model. Its HTTPS API lets the runner apply network policy alongside the VM declaration instead of requiring a separate series of changes in a router UI.

That fit matters more to me than a broad feature comparison. A workload's network policy should be reviewable beside its CPU, memory, and disk settings. The runner can derive the guest interface and policy from that entry, while drift guards check whether the existing router configuration still matches expectations before changing it.

VyOS also has uses beyond homelabs. Its vendor-published customer stories describe Carrefour Belgium using it to route between Google Cloud VPCs, and Stream IT Networks deploying it in a rural ISP network. Those are examples of virtual and physical routing roles, not evidence that my firewall design is secure.

The trade-off is a shared router dependency. If that VM stops, isolated guests lose connectivity. The physical LAN is unchanged, and the Proxmox console is the recovery path. On a single host, this is not an HA design.

A router VM and a software-only switch

The design uses two bridges on the Proxmox host:

  • vmbr0 — the existing LAN bridge, bound to the physical NIC.
  • vmbr2 — a VLAN-aware bridge with no physical ports.

The second bridge switches traffic between attached VMs without a direct physical uplink. VLAN filtering separates their layer-2 networks: a guest in VLAN 203 does not share a broadcast domain with a guest in VLAN 201. Traffic routed between those networks must then pass the router's policy.

The VyOS VM is attached to both bridges. Its untagged LAN interface on vmbr0 provides management and upstream connectivity. Its trunk interface on vmbr2 carries the guest VLANs. Each isolated guest has one NIC assigned to its VLAN:

guest VM (VLAN 203) -> vmbr2 -> VyOS trunk -> VyOS LAN -> vmbr0

VLAN 203 uses 10.60.3.0/29, with the router at 10.60.3.1 and the VM at 10.60.3.2. The VLAN ID is not the subnet's third octet. The other current segments follow the same addressing pattern

A /29 is more address space than one guest needs, but it keeps the networks small and readable. It does not enforce one VM per segment or prevent another machine from being attached. Isolation depends on the bridge assignments and firewall policy, including restrictions to the single declared guest IP.

Policy lives in named chains, not in one long lis VyOS builds on nftables. Rather than maintaining one long list of guest rules, each VLAN gets a pair of named chains:

  • TR_203_FROM — traffic leaving VLAN 203.
  • TR_203_TO — traffic entering VLAN 203.

The forward chain classifies traffic by interface and dispatches it to the appropriate policy. FROM rules restrict the source to the declared VM IP; TO rules restrict the destination to that IP. Both chains default to drop. This keeps a policy tied to a workload rather than granting permissions to an entire /29.

For chess, the guest address is 10.60.3.2. Its policy permits selected traffic to Traefik on TCP 80/443 and direct internet egress. Traefik is allowed to reach the guest's backend on TCP 8080. Guest-initiated access to other LAN destinations and unrelated guest networks is not granted. Guest IPv6 forwarding is blocked rather than left as an alternative path.

Read as a policy statement: the chess VM has an explicit outbound policy, a published backend reachable from the proxy, and a separate administration policy. The current TO chain allows LAN-sourced SSH on TCP 22, ICMP, and established traffic arriving through eth1. My intended daily SSH path is the bastion, but the firewall currently permits the wider LAN source range. It would be misleading to call that bastion-exclusive enforcement.

Connection state is handled within the guest policies. There is no global established-traffic acceptance shortcut. That makes the policy context visible, but state rules alone do not prove that removing an allow-rule immediately terminates every existing flow. I keep that separate from the VPN fail-closed requirement and from acceptance testing.

There is no masquerading toward the LAN. Guest traffic to Traefik keeps its source address, so the proxy can see 10.60.3.2 rather than the router's LAN address for guest-originated requests. The return path needs a static route on the proxy for 10.60.0.0/16 through VyOS. Without it, replies follow the proxy's default gateway instead. This route was part of the chess migration fix. It preserves source attribution without using NAT for the LAN path.

Egress belongs in the VM declaration

Direct egress uses the upstream LAN connection and NAT at the router's egress interface. VPN egress uses the selected tunnel and policy-routing table. The choice belongs in the inventory, not in a separate router setting that is easy to forget when reviewing a VM.

For a VPN-routed guest, the intended failure mode is no internet access when its tunnel is unavailable, not fallback to direct egress. That is the fail-closed design requirement, not yet validated by a live tunnel-loss test. Chain-local connection-state rules alone do not establish it.

Making changes without surprising existing workloads

A router VM is easy to change, so the automation needs boundaries of its own:

  • API configuration, console recovery. Router SSH is disabled. The VyOS HTTPS API is used from the terraform runner host ; the Proxmox console is the fallback when the network path is unavailable.
  • Guarded provisioning. The provisioning path is add-oriented and checks existing configuration before applying changes. A VyOS set command can replace a value, so using set alone is not an overwrite safeguard. Drift guards compare hashes and retain full configuration text to make differences inspectable. Historical hash-only records cannot recover the old text.
  • A journal before the transaction. Planned router changes are recorded before the commit. A timeout or interrupted apply requires inspection of what was in flight rather than a blind retry.
  • Existing VMs are protected. Terraform uses ignore_changes = all and prevent_destroy for existing VMs. This workflow does not use terraform destroy. I often delete a VM manually in Proxmox and then reconcile it. Runner option 3 can also deliberately delete a VM through the API after confirmations; neither path uses Terraform destroy.

Credentials are authoritative in Vault. On my local PC, the Vault token is supplied interactively or through standard input; it is never saved or transferred to terra as a file. Credentials do not belong in the VM inventory or the change journal.

There is also a managed command on terra for Traefik TCP backend permissions: vmport add, vmport del, and vmport show. That narrow permission-management path is distinct from provisioning a network or removing a VM.

The declaration, end to end

The chess VM's inventory entry contains the compute settings and network policy together:

"analytics": {
  "node": "prox",
  "cpu_cores": 32,
  "memory_mb": 15360,
  "disk_gb": 15,
  "template_id": 902,
  "network_mode": "isolated",
  "vlan_id": 203,
  "ipv4": "10.60.3.2",
  "gateway": "10.60.3.1",
  "prefix_length": 29,
  "egress": "direct",
  "dns_servers": ["9.9.9.9", "149.112.112.112"],
  "docker": true,
  "traefik_ports": [8080]
}

From this entry the runner derives the tagged NIC on vmbr2, the router's VLAN interface and subnet, the FROM/TO policies, egress NAT, and the rule allowing Traefik to reach TCP 8080. The backend permission is for the proxy, not general LAN access to that port. Administrative SSH has the broader LAN permission described above.

Publication remains a separate access-layer concern. Traefik's file-provider route points to http://10.60.3.2:8080, with authentication chosen per route. Cloud-init supplies the VM's address, gateway, and DNS. The intended state is reviewable in the same inventory diff as the VM.

Migrating a real workload

The first migrated workload was a stateful analytics stack: a locally built API, workers, a web frontend, and a container handling TLS and forward-auth at the VM's edge.

The migration used a new isolated VM with direct egress. I pre-synced the repository and Docker volume while the old VM continued serving, built the images on the new machine, and loaded its data. During the maintenance window, I stopped the old stack, synced the changed database files, and switched the Traefik backend to http://10.60.3.2:8080. The chess migration and cutover are complete.

The application did not need a new networking model. Its backend still uses port 8080; the router and proxy define how that port is reached. The proxy's return route was one infrastructure detail that did need attention.

The image build also exposed a compute issue: exit code 132, an illegal instruction. The worker build needed CPU features that Proxmox's default kvm64 model did not expose. New VMs now default to x86-64-v4, with host available as an explicit per-VM opt-in. That baseline still requires compatible physical hardware; it is not a promise that any future AMD or Intel node will support it. CPU compatibility belongs in the VM contract just as much as its network settings do.

What this changes, and what it does not

I now have a place to express the access a workload needs instead of assuming it can reach the whole LAN. VLAN separation handles the layer-2 boundary, the router handles permitted routed traffic, and Traefik remains the publication path. The declaration connects those decisions to the VM that uses them.

There are still important limits:

  • The router and guests share one physical host. A router outage removes guest connectivity; a host outage removes both. The LAN is not being redesigned, and console recovery remains necessary.
  • The daily bastion workflow is narrower than the currently allowed LAN SSH sources. Operational intent and firewall enforcement are not yet identical.
  • A strict reverse-path-filtering hardening tool is installed, but its execution is unconfirmed. Global strict uRPF can conflict with policy-routed or asymmetric paths; I do not count it as applied or risk-free hardening.

For my single-host setup, the useful improvement is a smaller, explicit network trust boundary with a visible recovery path. It does not make the homelab highly available or remove the need to test policy changes. It makes those decisions easier to review before another service is added.