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:
| Output | Formula | Example |
|---|---|---|
| Service IP | zone.subnet + "." + service.id | 10.100.20.42 |
| Bridge name | "br" + zone.vlan | br20 |
| Host gateway | zone.subnet + ".254" | 10.100.20.254 |
| Service domain | name + "." + zone + ".lan" | gitea.private.lan |
| Activation port | zone.startPort + service.id | 42042 |
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
- Zone Configuration — define zones, place services, and allocate IDs
- Blueprint Catalog — use pre-configured service templates
- Service Lifecycle — understand socket activation and idle timeout behavior