Architecture

Nixda Stack is a set of NixOS modules for declarative service hosting on bare metal. A single host runs multiple isolated services, each assigned a static IP on a dedicated VLAN, reachable by name through a TLS-terminating reverse proxy, and discoverable via a local DNS zone. The entire topology — bridges, addresses, DNS records, proxy virtual hosts, and socket activation units — is derived from a single structured option: nixda.zones.


Module Hierarchy

The module tree is organized into four groups, each with a distinct responsibility.

modules/stack/ implements shared infrastructure. It has three sub-modules: networking.nix creates VLAN interfaces and bridges; dns.nix configures Unbound for the .lan zone; proxy.nix enables Caddy with its global options and firewall rules. All three read zone data from config.nixda.zones but declare no services themselves. The shared derivation logic lives in stack/lib.nix, which is not a NixOS module — it is a plain attribute set of functions imported by both stack and service modules.

modules/zones/ owns the schema. It declares the nixda.zones option as an attrsOf submodule, where each zone holds a VLAN ID, a subnet prefix, a port allocation baseline, and an attrsOf of service definitions. Every other module is a consumer of this data; none write to it. The module also asserts that service IDs are unique within each zone.

modules/catalog/ provides optional reusable templates. It declares nixda.catalog as an attrsOf blueprintType, where each blueprint is a pre-filled service attribute set. Blueprints carry runtime defaults — OCI image, NixOS configuration module, ports, idle timeout — but no identity: they have no id field and no zone placement. Consumers merge a blueprint into a zone service definition with lib.recursiveUpdate.

modules/service/ translates zone definitions into running workloads. It imports five implementation modules — native-service.nix, oci-service.nix, vm-service.nix, system-service.nix, and proxy-service.nix — each of which reads config.nixda.zones, filters for its runtime type, and emits the corresponding NixOS configuration. The proxy-service module is the most cross-cutting: it generates Caddy virtual hosts, systemd socket units, and systemd-socket-proxyd service units for every service that declares at least one port, regardless of runtime type.

The composition is one-directional. nixda.zones is the single write point. Everything else reads from it. There are no cross-module option dependencies except through nixda.zones and the functions in stack/lib.nix.


Zone-Based Networking

Each zone maps to an isolated L2 broadcast domain implemented with a VLAN and a Linux bridge.

The host has one physical trunk interface (configured via nixda.stack.networking.interface, for example eno3). The networking module creates a VLAN interface for each zone, tagged with that zone’s VLAN ID, on top of the trunk. A bridge named br{vlan_id} — for example br10, br20, br30 — is created per zone and the VLAN interface is enslaved to it. The host acquires a management address of {subnet}.254/24 on each bridge. This address is the host’s entry point into each zone: containers use it as their default gateway and DNS resolver.

The L3 gateway (.1) lives on the upstream router, not on the host. The host enables IPv4 forwarding (net.ipv4.ip_forward = 1) to allow bridge traffic to flow, but performs no NAT. The router is responsible for inter-zone routing and internet access.

Service IP addresses are statically derived: {zone.subnet}.{service.id}. For a zone with subnet 10.100.20 and a service with id = 42, the resulting IP is 10.100.20.42. The service ID is an integer between 1 and 254, enforced by the type system, and must be unique within its zone (enforced by an assertion in zones/default.nix).

Native containers (systemd-nspawn) attach directly to the zone bridge via hostBridge. They receive a static address via localAddress and route through .254.

OCI containers (Podman) connect via a per-zone Netavark bridge network named zone-{zoneName}. The module creates this network once per zone, attaches it to the existing bridge interface, and assigns static IPs using --ip. No ports are published with -p; the static IP is used directly. A startup ordering constraint ensures the bridge exists before the Podman network is created and the IP assignment runs afterward, because Netavark rejects subnets already present on host interfaces.

System services run directly on the host and are reached by the proxy via 127.0.0.1. They have no zone network attachment.


DNS

Unbound handles exclusively the .lan local zone. It does not serve as a recursive resolver for general internet queries. The upstream router runs AdGuard Home, which is configured to forward .lan queries to the host’s Unbound instance (conditional forwarding). All other queries are forwarded by AdGuard to Quad9.

The DNS flow for a .lan name is:

Example topology — the IPs below (192.168.8.1) are operator-specific values, not module defaults. Replace with your actual router address.

Client → AdGuard Home (router, 192.168.8.1)
           └── .lan queries → Unbound (host)
               └── returns A record pointing to reverse proxy IP

For all other names:

Client → AdGuard Home (router) → Quad9

The host itself uses the router (192.168.8.1) as its nameserver, following the same path as all other clients.

Unbound is configured with DNSSEC validation enabled (module-config = "validator iterator"), query name minimization, and identity/version hiding. It listens on all interfaces and permits queries from the 10.100.0.0/16 zone range, the loopback, and the router subnet.

DNS records for services are generated by iterating over all zones and all services within each zone. Every service that reaches this iteration produces an A record of the form:

\{name\}.\{zone\}.lan A \{reverseProxyIp\}

The record points to the reverse proxy IP (nixda.stack.dns.reverseProxyIp), not to the service’s container IP. Caddy routes the request to the correct backend using the hostname. This means all service names resolve to the same address; the routing decision is made at the HTTP layer, not at the DNS layer.


Reverse Proxy

Caddy terminates TLS for all services. Every service that declares at least one port receives a virtual host in the Caddy configuration.

Domain-based virtual hosts use the form https://{name}.{zone}.lan. Each virtual host block contains two directives:

tls internal
reverse_proxy 127.0.0.1:\{activationPort\}

tls internal instructs Caddy to issue a certificate from its internal CA rather than using ACME. This is appropriate for .lan addresses that are unreachable from the public internet. The reverse proxy target is always 127.0.0.1 at the activation port — Caddy never connects directly to a container IP.

An optional gateway virtual host is generated when nixda.stack.proxy.gatewayDomain is set. This exposes all services under a single domain (for example, a Tailscale domain) via subpath routing:

https://\{gatewayDomain\}/\{name\}*

The stripPrefix option on the service controls whether Caddy uses handle_path (which strips the /{name} prefix before forwarding) or handle (which preserves it). The default is true.

Caddy does not connect to container IPs. The proxy target is always a localhost port. The mapping between the localhost port and the actual backend is handled by systemd-socket-proxyd.


Socket Activation

Between Caddy and each service backend sits a pair of systemd units: a socket unit named proxy-{name}.socket and a service unit named proxy-{name}.service. This indirection serves two purposes: it decouples Caddy from direct knowledge of backend addresses, and it enables on-demand startup for services with a non-null idleTimeout.

The socket unit listens on 127.0.0.1:{activationPort}. When a connection arrives, systemd activates proxy-{name}.service, which runs systemd-socket-proxyd to forward traffic to the actual backend at {backendIp}:{backendPort}.

For system services, backendIp is 127.0.0.1. For all other runtimes it is the derived service IP ({zone.subnet}.{service.id}).

For services with a non-null idleTimeout, systemd-socket-proxyd is invoked with --exit-idle-time={seconds}. When the idle timer expires, the proxy service stops. Because the backend unit is declared in PropagatesStopTo, the backend container or VM also stops. On the next request, systemd re-activates the proxy via the socket, which re-activates the backend. Services with idleTimeout = null are always-on; their containers have autoStart = true and no stop propagation.

BindsTo on the proxy service creates a hard dependency: if the backend unit stops unexpectedly, the proxy service stops as well, preventing requests from hanging against a dead backend.

The restartTriggers on the socket unit include the activation port, the backend IP, the backend port, and the idle timeout. A change to any of these values causes the socket to restart on the next nixos-rebuild switch.


IP and Port Derivation

All network addresses and ports in the stack are derived from zone and service declarations. No values are manually assigned except zone.subnet, zone.vlan, service.id, and proxy.startPort.

The derivation functions are collected in modules/stack/lib.nix:

OutputFormulaExample
Service IPzone.subnet + "." + service.id10.100.20.42
Bridge name"br" + zone.vlanbr20
Host gatewayzone.subnet + ".254"10.100.20.254
Service domainname + "." + zone + ".lan"gitea.private.lan
Activation portzone.startPort + service.id42042

The zone.startPort defaults to proxy.startPort + zone.vlan * 100. With proxy.startPort = 40000 and a zone VLAN of 20, zone.startPort is 42000. A service with id = 42 gets activation port 42042.

This scheme guarantees that activation ports are unique across zones as long as VLANs are unique and service IDs are unique within each zone. The module enforces zone-level ID uniqueness via an assertion. VLAN uniqueness is not enforced at evaluation time — it is the operator’s responsibility to assign distinct VLAN IDs.

Port numbers are bound to 127.0.0.1 exclusively. They are internal to the host and not reachable from the network.


Module Dependency Graph

nixda.zones (schema + data)

    ├── modules/stack/networking.nix  → networking.vlans, networking.bridges,
    │                                    networking.interfaces, boot.kernel.sysctl

    ├── modules/stack/dns.nix         → services.unbound, networking.nameservers,
    │                                    networking.firewall

    ├── modules/stack/proxy.nix       → services.caddy (global), networking.firewall

    └── modules/service/
            ├── native-service.nix    → containers.*
            ├── oci-service.nix       → virtualisation.oci-containers.*, systemd.services
            ├── vm-service.nix        → (stub)
            ├── system-service.nix    → networking.firewall
            └── proxy-service.nix     → services.caddy.virtualHosts,
                                         systemd.sockets, systemd.services,
                                         virtualisation.oci-containers.*.autoStart,
                                         containers.*.autoStart

nixda.catalog (blueprints — read-only data, no NixOS config output)

Design Constraints

No host-specific data in modules. Zone subnets, VLAN IDs, and the trunk interface name are configuration inputs, not module defaults. Modules contain no hardcoded IP ranges or interface names.

All options have explicit types. The NixOS type system enforces integer bounds on id (1–254) and vlan (1–4094), port ranges, and the nullable/non-nullable distinction on idleTimeout and gatewayDomain.

Service IDs are the only allocation primitive. The entire IP and port space is a function of three integers: zone.vlan, zone.subnet (a string), and service.id. No allocation registries, no dynamic assignment.

The VM runtime is a stub. modules/service/vm-service.nix contains no implementation. The runtime.vm option exists in the schema and is type-checked, but no NixOS configuration is emitted for it. MicroVM support is deferred.


Next Steps