The network is the foundation of everything in the lab. Four VLANs, a FortiGate doing all the routing, a Cisco 3560 doing the switching, and a set of rules about what can talk to what. This post covers how it’s all wired together, how DNS works across zones, and how remote access gets in without exposing anything to the internet.

The Physical Layer#

One Cisco 3560 48-port switch handles all the physical connections. Every host in the lab plugs into it. The switch trunks to the FortiGate on a single uplink carrying all four VLANs tagged. The FortiGate terminates each VLAN on a sub-interface and handles all inter-VLAN routing.

The switch is the one piece of infrastructure that’s still manually configured via CLI. No Ansible, no Terraform. I’d love to have private VLANs configured for better intra-VLAN isolation in the DMZ, and the switch supports it, but standing up Cisco IOS automation for a single switch that changes twice a year isn’t worth the effort. It’s on the list. It’ll stay on the list.

VLANs and Zones#

VLANSubnetFortiGate ZoneInterfacePurpose
1 (native)10.x.x.0/24SHAREDinternal7Servers, k8s nodes, NAS, monitoring, runner
20010.x.x.0/24USERSusers-vlan-200Desktops, laptops, phones, wireless
310.x.x.0/24DMZdmzPublic-facing game servers
200010.x.x.0/24PRODprod-vlan-2000Reserved/future

The FortiGate sits at x.x.x.1 on every subnet. No direct routes between VLANs exist. If traffic needs to cross zones, there’s a firewall policy for it or it gets dropped. Every policy is managed in Terraform.

Traffic Flow Rules#

The rules are simple and strict:

SHARED zone is where the infrastructure lives. K8s nodes, the NAS, the monitoring stack, the GitHub Actions runner, the DNS server. Hosts on SHARED can reach the internet (policy 300, outbound NAT). The runner can SSH into DMZ hosts for Ansible deployments (policy 310).

USERS zone is for clients. Desktops, laptops, phones. USERS can reach SHARED for DNS (policy 309, UDP+TCP/53 to dnsmasq at 10.x.x.x) and for specific services like Plex (policy 314, TCP/32400 to nebula nodes). USERS cannot SSH into SHARED hosts. Admin access from USERS to the k8s nodes requires a specific admin policy (311).

DMZ zone is the strictest. Game servers face the internet through FortiGate VIP NAT rules. DMZ hosts never initiate connections into SHARED or USERS. The one exception is Promtail log shipping from DMZ to Loki at 10.x.x.x:3100 (a specific policy for TCP/3100). DMZ hosts don’t use internal DNS. They use public resolvers only. Allowing DNS queries from the DMZ into the internal network would be an attack vector.

PROD zone is mostly empty. Reserved for future use. PROD can reach dnsmasq for DNS (policy 315).

DHCP#

The FortiGate runs DHCP for all four VLANs, all managed in Terraform (dhcp.tf in each environment). SHARED and USERS DHCP hand out 10.x.x.x (dnsmasq) as the primary DNS server with 1.1.1.1 as fallback. DMZ DHCP uses the FortiGate’s default DNS (public resolvers only). The MetalLB VIP at 10.x.x.x is reserved in the SHARED DHCP range so it doesn’t get handed out to a random host.

DMZ hosts get DHCP-assigned IPs on first boot, then get locked down with DHCP reservations so the IPs don’t shuffle on reboot. The reservations are in environments/dmz/heezy/dhcp.tf and include the MAC address of each VM’s network interface.

DNS#

Split-horizon DNS via dnsmasq at 10.x.x.x. Full details in the DNS post, but the short version:

  • heezy.local: internal only, auto-generated from Ansible inventory, k8s services round-robin across all 5 nodes
  • yourdomain.tld: public domain on Cloudflare, but dnsmasq overrides SWAG-proxied subdomains to point at the MetalLB VIP (10.x.x.x) so LAN clients skip Cloudflare
  • Everything else: forwarded to 1.1.1.1 and 8.8.8.8

Ingress from the Internet#

Two paths in, neither exposes a port on the FortiGate:

Cloudflare Tunnel for web services. The SWAG pod in the k8s cluster creates an outbound-only tunnel to Cloudflare’s edge. Traffic for *.yourdomain.tld flows through the tunnel to SWAG, which reverse-proxies to internal ClusterIP services. The FortiGate never sees an inbound connection.

FortiGate VIP NAT for game servers. Minecraft and CS 1.6 need direct UDP connectivity that can’t go through a Cloudflare tunnel. These get traditional VIP NAT rules on the FortiGate: external IP + port mapped to the DMZ host’s internal IP + port. The firewall policies restrict inbound traffic to only the specific game ports. Full list on the game servers page.

Remote Access#

Tailscale handles all remote access. A Tailscale exit node runs on nebula-3 in the k8s cluster, advertising the home subnet (10.x.x.0/20). From any device on the tailnet, I can reach every host on every VLAN as if I were on the LAN.

No FortiGate SSL VPN exposed. No ports opened for remote access. The FortiGate has a CVE problem with its SSL VPN that makes exposing it to the internet a bad idea. Tailscale creates outbound-only WireGuard tunnels, so the attack surface is zero.

MetalLB VIP#

The k8s cluster has a single MetalLB VIP at 10.x.x.x. The SWAG LoadBalancer service claims it. This is the one IP that all internal DNS overrides for yourdomain.tld point at, and it’s the IP that the Cloudflare Tunnel terminates to internally.

MetalLB VIPs only serve LoadBalancer-type services. NodePorts are not accessible on the VIP. So heezy.local services (which use NodePorts) still round-robin across all 5 nodes. This is acceptable for internal/admin use.

What’s Managed Where#

ComponentManaged ByRepo
FortiGate interfaces, zonesTerraformterraform-heezy/environments/shared/heezy/
FortiGate firewall policiesTerraformterraform-heezy/environments/*/heezy/firewall.tf
FortiGate DHCPTerraformterraform-heezy/environments/*/heezy/dhcp.tf
FortiGate VIP NATTerraformterraform-heezy/environments/dmz/heezy/firewall-objects.tf
FortiGate SNMPTerraformterraform-heezy/environments/production/heezy/snmp.tf
Cisco 3560 switchManual CLI(on the list)
dnsmasqAnsibleansible-heezy/roles/dnsmasq/
MetalLBMicroK8s addonmicrok8s enable metallb:10.x.x.x-10.x.x.x
Tailscale exit nodek8s manifestheezy-k8s/apps/tailscale/
SWAG / Cloudflare Tunnelk8s manifestheezy-k8s/apps/swag/