Skip to content

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

ContainerPrivate NetworkIP AddressPortAutheliaNotes
traefikNoHost network80, 443--Reverse proxy
autheliaYes10.10.10.99091--SSO provider
adguardNo127.0.0.18080 (web), 53 (DNS)YesEphemeral
immichYes10.10.10.52283YesprivateUsers
vaultwardenYes10.10.10.38000NoprivateUsers
opencloudYes10.10.10.1019200Nocloud storage
cloudflared--------systemd service, not a container

Disabled Containers

ContainerReason
nextcloudReplaced by OpenCloud
linkwardenNot currently in use

Container Configuration Pattern

Each container module in containers/ follows a consistent two-part structure:

1. Traefik Route Definition

nix
# 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

nix
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:

nix
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:

nix
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:

nix
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

  1. Create server/containers/myservice.nix:
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;
        # ...
      };
    };
  };
}
  1. Add the import to server/flake.nix:
nix
modules = [
  # ...
  ./containers/myservice.nix
];
  1. Add DNS rewrite in AdGuard (if using wildcard *.nemnix.site, this is automatic).

  2. Rebuild:

bash
sudo nixos-rebuild switch --flake ~/nixos-homelab