Networking#

What Is It?#

Networking is everything that lets the parts of your self-hosted solution find and talk to each other — your Proxmox servers, the LXC containers running your apps, your phone browsing the dashboard, and the wider internet reaching an exposed app . None of that works without a router sitting in the middle handing out IP addresses, resolving domain names, and shuttling packets.

PSW supports two router scenarios, picked once at wizard time:

  • OPNsense mode (preferred). OPNsense is a free, open-source firewall + router platform. When it’s there, PSW programs it for DHCP, DNS, and firewall — and you get a real router with an API.
  • ISP-router mode. Your existing ISP/home router stays the gateway. PSW takes over the local-DNS job itself by running AdGuard Home and a recursive Unbound sidecar inside its bootstrap target . No router-side automation — instead, you carve out a static IP range outside the ISP router’s DHCP pool and PSW validates the plan stays inside it.

Either way, the end result is the same from the apps’ perspective: stable IPs for targets , split-horizon DNS for *.yourdomain.ca, and recursive DNS resolution for the rest of the internet — no public DNS resolver in the path.

Why PSW Cares About the Router#

PSW does things for you that a generic router can’t help with:

  • Reserve a static IP every time a new target is created, so your containers always come back at the same address after a reboot
  • Publish an internal DNS record every time you deploy an app, so jellyfin.yourdomain.ca points at your internal Traefik on the LAN (this is what makes split-horizon DNS work)
  • Remove both the moment you remove the app

A consumer router typically has none of that exposed as an API — you’d have to click buttons in a web interface forever. OPNsense is the scriptable answer; ISP-router mode is the “PSW does it itself” answer when OPNsense isn’t on the table.

The Three Jobs#

PSW always needs the same three things from your network — only who provides them changes between modes:

JobWhat it meansOPNsense modeISP-router mode
GatewayEvery container and VM sends internet-bound traffic to the routerOPNsense (firewall + routing)ISP/home router
Local DNSYour devices ask “where is dashboard.yourdomain.ca?” and get back an internal IP, plus everything-else queries resolve recursivelyopnsense_unbound (recursive Unbound on the OPNsense box)adguard_home (AdGuard + recursive Unbound sidecar on the bootstrap target)
DHCPMake sure target IPs aren’t handed out to phones/laptops by the dynamic poolopnsense_kea — squat reservations via the OPNsense APIisp_router_partition — validates target IPs sit outside the ISP DHCP pool, no router writes

In OPNsense mode, all three jobs use the same credentials (the OPNsense API key + secret). In ISP-router mode there are no router-side credentials — PSW only needs admin credentials for the AdGuard instance it deploys onto the bootstrap target .

ACME certificates don’t need the router. Traefik talks directly to your WAN DNS provider (e.g. Cloudflare ) to prove domain ownership and get certificates. The router is only involved for local network things.

Setting It Up — Three Paths#

The Start screen of the Setup Wizard is where you pick the mode. Once you choose, the wizard collects the inputs that mode needs and writes the right backend names into project.yml.

Path A — You already have OPNsense#

Most common if you’re a tinkerer who already runs OPNsense as your home router. You:

  1. Generate an API key pair inside OPNsense (System → Access → Users → API Keys). OPNsense gives you a small text file with key=… and secret=….
  2. Paste the file (or enter the fields individually) into the wizard along with the router’s URL (e.g. https://10.10.0.1).
  3. The wizard has a Test button that hits /api/core/firmware/status with your credentials and reports the OPNsense version back. Green means you’re good; the Start form also re-runs the check on submit before writing anything.

PSW normalises the URL to https://… form, writes the gateway to network.yml → management.gateway, stores the encrypted credentials in secrets/providers.yml (see secrets ), and registers both local-dns and dhcp backends in project.yml in the same submission. Nothing else changes on your router — your existing rules, aliases, and settings stay exactly as they are. PSW only calls the Unbound and Kea APIs during day-to-day operations.

Path B — Install OPNsense first#

If you don’t have an OPNsense yet but want one, PSW installs one for you on a fresh VM on one of your Proxmox nodes before you run the wizard:

  1. Prepare (psw opnsense prepare) — creates a VM on the node, attaches the OPNsense ISO, and writes a pre-filled config.
  2. Manual installer — you open the VM’s console in Proxmox and click through the tiny OPNsense installer (~3 minutes, mostly “press enter”).
  3. Configure (psw opnsense configure) — after OPNsense reboots, PSW injects an API key, turns on Kea DHCP , enables Unbound DNS in recursive mode (forwarding = 0), and registers it as your router.

When PSW is done, you have an OPNsense VM sitting at 10.10.0.1 that owns DHCP and DNS for your whole setup. Your ISP router still provides internet but is now configured as a bridge (a pass-through) — OPNsense does the actual routing. Once it’s up, return to the dashboard and start the wizard — the Start screen will connect to your new OPNsense like any other.

Manual step, not an oversight. The installer wants you to confirm disk selection and keymap interactively. PSW could automate this further, but having the user press enter once is an acceptable trade-off for getting a proper router.

Path C — Keep your ISP router, let PSW run DNS#

Pick this when you don’t want to deal with OPNsense at all. Your existing router stays the gateway and the DHCP server for your phones/laptops/TVs; PSW takes over the local-DNS job by running AdGuard Home + a recursive Unbound sidecar inside its bootstrap target :

  1. Decide a static range. Look at your router’s DHCP settings — note where the dynamic pool starts and ends (e.g. 192.168.1.50–192.168.1.250). Plan all PSW target IPs outside that range (e.g. 192.168.1.10–192.168.1.49).
  2. Run the wizard’s Start step. Select “ISP router” mode. Enter the gateway IP (your router) and the dynamic pool range you just noted. PSW writes the right backend names into project.yml (local_dns: adguard_home, dhcp: isp_router_partition).
  3. Bootstrap. During bootstrap step 5 PSW SSH s to the bootstrap target, drops a Podman Quadlet for AdGuard + one for Unbound, applies the net.ipv4.ip_unprivileged_port_start = 53 sysctl so AdGuard can bind port 53 rootless, and waits for both containers to come up healthy.
  4. One manual step on your router. Open the router’s web UI and set the LAN’s “DNS server” / DHCP option 6 to the bootstrap target’s IP. Now every device on your network uses AdGuard for DNS.

The wizard validates the plan: if any target IP you pick falls inside the ISP DHCP pool, PSW refuses to bootstrap with a clear error pointing at the conflict. Move the IP out of the pool (or shrink the pool on the router) and re-run.

Your Network At a Glance#

Regardless of which path you take, PSW lays out the network the same way. Every value lives in network.yml — the single source of truth every PSW component reads from (the wizard, the OPNsense installer, the deployment engine). The wizard’s Start step seeds these defaults; you can edit network.yml before the Network step to override any of them:

ThingDefaultStored asWhat it is
Subnet10.10.0.0/24management.networkThe whole private network (256 addresses) everything lives on
Gateway / DNS10.10.0.1management.gateway + dns.primaryOPNsense — every other machine points here for routing and DNS lookups
DHCP pool10.10.0.10010.10.0.200management.dhcp_pool.{start,end}The address range Kea manages. Every managed target is planned inside this range; PSW writes a squat reservation so the pool allocator won’t hand the same IP to a phone or laptop. The LXC itself boots with the planned IP statically — no DHCP request is ever made by PSW-managed containers
Static range10.10.0.210.10.0.99implicit (anything outside the pool)Used for Proxmox management hosts and anything else you want to pin manually (e.g. OPNsense itself at .1, a Foundation VM at .10)
Bridgevmbr0OPNsense VM config (servers/<name>.yml)The virtual Ethernet switch inside Proxmox. Every container and VM plugs into it, and OPNsense is on it too — so they’re all on the same LAN

No VLAN separation, no DMZ, no guest network — one flat network, everyone can reach everyone. This is the simplest working design, and for a self-hosted setup behind a firewall it’s fine. See what about other firewalls below for where this is heading.

The network.yml File#

Everything PSW knows about your network lives in a single file: network.yml at the root of your user project . It’s validated by the NetworkConfig Pydantic schema .

# Your router + physical machines
management:
  gateway: 10.10.0.1              # OPNsense
  network: 10.10.0.0/24           # CIDR of the whole LAN
  dhcp_pool:                      # the OPNsense Kea pool — what targets are planned inside
    start: 10.10.0.100
    end:   10.10.0.200
  hosts:
    small:                        # a Proxmox node
      ip: 10.10.0.198
      roles: [proxmox]
      ssh_user: root
    opnsense:                     # only present if PSW installed it
      ip: 10.10.0.1
      roles: [opnsense]

# Where DNS lookups go (normally 10.10.0.1 as above)
dns:
  primary: 10.10.0.1
  secondary: 1.1.1.1

# Containers and bare servers PSW deploys apps onto
targets:
  core:
    type: lxc
    node: small
    ip: 10.10.0.100               # planned static IP — what the LXC boots with
    cores: 8
    memory: 40960
    disk: 200
  my-vps:
    type: bare
    ip: 203.0.113.45              # external, not managed by OPNsense
    ssh_user: ubuntu

Two clean halves: management lists things PSW does not own (your physical servers, your router), and targets lists things PSW does own and will create, modify, or delete during convergence . See infrastructure for everything you can put in each section.

Why is ip allowed to be empty on LXC targets ? It’s a transient state — the wizard’s Infrastructure step asks you to pick an IP for every target before bootstrap or convergence creates the LXC. Once the IP is set, the LXC boots with that exact static address and never changes unless you edit network.yml (which triggers an in-place reconfigure on the next convergence tick). The plan is the truth — PSW does not pick IPs on your behalf at deploy time.

How Things Actually Talk To Each Other#

Three distinct conversations happen on a healthy setup:

1. PSW on your laptop → OPNsense#

Every time PSW creates a target, adds an app, or tears something down, it calls the OPNsense API:

PSW (your laptop)
  ↓  HTTPS (basic auth: api_key + api_secret)
OPNsense (10.10.0.1)
  ├─ /api/kea/dhcpv4/setReservation        ← squat reservation for the planned target IP
  ├─ /api/unbound/settings/upsertHostOverride ← new app gets a DNS record
  └─ /api/unbound/settings/searchHostOverride ← check what's already there

API traffic goes over HTTPS. Self-signed certs on OPNsense are fine — PSW defaults to verify_ssl: false since the router is on your own LAN.

2. Your devices → apps#

Your phone on home WiFi
  → asks 10.10.0.1 (OPNsense Unbound): "where is jellyfin.yourdomain.ca?"
  → Unbound answers: "10.10.0.100" (Traefik's internal IP)
  → phone connects to 10.10.0.100:443 (HTTPS via Traefik)
  → Traefik proxies to the Jellyfin container on the right target

This is split-horizon DNS doing its thing — see that document for the full picture.

3. The internet → apps (only for exposed apps)#

If an app is exposed via Pangolin , internet traffic skips OPNsense entirely: it hits your VPS directly, then crosses a WireGuard tunnel into your bootstrap target . See remote access for the tunnel details.

What About Other Firewalls and Topologies?#

Today: two backends per networking role. Local DNS: opnsense_unbound or adguard_home. DHCP: opnsense_kea or isp_router_partition. WAN DNS: cloudflare (one backend, both modes).

By design, not by accident: PSW models these as pluggable providers behind clean Python protocols . Adding a third backend (e.g. pfSense , a UniFi gateway, Pi-hole , Technitium ) means writing a new provider class that implements the same interface — the bootstrap orchestrator and convergence engine don’t know or care which backend is behind it. The bootstrap step that calls backend.prepare() is identical for every backend: no if mode == ... branching anywhere outside the backend module itself.

On the roadmap: additional first-class provider types for things OPNsense does today but that PSW doesn’t yet automate through a typed interface — notably firewall rules, VLAN management, and more explicit gateway/routing providers.

What won’t change: the shape of network.yml, the split-horizon DNS model, and the recursive-DNS privacy guarantee. Those are part of the philosophy — “solid infrastructure over flexible guesswork.”

Common Questions#

Can I use my existing ISP router as the gateway? Yes — that’s Path C above. PSW takes over the local-DNS job itself with AdGuard Home + a recursive Unbound sidecar; you partition the LAN so target IPs sit outside the ISP router’s DHCP pool.

Do my other home devices (phones, laptops, TVs) have to use the PSW DNS? If you want them to resolve *.yourdomain.ca internally (recommended!), yes — they need to use the PSW DNS server (OPNsense in mode A, AdGuard on the bootstrap target in mode C). Easiest way: set the right IP as the LAN’s “DNS server” in your router’s DHCP settings, so every device gets it automatically.

Why does PSW always use recursive DNS? Privacy. Forwarder mode sends every query your network makes to a public resolver (Cloudflare, Quad9, Google) — that operator sees your full query pattern. Recursive mode walks root → TLD → authoritative servers itself; no third party sees the full picture. PSW enforces this in both modes (OPNsense Unbound forwarding = 0; AdGuard upstreams to the local Unbound sidecar).

What if OPNsense goes down? DNS and DHCP stop working for new requests, and exposing new apps fails. Existing tunnels and already-resolved IPs keep working for a while (DNS caches). Restore OPNsense and everything self-heals on the next convergence tick.

Can I change the subnet from 10.10.0.0/24? Yes — edit network.yml before the Network step: set management.network (CIDR), management.gateway, dns.primary, and management.dhcp_pool.{start,end} to the new range. After OPNsense is deployed, changing the subnet is a bigger operation (renumber everything) and not automated.

Does PSW configure my ISP router? No. PSW never touches devices it doesn’t own. Your job is to make sure OPNsense has a working path to the internet — usually by putting the ISP router in bridge mode or pointing its gateway at OPNsense.

Where are the OPNsense credentials stored? Encrypted in secrets/providers.yml under the opnsense: key (url, api_key, api_secret, verify_ssl). See secrets for how encryption works.

Key Ideas#

  • Two router scenarios, same downstream contract — OPNsense mode (PSW programs gateway + DNS + DHCP) or ISP-router mode (your existing router stays the gateway; PSW runs AdGuard + recursive Unbound on the bootstrap target)
  • Three setup paths to get there — bring your own OPNsense, let PSW install one, or stay on your ISP router
  • Flat LAN, one bridge (vmbr0) — everything PSW manages sits on the same network; simple, reliable, easy to reason about
  • network.yml is the truth — validated by NetworkConfig , split cleanly between management (yours) and targets (PSW’s)
  • Pluggable by design — every networking role is a typed provider ; the OPNsense and AdGuard backends are interchangeable behind the same Python protocol, and adding pfSense / UniFi / Pi-hole is “write a new provider class”
  • Recursive DNS, always — PSW never forwards your queries to a public resolver; both modes resolve root → TLD → authoritative servers themselves
  • Networking is infrastructure, not an app — DNS, DHCP, and the gateway are always there from bootstrap onward, so every other part of the system can rely on them