Nix Expressions

Nix Expressions & Design Patterns

A deep dive into the custom Nix functions and architectural patterns that make this repository highly modular and declarative.

🛠️ Core Library Functions (lib/)

Our custom library simplifies the standard NixOS module system, making it more concise and easier to manage.

1. Unified Option Definition (mkOpt, mkBoolOpt)

Instead of long mkOption blocks, we use:

  • mkOpt types.str "default": Defines a string option with a default.
  • mkBoolOpt true: Defines a boolean option.
  • mkOpt' types.lines "" "desc": Defines an option with a custom description.

2. Auto-loading Pipeline (mapModules)

We use recursive folder scanning to import Nix files automatically. This eliminates the need for manual imports = [ ./file.nix ... ] lists.

  • mapModules ./dir import: Imports all .nix files in a directory.
  • mapModulesRec: Scans subdirectories as well.

🏗️ Functional Wrappers & Pipelines

To improve software organization and security, we implement custom “pipeline” functions.

The Jailing Wrapper (wrapFakeHome & mkWrapper)

Located in lib/pkgs.nix, these functions create a secure execution environment for apps that refuse to follow XDG standards by redirecting $HOME to a “fake” directory (usually ~/.local/user).

1. wrapFakeHome pkg binName

Creates a minimal, reliable wrapper for a straightforward application, targeting its exact binary.

  • Best for: Simple GUI apps (Zoom, QQ) or CLI tools.
  • Implementation: It is a thin helper around mkWrapper.

2. mkWrapper pkg postBuild

The raw wrapping primitive used when complex logic is required.

  • Best for: Complex GUI applications (Steam, JetBrains IDEs, VSCode) that need multiple binaries wrapped, specific --user-data-dir flags added, or icon paths patched.
  • Why: By using explicit wrapProgram calls in the module itself (the “clean-room” approach), we avoid brittle .desktop file sed-patching and Nix store permission errors that plagued older symlinkJoin abstractions.

Desktop Entries (mkLauncherEntry & mkFakeHomeEntry)

mkLauncherEntry

Generates an imperatively created .desktop application entry. Prefixes the name with launcher..

Signature: title: { prefix ?, name, description ?, icon, exec, categories ? [] } -> derivation

mkFakeHomeEntry

Generates an isolated .desktop application entry for an app that needs to run in the Fake Home but doesn’t have a native Nix package (or requires specific launch flags like --private-window).

Signature: title: { name, description ?, icon, exec, categories ? [] } -> derivation

Key Behavior for XDG MIME Bindings: This function wraps mkLauncherEntry and creates a .desktop file named launcher.<name>.desktop. It also automatically generates a shell script wrapper for the Exec line so that $HOME is correctly set without complex escaping. If you need to set this app as a default handler in modules.xdg.mime.defaultApplications, you must use the launcher. prefix.

# Creating the isolated entry
(mkFakeHomeEntry "Firefox (Private)" {
  name = "firefox-private";
  exec = "firefox --private-window %U";
  icon = "firefox";
})

# Binding it in XDG
modules.xdg.mime.defaultApplications = {
  "x-scheme-handler/http" = ["launcher.firefox-private.desktop"];
};

The Module Bridge (mkAliasDefinitions)

We use mkAliasDefinitions to bridge related option namespaces, keeping module config concise.

home.*home-manager.users.<name>.* (home.nix): home.file, home.configFile, home.dataFile are aliased to their long-form Home Manager equivalents.

  • Benefit: write home.configFile."app/config".source = ... in any NixOS module.

config.user.*users.users.<name>.* (default.nix): All config.user attributes are forwarded to users.users.<name> via mkAliasDefinitions. Bridge-compatible fields (name, description, home, uid, extraGroups, packages, openssh) map directly to NixOS user options.

  • Benefit: any module can write user.packages = [...] or user.openssh.* and have it automatically reach the NixOS user submodule at build time.

config.identity.* (default.nix): User profile fields that do NOT exist on users.users.<name> (email, fullName, github, website, vaultwarden, signing, ssh keys) live under a separate options.identity namespace with freeformType = attrs. This keeps them out of the mkAliasDefinitions forwarding path while remaining accessible to all modules.

Deploying agenix Secrets: environment.etc vs home.configFile

Agenix decrypts secrets to runtime paths like /run/agenix/<name>. These paths do not exist during Nix evaluation, so they cannot be used as source in home.configFile or home.file — Nix pure evaluation forbids access to absolute paths outside the store:

# BROKEN — fails pure eval with "access to absolute path is forbidden"
home.configFile."vdirsyncer/config".source = config.age.secrets."vdirsyncer_gmail.conf".path;

The fix follows the pattern from modules/profiles/hardware/yubikey.nix: deploy the secret via environment.etc (which creates a symlink in /etc/), then point the application at /etc/agenix/<name>:

# Correct — environment.etc creates a symlink at runtime
age.secrets."vdirsyncer_gmail.conf" = { ... };
environment.etc."agenix/vdirsyncer_gmail.conf" = {
  source = config.age.secrets."vdirsyncer_gmail.conf".path;
  mode = "0400";
  user = config.user.name;
  group = "users";
};
services.vdirsyncer.jobs.gmail_sync.configFile = "/etc/agenix/vdirsyncer_gmail.conf";

systemd.tmpfiles.rules is an alternative when the target must be inside $HOME, but environment.etc is preferred for system-level config files.


🧬 Architectural Patterns

The genNginxVhost / genNginxVhostBase Helpers (lib/nginx.nix)

Two-tier vhost generator eliminates boilerplate across all service modules.

genNginxVhost — wraps genNginxVhostBase with a default "/" location containing proxyPass + Alt-Svc. For standard reverse-proxy services:

genNginxVhost :: AttrSet -> AttrSet
genNginxVhost = {
  domain, ssl, proxyPass,          # required
  proxyWebsockets ? false,          # optional
  quic ? false, kTLS ? false,       # from global nginx config
  extraConfig ? "",                 # vhost-level extra nginx directives
  locationExtraConfig ? "",         # appended to "/" location extraConfig
  locations ? {},                   # additional locations merged with "/"
  oauth2 ? false, oauth2Groups ? [],                # per-vhost SSO
}: { ... };

genNginxVhostBase — structural boilerplate only. No default location. For PHP-FPM, fcgiwrap, and services that need custom location logic:

genNginxVhostBase :: AttrSet -> AttrSet
genNginxVhostBase = {
  ssl,                              # required — enables forceSSL + useACMEWildcardHost
  quic ? false, kTLS ? false,       # from global nginx config
  extraConfig ? "",                 # vhost-level extra nginx directives (appended after real_ip)
  oauth2 ? false, oauth2Groups ? [],                # per-vhost SSO
  locations ? {},                   # caller controls all locations
}: { ... };

Both generate: forceSSL, useACMEWildcardHost, quic, http3, kTLS, real_ip headers with Tailscale CIDRs, and the oauth2 submodule. genNginxVhost adds a "/" location with proxyPass + proxyWebsockets + Alt-Svc header; genNginxVhostBase passes through the caller’s locations as-is.

Firewall Helpers (lib/firewall.nix)

Standard RFC 1918 + Tailscale source ranges and helper functions. All service modules that open non-public ports should use these instead of copy-pasting iptables rules.

# Trusted internal networks
lanSources = [
  "192.168.0.0/16"  # RFC 1918 class C — home routers
  "10.0.0.0/8"      # RFC 1918 class A
  "172.16.0.0/12"   # RFC 1918 class B — Docker, mid-size
  "100.64.0.0/10"   # Tailscale CGNAT
];

mkFirewallFor — standard ACCEPT-from-LAN firewall block:

networking.firewall = mkFirewallFor {
  tcp = ["8096"];
  udp = ["22000" "21027"];
  # For multiport: tcpMulti = ["47984,47989,48010"];
  comment = "jellyfin";
};

Generates ACCEPT rules for each protocol×port×source combination, tagged with -m comment. Includes extraStopCommands to clean up on firewall reload.

mkRestrictedPort — whitelist-only for ports exposed beyond localhost (e.g. Ollama API). Inserts ACCEPT rules at the top of the nixos-fw chain followed by a terminal DROP:

networking.firewall = mkIf (exposedToLan) (mkRestrictedPort "11434");

Vhost Submodule Extension (NixOS Type Merging)

Two independent modules extend services.nginx.virtualHosts.<name> with custom options. NixOS automatically merges submodule declarations on the same option path — neither module needs to know about the other:

ModuleAdds to each vhostConfig action
modules/services/web/acme.nixuseACMEWildcardHost (bool)Sets useACMEHost = mkForce baseDomain + acmeRoot = null
modules/services/auth/oauth2-proxy.nixoauth2.enable (bool), oauth2.allowedGroups (list)Generates auth_request directives + internal auth/redirect locations

Both declare options.services.nginx.virtualHosts = mkOption { type = types.attrsOf (types.submodule ...); };. When NixOS evaluates the module system, it sees two declarations for the same option path with compatible types and merges the submodule options. Every vhost transparently gets both useACMEWildcardHost AND oauth2.* — service modules just set them:

services.nginx.virtualHosts = mkIf cfg.nginx.enable {
  "${cfg.domain}" = genNginxVhost {
    ...
    oauth2 = cfg.oauth2.enable;        # from oauth2-proxy.nix extension
  };                                   # useACMEWildcardHost set internally
};

This is the same pattern nixpkgs uses — multiple modules contribute to services.nginx.virtualHosts without coordination. The key constraint: both declarations must use the same type. Mixing types.attrsOf (types.submodule ...) with a plain types.attrs would fail at evaluation time.

The “Decoration” Pattern

Used in our theme system, this pattern ensures that visual changes are non-destructive. A decoration (like a CSS theme) is only applied if the application itself is already enabled.

config = mkIf (config.modules.desktop.browsers.firefox.enable) {
  # ... themes apply here ...
}

Declarative Provisioning (ensures)

Used in the PostgreSQL module, this pattern handles the entire database lifecycle (user creation, DB creation, password rotation) at boot time via Systemd post-start hooks. This removes the need for manual SQL initialization scripts.


🧬 Pure Hermetic Evaluation

The flake is fully hermetic — zero --impure flags, zero getEnv calls, zero HEYENV. Build-time paths use toString self (the flake store path), which contains all config files, modules, and themes.

Host / User / Theme Resolution

ValueSource
Host namemapAttrs key from hosts/<name>/networking.hostName
User identityoptions.identity (freeform attrs), set per-user in modules/profiles/user/<name>.nix
Thememodules.theme.active set in host modules.nix
Dotfiles pathtoString self at build time; $DOTFILES_HOME at runtime (set by shell init)

Runtime DOTFILES_HOME

DOTFILES_HOME is set by /etc/zshenv (generated by modules/hey.nix) and points to the flake store path. It is a runtime convenience for Janet CLI tools and shell scripts — not a build-time input. No --impure needed.

For more context, see Toolchain & System Integration.