Container Architecture
All services run in NixOS containers (systemd-nspawn), not Docker or Podman. Each container is a lightweight NixOS system with its own configuration, firewall, and (optionally) network namespace.
Why NixOS Containers?
- Declarative: container configuration is Nix code, same as the host.
- No image registry: containers are built from the host's Nix store.
- Shared kernel: lighter than VMs, no hypervisor overhead.
- NixOS modules inside: services like Traefik, Authelia, and Immich use native NixOS service modules inside the container.
Container Inventory
| Container | Private Network | IP Address | Port | Authelia | Notes |
|---|---|---|---|---|---|
| traefik | No | Host network | 80, 443 | -- | Reverse proxy |
| authelia | Yes | 10.10.10.9 | 9091 | -- | SSO provider |
| adguard | No | 127.0.0.1 | 8080 (web), 53 (DNS) | Yes | Ephemeral |
| immich | Yes | 10.10.10.5 | 2283 | Yes | privateUsers |
| vaultwarden | Yes | 10.10.10.3 | 8000 | No | privateUsers |
| opencloud | Yes | 10.10.10.101 | 9200 | No | cloud storage |
| cloudflared | -- | -- | -- | -- | systemd service, not a container |
Disabled Containers
| Container | Reason |
|---|---|
| nextcloud | Replaced by OpenCloud |
| linkwarden | Not currently in use |
Container Configuration Pattern
Each container module in containers/ follows a consistent two-part structure:
1. Traefik Route Definition
# Defined at the HOST level, injected into the Traefik container's config
containers.traefik.config.services.traefik.dynamicConfigOptions.http = {
services.myservice.loadBalancer.servers = [{
url = "http://${config.containers.myservice.localAddress}:PORT";
}];
routers.myservice = {
rule = "Host(`subdomain.${domain}`)";
service = "myservice";
entrypoints = [ "websecure" ];
middlewares = [ "authelia" ]; # Optional
};
};This pattern keeps all routing configuration co-located with the service it routes to, even though it technically modifies the Traefik container's config. NixOS module merging makes this work transparently.
2. Container Definition
containers.myservice = {
autoStart = true;
privateNetwork = true;
hostAddress = "10.10.10.X";
localAddress = "10.10.10.Y";
config = {
networking.firewall.allowedTCPPorts = [ PORT ];
services.myservice = { ... };
};
};Network Isolation
Private Network
Containers with privateNetwork = true get their own network namespace with a veth pair:
Host (10.10.10.X) <──veth──> Container (10.10.10.Y)Traffic between the container and the internet goes through NAT on the host.
No Private Network
Traefik and AdGuard share the host's network namespace. This is necessary because:
- Traefik needs to bind to ports 80/443 on the host's public IP.
- AdGuard needs to bind to port 53 on the host's public IP for DNS.
User Namespace Isolation
Some containers use privateUsers = "pick" for user namespace mapping:
containers.immich = {
privateUsers = "pick";
# ...
};This maps UIDs/GIDs inside the container to unprivileged ranges on the host, providing an additional layer of isolation. Even if a process escapes the container, it runs as an unprivileged user on the host.
Ephemeral Containers
The AdGuard container is marked as ephemeral:
containers.adguard = {
ephemeral = true;
# ...
};This means the container's root filesystem is discarded on restart. Since AdGuard's configuration is entirely declarative (mutableSettings = false), no persistent state is needed.
Bind Mounts
Containers that need access to agenix secrets use bind mounts:
containers.traefik = {
bindMounts."/persist/etc/ssh/ssh_host_ed25519_key".isReadOnly = true;
# ...
};This allows the container to decrypt age-encrypted secrets using the host's SSH key.
Adding a New Container
- Create
server/containers/myservice.nix:
{ domain, config, ... }: {
# Traefik routing
containers.traefik.config.services.traefik.dynamicConfigOptions.http = {
services.myservice.loadBalancer.servers = [{
url = "http://${config.containers.myservice.localAddress}:PORT";
}];
routers.myservice = {
rule = "Host(`myservice.${domain}`)";
service = "myservice";
entrypoints = [ "websecure" ];
middlewares = [ "authelia" ];
};
};
# Container
containers.myservice = {
autoStart = true;
privateNetwork = true;
hostAddress = "10.10.10.X"; # Pick unused pair
localAddress = "10.10.10.Y";
config = {
networking = {
enableIPv6 = false;
firewall.allowedTCPPorts = [ PORT ];
};
services.myservice = {
enable = true;
# ...
};
};
};
}- Add the import to
server/flake.nix:
modules = [
# ...
./containers/myservice.nix
];Add DNS rewrite in AdGuard (if using wildcard
*.nemnix.site, this is automatic).Rebuild:
sudo nixos-rebuild switch --flake ~/nixos-homelab