Celilo's zones describe what's allowed to talk to what. The firewall enforces those rules — but the firewall can only enforce them if traffic between zones is forced to go through it. Without that, zones are honor-system: a container in app that knows the gateway IP for secure can ARP for the database directly, the switch happily floods the frame, and your "isolation" is a sticker on the box.

802.1q VLAN tagging is what makes zones real. This page walks through how to wire your home lab so Celilo's policy actually has teeth.

Why VLANs

Most home networks live on a single broadcast domain — one subnet, one switch, every device's ARP requests reach every other device. That's fine for a flat network, but it makes every "zone" the same zone. Anything talking to anything, with the firewall as a polite suggestion.

802.1q tags every Ethernet frame with the VLAN it belongs to. The switch refuses to forward frames between tags, so each zone is its own broadcast domain. A host on the app VLAN can send packets toward an IP on the secure VLAN, but those packets only get there if a router decides to route them — and the only router with interfaces in both VLANs is your firewall machine. That's where Celilo's policy lives.

The practical upshot:

  • Compromising a workload in app doesn't get you L2 access to secure; you have to defeat the firewall, not just guess an IP.
  • You can put non-Celilo devices on a zone (a NAS in secure, an IoT camera in internal) and they get the same policy enforcement as Celilo-managed containers.
  • Adding a zone is a switch-config change plus a firewall-config change, not re-cabling.

Reference topology

Here's how the zones, hosts, and capabilities fit together at the logical level:

Celilo zone topology: external cloud connects through a perimeter firewall to internal (VLAN 192), where the celilo orchestrator lives; the iptables firewall connects internal to dmz (VLAN 10, hosting caddy with the public_web capability), app (VLAN 20, hosting authentik with the idp capability), and secure (VLAN 30, hosting postgres).

The five zones, with the example modules placed on each:

  • external — the public internet. Untagged, lives upstream of the firewall. The cloud at the top of the diagram.
  • internal — VLAN 192 by default. Where you, the operator, and (today) the Celilo orchestrator live.
  • dmz — VLAN 10 by default. Public-facing workloads. Caddy lives here, terminating HTTPS.
  • app — VLAN 20 by default. Internal services. Authentik (the identity provider) lives here, as do most application servers.
  • secure — VLAN 30 by default. Sensitive data. Postgres lives here.

Two firewalls do different jobs:

  • The perimeter firewall (labeled perimeter-fw in the diagram) is the only thing facing the public internet. It does NAT for outbound traffic, DNAT for inbound traffic on ports 80/443, and provides the firewall capability that public-facing modules (Caddy, anything wanting exposeService) call into. In your setup it can be a port-forward on your ISP router, a Cloudflare tunnel, or a VPS — see Perimeter ingress.
  • iptables sits between zones. It's the inter-zone gateway: every packet from app to secure, or from dmz to app, passes through it and gets policy applied. Also provides the firewall capability — modules don't have to know which firewall is which; they ask for firewall and Celilo wires them to whichever provider serves their zone.

The capability labels on the diagram (firewall, idp, public_web) are how modules find each other. Caddy declares it provides public_web; your web app's manifest declares it requires public_web; Celilo wires the call without anyone hardcoding hostnames or IPs. Same for idp (Authentik) and firewall (whichever module provides the perimeter or inter-zone firewall in your setup).

Physical layout

That's the logical view. Here's what the actual wiring looks like in a typical deployment:

Physical layout: ISP router with wifi hosts laptops, TVs, phones, IoT devices on a flat untrusted network. The firewall machine sits behind the ISP router with a single trunk port to a managed switch. The switch fans out: an access port to the management box and trunk ports to each of several Proxmox hosts.

Five kinds of physical things:

  • ISP router with wifi. Stays as you have it. This is where untrusted endpoints (laptops, phones, smart TVs, IoT) live. It's not part of the Celilo network; it's the public side of the firewall.
  • Firewall machine. A small Linux box (Raspberry Pi, mini-PC, repurposed thin client) with two interfaces: one to the ISP router (untrusted, public side), one to the managed switch (trusted, trunk port carrying every VLAN). Runs the iptables module to provide inter-zone firewalling. If you choose to also run a perimeter firewall locally (option A in Perimeter ingress), the same box can do both jobs — or you can split them onto separate hosts.
  • Managed switch. Any switch that supports 802.1q. Trunk port to the firewall, trunk port to each Proxmox host, access port to anything else (mgmt box, NAS, wired endpoints).
  • Proxmox hosts. One or more. Each connects to the switch over a single trunk port; vmbr0 is configured VLAN-aware, and containers attach with a tag=N matching their zone.
  • Mgmt box. Whatever machine you run celilo from. Usually on an access port carrying the internal VLAN untagged.

This is the layout above. Three things to configure:

Pick your numbers

Celilo ships with these defaults, which match the diagrams above:

ZoneVLANDefault subnetNotes
dmz1010.0.10.0/24Public-facing workloads (Caddy)
app2010.0.20.0/24Internal services (Authentik, internal APIs)
secure3010.0.30.0/24Databases, secret stores
internal192192.168.1.0/24Operator LAN, mgmt
externalOff-LAN; lives on the public side of the firewall

If you have to use different VLAN numbers (existing network, conflicts with a vendor default), you can override them later — see Telling Celilo about it.

Switch configuration

Vendor specifics vary; the shape is the same:

  • Create VLANs 10, 20, 30, 192 in the switch's admin UI.
  • The port to the firewall machine: tagged trunk — all four VLANs tagged. (Some vendors call this "general" or "hybrid"; the point is the firewall sees the tag.)
  • Each port to a Proxmox host: tagged trunk — same four VLANs tagged.
  • Port to the mgmt box: access port, untagged on VLAN 192.
  • Ports for wired endpoints (printers, NAS, anything not running zone-aware software): access port, untagged on whichever VLAN that device should live on.

For step-by-step admin-UI walkthroughs, the vendor's docs are the source of truth — UniFi, MikroTik, TP-Link/Omada, Cisco, and Netgear all have working guides. The Celilo side doesn't care which switch you use, only that the tags match the numbers above.

Hardware suggestion: the Sodola 9-port 2.5G smart switch (8× 2.5G + 1× 10G SFP+, fanless, ~$100) is a known-working option that's been used in real Celilo deployments. If that link rots, search for "Sodola 9-port 2.5G smart managed switch" — same shape, plenty of stock.

Firewall machine

Celilo's iptables module configures the firewall rules for you. What you have to set up before running Celilo is the VLAN sub-interfaces themselves, so the kernel has somewhere to apply rules. On Debian/Ubuntu, /etc/network/interfaces looks like:

auto eth0
iface eth0 inet manual
    # Trunk to the switch — no IP on the trunk itself

auto eth0.10
iface eth0.10 inet static
    address 10.0.10.1/24

auto eth0.20
iface eth0.20 inet static
    address 10.0.20.1/24

auto eth0.30
iface eth0.30 inet static
    address 10.0.30.1/24

auto eth0.192
iface eth0.192 inet static
    address 192.168.1.1/24

# eth1 is the public side facing the ISP router
auto eth1
iface eth1 inet dhcp

Each eth0.N sub-interface is the gateway for its zone — that's the IP Celilo's auto-derived gateway field will produce when a module asks for one. Restart networking, confirm ip -br addr shows the sub-interfaces up, then celilo machine add the firewall and run module deploy iptables to lay down the rules.

Proxmox host

Each Proxmox host needs a single VLAN-aware bridge. /etc/network/interfaces:

auto vmbr0
iface vmbr0 inet static
    address 192.168.1.10/24
    gateway 192.168.1.1
    bridge-ports eno1
    bridge-stp off
    bridge-fd 0
    bridge-vlan-aware yes
    bridge-vids 2-4094

Two important bits: bridge-vlan-aware yes tells the bridge to respect 802.1q tags, and bridge-vids 2-4094 permits the full tag range on the bridge (Proxmox restricts it by default). The Proxmox host itself is reachable on the untagged segment of the bridge — most setups put that on internal so you can SSH in from your mgmt box.

Once the bridge is up, container creation is hands-off. Celilo's Terraform templates emit a network block with tag = $self:vlan, and Celilo auto-derives vlan from the zone the module requested — see Telling Celilo about it.

Minimal: firewall ↔ Proxmox direct, no switch

If you don't have a managed switch (yet), you can still get zone enforcement. The trick is to skip the switch entirely and run a trunk straight from the firewall to the Proxmox host. Two viable shapes:

Shape A: single cable, sub-interfaces on both ends

One Ethernet cable from the firewall's "trusted" NIC to a Proxmox NIC. Both ends speak 802.1q. Configuration is identical to the recommended setup above — eth0.10/20/30/192 on the firewall, VLAN-aware vmbr0 on Proxmox — you just don't have a switch in between.

What you give up: there's nowhere to plug a non-Proxmox device into a specific zone. Want a NAS in secure? It has to be a Proxmox container, or you have to add a switch.

Shape B: multi-NIC firewall, untagged per-zone subnets

If your firewall machine has several NICs, you can give each zone its own untagged NIC and crossover-cable each one to a Proxmox NIC. No 802.1q anywhere. Simpler to debug, but it requires Proxmox bridges per zone (vmbr10, vmbr20, etc.) and the same NIC-port-shortage problem as per-zone NICs below.

Status: Shape A is what most no-switch home labs end up with; Celilo doesn't need anything different from the recommended setup. Shape B works in principle but is less tested. If you're standing one of these up, let us know how it goes — the doc page wants a real wiring photo.

Per-zone NICs (no VLANs)

Each zone gets its own physical NIC on the firewall and on each Proxmox host. Cleanest L2 isolation, no tagging to misconfigure — but you run out of NIC ports fast. Five zones means five NICs per Proxmox host, and most home-lab boxes ship with one or two onboard.

Worth knowing exists; in practice 802.1q is what most home labs use because it makes "add another zone" a config change rather than a hardware change. Vendor docs (Proxmox networking, Linux bridge) cover the per-NIC setup.

Perimeter ingress

The diagrams show a Linux box at the perimeter doing public NAT and inbound DNAT for ports 80/443. That's one option, but it's not the only one. Three patterns work today; pick whichever fits your ISP situation and tolerance for outsourced trust:

A. Port-forwarding on your ISP router

The simplest setup. Configure port-forward rules in your ISP router's admin UI to send TCP 80/443 to your DMZ host (typically the Caddy container — its IP is what module deploy caddy prints on success). No extra hardware, no public IP beyond what the ISP gives you. Most consumer routers support this.

Recommended starting point. A planned isp-router Celilo module will give you a checklist of port-forward rules to enter manually and confirm when done; until then, do it by hand once and forget about it.

B. Cloudflare tunnel

No port-forward, no public IP needed. Cloudflare Tunnel runs a daemon inside your network that dials out to Cloudflare's edge; Cloudflare proxies inbound HTTPS through that tunnel back to your DMZ. Works behind CG-NAT, works on residential connections that block inbound traffic.

Trade-offs: HTTPS terminates at Cloudflare (you trust them with TLS for the domains you tunnel), and you're tied to their pricing/policy. Not yet wrapped in a Celilo module; configure it manually, then point the tunnel's local endpoint at Caddy.

C. VPS + WireGuard

Rent a small public-IP VPS (Digital Ocean, Hetzner, Vultr — $4–$6/month), run a WireGuard tunnel between it and a host on your dmz, and forward inbound 80/443 on the VPS through the tunnel into your network. The VPS becomes your "public IP" for ingress purposes; TLS can terminate either at the VPS or at home, your call.

Most flexible, most setup. Useful if you're behind CG-NAT, want a static public IP, or want geographically-distant ingress. Not yet wrapped as a Celilo module either; manual today.

Today: none of A/B/C is automated by Celilo end-to-end. You configure them manually, then Celilo's firewall capability operates inside the perimeter (DMZ container ↔ app ↔ secure). The perimeter-fw box in the topology diagram is a stand-in for whichever of these you choose.

Wireless

Wifi isn't part of the Celilo zone model. Connect to your ISP router's wifi the way you always have — that's where laptops, phones, and IoT devices live, and it's how you reach the orchestrator on internal. Celilo's protected zones (dmz, app, secure) are wired-only by design; there's no reason to want clients on them over wifi.

Telling Celilo about it

Two pieces of state Celilo cares about: subnets and VLAN tags.

Subnets

celilo system init prompts for each zone's subnet during initial setup. Defaults match the table above. You can also set them non-interactively:

celilo system config set network.dmz.subnet 10.0.10.0/24
celilo system config set network.app.subnet 10.0.20.0/24
celilo system config set network.secure.subnet 10.0.30.0/24
celilo system config set network.internal.subnet 192.168.1.0/24

Gateway IPs aren't prompted for. When you celilo machine add the firewall machine, Celilo SSHes in and inspects its interfaces; the IP it finds on each VLAN sub-interface (eth0.10, eth0.20, and so on) becomes the canonical gateway for that zone.

VLAN tags

Celilo ships with VLAN tag defaults that match the diagrams above (dmz=10, app=20, secure=30, internal=192). If your switch uses different numbers — for example, a vendor default that conflicts — override them:

celilo system config set network.dmz.vlan 110
celilo system config set network.app.vlan 120
celilo system config set network.secure.vlan 130
celilo system config set network.internal.vlan 1

Whatever numbers you choose here have to match the tags on your switch and the sub-interface names on the firewall (eth0.110 instead of eth0.10, etc.). They're the canonical record; modules pick up these values automatically.

What modules see

When a module requests an LXC container or a machine in a zone (e.g. requires.machine.zone: app in its manifest), Celilo auto-injects the matching vlan, gateway, subnet, and bridge values into that module's $self: namespace. The module's Terraform template then drops them straight into the Proxmox container spec:

resource "proxmox_lxc" "my_app" {
  network {
    name   = "eth0"
    bridge = "$self:bridge"     # → vmbr0
    tag    = $self:vlan         # → 20 (for the 'app' zone)
    ip     = "$self:target_ip"
    gw     = "$self:gateway"    # → 10.0.20.1
  }
}

You don't set per-module VLAN tags by hand. Declaring the zone is enough. See apps/celilo/src/variables/context.ts for the auto-derivation logic if you want to dig in.

Known limitation: celilo system init prompts for subnets but not VLAN tags. The defaults are usually right, but if you need custom numbers you have to set them with system config set afterwards. Promoting VLAN tag prompts into the interactive init flow is a likely improvement.

Once your network is wired and Celilo knows the numbers, you're done with this page. Head back to step 2 of the main walkthrough to add your Proxmox service.