GitHub - gamedolphin/system: Sandeep's Literate System Configuration

GitHub

45 min read Original article ↗

Introduction

Hello! Welcome to my literate file that describes (and generates) the configuration for all my computers.

For people who are confused, this file has a bunch of source blocks that are then put into individual files using emacs+org’s tangle mode.

Once the files are generated, they are converted into an immutable system configuration using nixos.

This repository consists of configuration for my main machine

  • smallbox – my wfh computer and main laptop
  • homelab – personal homelab server for experiments etc

To generate the actual nix files, you need to open this file in emacs and then execute M-x org-babel-tangle.

Or run the following from the command line

emacs README.org --batch -f org-babel-tangle

Once the nix files are ready, you can deploy using

nixos-rebuild switch --flake .#<machine>

To update the homelab machine

nix run github:serokell/deploy-rs -- .#homelab

Other files in this repo are :-

  • flake.lock so as to keep my versions intact. More on that later.
  • assets/* contains images like the wallpaper that cannot be part of this.
  • secrets/secrets.yaml contains encrypted keys and is edited using sops.

Everything else in this repository is generated. They ideally shouldn’t be edited directly. There’s also a github action that generates and commits these files if they do differ.

Emacs + Org + Tangle

  • Emacs is the text editor that I use. Some people might take offense at me calling it a text editor.
  • Org mode is an inbuilt plugin for emacs that helps with managing org files like this one. Org files are similar to markdown but with superpowers.
  • Tangle is an org mode option that lets us export snippets to other files. In this case, the configuration snippets you see are written to individual files.
    • Anything that appears in <<code-id>> is like a variable that gets filled in later. You will see them in the snippets below where they are filled in by other snippets later in the file.

Nix & Nixos

  • Nix is a bespoke programming language, used mainly to configure environments and dependencies.
  • Nixos is a linux distro where you define your operating system and other things using nix. The expectation is that the output of evaluating the nix files is a “configuration” that can be applied. This way your operating system is always defined by configuration files. You almost never install anything. Make a change to the configuration, reapply and repeat. You need vim? Add it to the config, and rebuild.

    YourNixCode(Input) -> System Configuration

    I use nix flakes which means that the entry point for the nix evaluation is a file called flake.nix which has two parts (among other things)

    {
      inputs: # describes the function input, consisting mainly of package sources
      outputs: # what the function outputs, a nixos configuration in our case
    }
        

    Nix flakes is still behind an experimental flag, but it is considered the standard by most of the community. Flakes allow us to pin the input package versions using a flake.lock file. This prevents unwanted and surprise updates when rebuilding without changing the configuration.

TLDR App List

Window ManagerNiri
Desktop ShellNoctalia
Login Greetertuigreet
Terminal EmulatorAlacritty
ShellZsh
Text EditorEmacs
File ManagerThunar
FontsAporeti
Colors + IconsCatppuccin

Configuration Variables

I have a bunch of constant strings that I would rather put in a file. Thats what user.nix is. The values are imported at the beginning and are available to almost all the functions being called to configure the system.

{
  system = "x86_64-linux";
  username = "nambiar";
  stateVersion = "26.05";
  locale = "sv_SE.UTF-8";
  sshPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeSRZJzhHgam3p9UOuJfdRZSOWPZ991ksZIjqLl7WSo sandeep@wavefunk.io";
}

Flake Inputs

The inputs for my system’s configuration are very simple

  1. nixpkgs - the main nix repository of packages. Its huge and growing. Pinned to the unstable release channel. Sometimes pinned to a specific commit because unstable broke something and the fix hasn’t made it into the release yet.
  2. home-manager - a nix module that helps keep track of user specific dotfiles and configurations as part of my nix config.
  3. emacs-overlay - this has more configuration options and generally a newer emacs available provided by the community.
  4. sops-nix - adds latest sops for secrets management.
  5. catppuccin - nix module that allows everything to be catppuccin themed.
  6. deploy-rs - tool that allows deploying to remote machines (like the homelab)
  7. noctalia - the desktop shell: bar, launcher, notifications, control center, lock screen and wallpapers in one.
  8. disko - manage the disk configuration declaratively.
  9. llm-agents - latest greatest AI tooling versions.
  10. niri - desktop window manager.
  11. noctalia - desktop shell that manages everything else. stays out of the way.
  12. kache - rust specific cache for cross worktree shared cache on the machine.
{
  description = "Sandeep's nixos configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    emacs-overlay = {
      url = "github:nix-community/emacs-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    sops-nix = {
      url = "github:Mic92/sops-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    catppuccin = {
      url = "github:catppuccin/nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    deploy-rs.url = "github:serokell/deploy-rs";

    disko = {
      url = "github:nix-community/disko";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    llm-agents.url = "github:numtide/llm-agents.nix";

    niri = {
      url = "github:sodiboo/niri-flake";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    noctalia.url = "github:noctalia-dev/noctalia/cachix";

    noctalia-greeter = {
      url = "github:noctalia-dev/noctalia-greeter";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    kache.url = "github:kunobi-ninja/kache";
  };

  <<flake-outputs>>
}

Flake Output

Now that the inputs are ready, the outputs define what the system will actually look like. I also define the machines that this configuration specifies early on. Finally, I iterate over the machines list and pull files from /.machines/${name} subdirectory. This allows me to have configuration that has machine specific configuration limited to those files while also keeping a modular reusable base. We also add a devshell that makes editing this repository easier in emacs.

outputs = {
  self,
    nixpkgs,
    home-manager,
    emacs-overlay,
    sops-nix,
    catppuccin,
    deploy-rs,
    disko,
    llm-agents,
    niri,
    noctalia,
    noctalia-greeter,
    kache,
    ...
}@inputs:
  let
    user = import ./user.nix;
    lib = nixpkgs.lib;
    workstations = [
      "smallbox"
    ];

    pkgs = import nixpkgs {
      inherit (user) system;
    };

    deployPkgs = import nixpkgs {
      inherit (user) system;
      overlays = [
        deploy-rs.overlays.default
        (self: super: { deploy-rs = { inherit (pkgs) deploy-rs; lib = super.deploy-rs.lib; }; })
      ];
    };
  in
    {
      nixosConfigurations = builtins.listToAttrs (
        builtins.map (machine: {
          name = machine;
          value = lib.nixosSystem {
            modules = [
              <<flake-emacs-module>>
              <<flake-kache-module>>
              <<flake-config-module>>
              <<flake-home-module>>
              niri.nixosModules.niri                       # niri-flake
              disko.nixosModules.disko                     # disks
              sops-nix.nixosModules.sops                   # sops
              catppuccin.nixosModules.catppuccin           # theme
              kache.nixosModules.default                   # kache
              noctalia-greeter.nixosModules.default        # login
            ];
            specialArgs = {
              hostname = machine;
              inherit user llm-agents;
            };
          };
        }) workstations
      ) // {
        homelab = lib.nixosSystem {
          modules = [
            ./machines/homelab/configuration.nix
            disko.nixosModules.disko
            sops-nix.nixosModules.sops
          ];
          specialArgs = { inherit user; };
        };
      };

      <<homelab-module>>

      devShells.${user.system}.default = pkgs.mkShell {
        buildInputs = with pkgs; [
          nil               # nix lsp server
          nixfmt            # nix formatter
          sops              # used to edit secrets
        ];
      };
    };

Lets look at the individual modules

  1. Emacs + Kache The first is the emacs overlay so that it uses the nix-community emacs overlay from the inputs instead of the nixpkgs one. Overlays are a special nix way to override existing packages within a repository.
    ({ ... }: {
      nixpkgs.overlays = [ emacs-overlay.overlays.default ];
    })
        
    ({ ... }: {
      nixpkgs.overlays = [ kache.overlays.default ];
    })
        
  2. Then the workstation-specific configuration.
    ./machines/${machine}/configuration.nix
        
  3. And finally the home-manager module. This can be initialized and managed on its own but I’d rather use the nixos-rebuild command to build everything instead of managing userland dotfiles separately.
    home-manager.nixosModules.home-manager
    {
      home-manager.useGlobalPkgs = true;
      home-manager.useUserPackages = true;
      home-manager.extraSpecialArgs = {
        inherit user;
      };
      <<flake-home-backup>>
      <<flake-home-config>>
    }
        
    • Home-Manager will not overwrite existing configuration files and that is good in most cases, but when everything is declarative like it is here, I’d rather that home-manager create a .backup and replace the file.
      home-manager.backupFileExtension = "backup";
              
    • Finally I pull in the machine specific home configuration. Along with the overrides from catppuccin.
      home-manager.users.${user.username} = {
        imports =  [
          ./machines/${machine}/home.nix
          catppuccin.homeModules.catppuccin
          noctalia.homeModules.default
        ];
      };
              

Envrc + Direnv

Editing this file will be much nicer if we have the dev environment configured. That is done in the devshells section. But to auto load this dev shell, we need a .envrc file. This tells direnv to load the devshell in the flake. Finally, we also look for a .envrc-private file and try to load that. That contains devshell specific secrets.

use flake

watch_file .envrc.private
if [[ -f .envrc.private ]]; then
  source_env .envrc.private
fi

Machines

The individual machines subdirectory is configured as follows :-

+--machine
|  +--configuration.nix           # has the system configuration.
|  +--home.nix                    # has the user level configuration.
|  +--hardware-configuration.nix  # has the unique hardware configuration.

nixos-rebuild switch --flake .#smallbox looks for the smallbox configuration (in /machines/smallbox) and returns the configuration that is specific to that computer.

  • Note about imports imports = [] in a nix file will pull in the function/object from the list of files provided. This imported object (or function result) is just trivially merged into a common object.

We can take a look at that the common hardware options I have for all my machines.

Other Utils

Updates

To update the computer, I just need to update the flake.lock file to have references to the latest repository. This is done with :-

Editing secrets

The age key needs to exist. Then I can edit the secrets using :-

sops edit secrets/secrets.yaml

Hardware

I’ll let the code comments explain the file here.

{ pkgs, lib, user, config, ...} :
{
  nixpkgs.hostPlatform = lib.mkDefault user.system;            # x86_64-linux
  powerManagement.cpuFreqGovernor = lib.mkDefault "powersave"; # enable power saving on the cpu

  # update cpu microcode with firmware that allows redistribution
  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;

  hardware = {
    # always enable bluetooth
    bluetooth.enable = true;

    # always enable graphics drivers and enable a bunch of layers for it (including vulkan validation)
    graphics = {
      enable = true;
      extraPackages = with pkgs; [
        vulkan-validation-layers # helps catch and debug vulkan crashes
      ];
    };
  };

  hardware.enableAllFirmware = true; # enable all firmware regardless of license
}

Configuration

This section describes the main system configuration for the computers that I have. Nix will look for a default.nix file if you give it a path to a folder to import. And default.nix looks as follows :-

{ pkgs, user, ... } :
{
  imports = [
    ./boot.nix
    ./login.nix
    ./cli.nix
    ./files.nix
    ./locale.nix
    ./nix-settings.nix
    ./networking.nix
    ./niri.nix
    ./services.nix
    ./audio.nix
    ./steam.nix
    ./sops.nix
    ./ai.nix
  ];

  <<config-system-packages>>

  <<config-ai>>

  <<config-user>>

  <<config-programs>>

  <<config-fonts>>

  # enable the catppuccin theme for everything with mocha + blue accents
  catppuccin.enable = true;
  catppuccin.autoEnable = true;
  catppuccin.flavor = "mocha";
  catppuccin.accent = "blue";

  system.stateVersion = user.stateVersion;
}

Whoa. Thats a lot of imports. Lets go through them one by one.

Nix Settings

These are global nix settings that configure the settings for the actual tool.

{ pkgs, user, ... } :
{
  nix.settings = {
    # enable flakes
    experimental-features = ["nix-command" "flakes"];

    # allow the configured primary user to use flake-provided binary caches
    trusted-users = [ user.username ];

    # add a cache that speed up new applications by downloading binaries
    # from the trusted cache instead of compiling from sourcer
    substituters = [
      "https://nix-community.cachix.org"
      "https://noctalia.cachix.org"
    ];
    # trust the cache public key
    trusted-public-keys = [
      "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
      "noctalia.cachix.org-1:pCOR47nnMEo5thcxNDtzWpOxNFQsBRglJzxWPp3dkU4="
    ];
  };

  # allow proprietary software on this machine. I'm not a purist.
  nixpkgs.config.allowUnfree = true;

  # this declares how often old configurations are cleared up.
  # i cleanup anything older than a week, every week.
  nix.gc = {
    automatic = true;
    options = "--delete-older-than 7d";
    dates = "weekly";
  };

  programs = {
    # command line utility that makes applying changes easy and pretty
    nh = {
      enable = true;
      flake = "/home/${user.username}/system";
    };
  };
}

Boot

This file has most of the settings the control how the computer boots up.

{ pkgs, ... } :
{
  boot = {
    initrd = {
      verbose = false;     # its a lot of logs. dont need it, unless we do.
      kernelModules = [ ]; # no kernel modules on boot
    };

    extraModulePackages = [ ];                   # no extra packages on boot either
    kernelPackages = pkgs.linuxPackages_latest;  # latest greatest linux kernel
    kernelParams = [ "silent" ];                 # quiet those logs

    consoleLogLevel = 0;                         # quiten more logs
    plymouth.enable = true;                      # graphical boot animation instead

    supportedFilesystems = [ "ntfs" ];           # should see the ntfs (windows)

    loader = {
      systemd-boot.enable = true;                # systemd-boot
      systemd-boot.configurationLimit = 5;
      efi.canTouchEfiVariables = true;           # allow editing efi to edit the boot loader


      timeout = 5;                               # grub timeout to make a selection
    };
  };
}

Login

{
  programs.noctalia-greeter = {
    enable = true;

    settings = {
      session = {
        default = "Niri";
      };
      user = {
        default = "nambiar";
      };
    };
  };
}

CLI

This is the initial system level configuration for the terminal that I use on this machine. Its just zsh.

{ pkgs, user, ... }:
{
  console.useXkbConfig = true;
  users.users.${user.username}.shell = pkgs.zsh;

  environment.shells = with pkgs; [ zsh ];
  programs.zsh.enable = true;

  environment.pathsToLink = [ "/share/zsh" ];
}

Files

I use Thunar as the file explorer. Also setup a few plugins for Thunar in this config. Along with that, a few other utilities like zip and enabling services to automount usb drives.

{ pkgs, user, config, ... } :
{
  environment.systemPackages = with pkgs; [
    zip
    unzip
    p7zip
    usbutils
    udiskie
    file-roller
    uv
  ];

  programs.thunar = {
    enable = true;
    plugins = with pkgs; [
      thunar-archive-plugin
      thunar-media-tags-plugin
      thunar-volman
    ];
  };

  programs.xfconf.enable = true; # to save thunar settings

  services = {
    gvfs.enable = true;    # Mount, trash, and other functionalities
    tumbler.enable = true; # Thumbnail support for images
    udisks2.enable = true; # Auto mount usb drives
  };
}

Locale

I live in Sweden and would like all my locale and timezone settings to match. Except my default locale.

{ user, ... } :
let
  locale = user.locale;
  defaultLocale = "en_GB.UTF-8";
in
{
  # Set your time zone.
  time.timeZone = "Europe/Stockholm";

  # Select internationalisation properties.
  i18n.defaultLocale = defaultLocale;

  i18n.extraLocaleSettings = {
    LC_ADDRESS = locale;
    LC_IDENTIFICATION = locale;
    LC_MEASUREMENT = locale;
    LC_MONETARY = locale;
    LC_NAME = locale;
    LC_NUMERIC = locale;
    LC_PAPER = locale;
    LC_TELEPHONE = locale;
    LC_TIME = defaultLocale;
  };
}

Networking

Not much to see here. I want networking to be enabled. I want firewall as well.

{ pkgs, ... } : {
  networking = {
    # allow automatic ip assignment when connecting to a network
    useDHCP = pkgs.lib.mkDefault true;
    networkmanager.enable = true;
    firewall.enable = true;

    # let wifi info be NOT declarative, allowing user to configure wifi.
    wireless.userControlled = true;
  };
}

Niri

This is a big one because the DE needs so much configuration. This section mostly installs Niri. The key bindings and monitor configuration are done in the home manager section.

{ config, lib, pkgs, ... }:
{
  programs.niri = {
    enable = true;
    package = pkgs.niri;
  };

  niri-flake.cache.enable = false;

  # Keep using the existing polkit-gnome user agent instead of niri-flake's KDE agent.
  systemd.user.services.niri-flake-polkit.enable = false;

  # Niri's default portal preference tries GNOME first. GNOME's FileChooser
  # backend delegates to a service that is not activatable in this session, so
  # route file dialogs directly to GTK while keeping the rest of the defaults.
  xdg.portal = {
    extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
    config.niri = {
      default = [ "gnome" "gtk" ];
      "org.freedesktop.impl.portal.Access" = [ "gtk" ];
      "org.freedesktop.impl.portal.FileChooser" = [ "gtk" ];
      "org.freedesktop.impl.portal.Notification" = [ "gtk" ];
      "org.freedesktop.impl.portal.Secret" = [ "gnome-keyring" ];
    };
  };

  environment.sessionVariables = {
    XDG_SESSION_TYPE = "wayland";
    XDG_CURRENT_DESKTOP = "niri";
    XDG_SESSION_DESKTOP = "niri";
    NIXOS_OZONE_WL = "1";
    ELECTRON_OZONE_PLATFORM_HINT = "auto";
    XCURSOR_SIZE = "24";
    GDK_SCALE = "1.5";
    RUSTICL_ENABLE = "radeonsi"; # for davinci resolve
  };

  security.polkit.enable = true;
  security.pam.services.gdm.enableGnomeKeyring = true;
}

Services

These are some of the services that I enable at the system level. Explanation in the comments.

{ user, ...} :
{
  services = {
    blueman.enable = true;                # bluetooth manager
    fwupd.enable = true;                  # firmware updating service
    fstrim.enable = true;                 # ssd maintenance service
    thermald.enable = true;               # thermal regulation service
    printing.enable = true;               # printing services, cups
    gnome.gnome-keyring.enable = true;    # keyring
    flatpak.enable = true;                # allow installing things from flatpaks
    upower.enable = true;                 # battery/power reporting, used by noctalia
    power-profiles-daemon.enable = true;  # power profile switching from noctalia's control center

    # printer discovery
    avahi = {
      enable = true;
      nssmdns4 = true;
      openFirewall = true;
    };
  };

  virtualisation = {
    containers.enable = true;
    docker.enable = true;
    podman = {
      enable = true;
      defaultNetwork.settings.dns_enabled = true; # Required for containers under podman-compose to be able to talk to each other.
    };
  };

  users.users.${user.username}.extraGroups = [ "podman" "docker" ]; # add self to docker and podman user group
}

Audio

This is still a work in progress, but it almost works. I enable all the audio related services, hoping everything is going to be okay.

{ pkgs, ...}:
{
  environment.systemPackages = with pkgs; [ pamixer ];
  services.pipewire = {
    enable = true;
    alsa.enable = true;
    alsa.support32Bit = true;
    pulse.enable = true;
    jack.enable = true;
  };

  # pipewire needs realtime scheduling access
  security.rtkit.enable = true;
}

Steam

Finally, I have steam installed and it requires a few extra things as well.

{ pkgs, ... } :
{
  environment.systemPackages = with pkgs; [
    steam-run # also used for random executables that expect fhs
  ];

  programs.steam = {
    enable = true;
    # Open ports in the firewall for Steam Remote Play
    remotePlay.openFirewall = true;
    # Open ports in the firewall for Source Dedicated Server
    dedicatedServer.openFirewall = true;
  };
}

Sops

We use sops to manage secrets on this machine.

{ user, ...} :
{
  sops.defaultSopsFile = ../secrets/secrets.yaml;
  sops.defaultSopsFormat = "yaml";
  sops.age.keyFile = "/home/${user.username}/.config/sops/age/keys.txt";
}

Sops requires a public .sops.yaml that dictates how the secrets are encrypted. This contains the public key and path to the secrets file.

keys:
  - &primary age1yq35g6mmlem0rhr47u6ewh8dctlwp9hj0s0ac60e4hrw9hjzlqms6crf7n
creation_rules:
  - path_regex: secrets/secrets.yaml$
    key_groups:
      - age:
        - *primary

AI + LLMs

The workstation is increasingly being automated with agents at this point. Adding it to the global packages makes it easier to launch in arbitrary locations.

{ pkgs, lib, config, user, llm-agents, ... } :
{
  environment.systemPackages = (with llm-agents.packages.${pkgs.stdenv.hostPlatform.system}; [
    claude-code
    pi
    agent-browser
    codex
    codegraph
  ]);

  nix.settings = {
    substituters = [ "https://cache.numtide.com" ];
    trusted-public-keys = [
      "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g="
    ];
  };
}

Miscellaneous Packages and Programs

environment.systemPackages = with pkgs; [
  wget       # fetch utility
  curl       # more fetch utility
  binutils   # executable utilities, like ld
  dmidecode  # tool for dumping system info
  libnotify  # notification daemon
  python3    # nice to have this ready for quick things
  cacert     # certificate authority
  remmina    # remote desktop app
  jaq        # rust based jq alternative
  comma      # nix-run helper
  tmux       # seems useful for a bunch of things
  kache      # rust cache
  element-desktop
];

# to enable svg icons in gtk apps
# https://github.com/catppuccin/nix/issues/584
programs.gdk-pixbuf.modulePackages = [ pkgs.librsvg ];

# sharing folders to the tv over local network
services.minidlna = {
  enable = true;
  openFirewall = true;
  settings = {
    media_dir = [
      "V,/home/nambiar/videos"
    ];
    friendly_name = "smallbox";
    inotify = "yes";
  };
};

users.users.minidlna = {
  extraGroups = [ "users" ];
};

# enabling the kache service for faster rust builds
services.kache = {
  enable = true;
  daemon.enable = true;
  settings.cache = {
    local_max_size = "40GB";
  };
};
programs = {
  nix-ld.enable = true;   # helps with linking troubles with dynamic libraries
  appimage.enable = true; # allow appimage installations
  dconf.enable = true;    # to save user settings
  gnupg.agent = {
    # pgp client
    enable = true;
    enableSSHSupport = true;
  };
  firefox.enable = true;   # browser
  wireshark.enable = true; # vpn
};

Fonts

Nothing much to see here. I love Aporetic, and I use it everywhere.

fonts.packages = with pkgs; [
  aporetic
  nerd-fonts.iosevka
];

User Config

This creates the user profile that I login with. Initially created during install.

users.users.${user.username} = {
  isNormalUser = true;
  description = "Sandeep Nambiar";
  extraGroups = [
    "networkmanager" # allow editing network connections
    "wheel"          # can do sudo
    "scanner"        # access to the network scanner
    "lp"             # access to the printer
  ];
};

Home

I use home-manager to manage my user level dotfiles and configurations. Most of the “theme” of the system is decided here. I also use it to install programs that are okay with being installed at the user level instead of the system.

{ pkgs, user, ... } :
{
  imports = [
    ./noctalia.nix
    ./lock.nix
    ./unity.nix
    ./niri.nix
    ./theme.nix
    ./terminal.nix
    ./dev.nix
    ./emacs
  ];

  <<home-user>>

  <<home-packages>>

  programs.home-manager.enable =  true;
}

Oof! Again with all the imports! We can go through them one at a time!

Noctalia

./assets/background.png

Noctalia is the desktop shell. A single Wayland client provides the bar, the application launcher, the notification daemon, the control center, the session menu, the lock screen and the wallpaper. The settings below are generated into a TOML file that noctalia validates at build time. I stay close to the defaults, with the catppuccin palette to match the rest of the system. The shell runs as a systemd user service (the method the v5 NixOS docs recommend): it is bound to graphical-session.target, restarts on crash and on config changes. Niri talks to it over IPC (noctalia msg ...) — the keybindings are in the niri section below. Per the docs, launch_apps_as_systemd_services keeps apps launched from noctalia alive across shell restarts.

{ config, ... }:
{
  programs.noctalia = {
    enable = true;
    systemd.enable = true;
    settings = {
      shell = {
        font_family = "Aporetic Sans Mono";
        launch_apps_as_systemd_services = true;
      };

      bar = {
        margin_edge = 0;
        margin_ends = 0;
        radius = 0;
      };

      theme = {
        mode = "dark";
        source = "builtin";
        builtin = "Catppuccin";
      };

      wallpaper.directory = "${config.home.homeDirectory}/wallpapers";
    };
  };

  home.file = {
    background = {
      source = ../assets/background.png;
      target = "wallpapers/background.png";
    };

    me = {
      source = ../assets/me.jpg;
      target = ".me.jpg";
    };
  };
}

Lock Screen

The lock screen and the session (logout) menu are provided by noctalia, configured in the section above. I use swayidle for explicit session lock/sleep events. Idle timeouts are deliberately disabled so this machine never locks, turns monitors off, or sleeps unless I ask it to. They are configured below.

{ config, lib, pkgs, ... } :
let
  lockCommand = "${lib.getExe config.programs.noctalia.package} msg session lock";
in
{
  services = {
    swayidle = {
      enable = true;
      events = {
        lock = lockCommand;                                                          # noctalia's lock screen.
        before-sleep = "${pkgs.systemd}/bin/loginctl lock-session";                  # lock before suspend.
      };

      timeouts = [];
    };
  };
}

Unity

I work with the Unity Game Engine and I have the unity hub installed globally, instead of in a project flake. Unity also ships an experimental standalone Unity CLI for installing editors, managing modules, and scripting Hub workflows from the terminal. Often I work with dotnet directly inside of unity and so we set that up too.

{ config, lib, pkgs, ... }:
let
  unityCliBin = pkgs.stdenvNoCC.mkDerivation rec {
    pname = "unity-cli-bin";
    version = "0.1.0-beta.7";

    src = pkgs.fetchurl {
      url = "https://public-cdn.cloud.unity3d.com/hub/prod/cli/${version}/unity-linux-x64";
      hash = "sha256-5BmKjJTP0PQ2tDSvjPBTm9Ktfvx380yDkrmfUKEOXhU=";
    };

    dontUnpack = true;

    installPhase = ''
      runHook preInstall
      install -Dm755 $src $out/bin/unity-cli
      runHook postInstall
    '';
  };

  unityhubWithCli =
    let
      unityhub = pkgs.unityhub.override {
        extraPkgs = pkgs: with pkgs; [
          dotnet-sdk_10
          python314
          unityCliBin
        ];
      };
    in unityhub.overrideAttrs (old: {
      installPhase = old.installPhase + ''
        makeWrapper ${unityhub.fhsEnv}/bin/unityhub-fhs-env $out/bin/unity \
          --add-flags ${unityCliBin}/bin/unity-cli \
          --argv0 unity
      '';
    });
in
{
  home.packages = [
    unityhubWithCli
  ];

  xdg.desktopEntries.unityhub = {
    name = "Unity Hub";
    exec = "${unityhubWithCli}/bin/unityhub %U";
    terminal = false;
    type = "Application";
    icon = "unityhub";
    settings.StartupWMClass = "unityhub";
    comment = "The Official Unity Hub";
    categories = [ "Development" ];
    mimeType = [ "x-scheme-handler/unityhub" ];
  };

  xdg.mimeApps = {
    enable = true;
    defaultApplications."x-scheme-handler/unityhub" = "unityhub.desktop";
  };
}

Niri

This configures the desktop environment along with the peripherals. The comments should explain whats happening.

{
  config,
  lib,
  pkgs,
  ...
}:
let
  inherit (config.lib.niri) actions;
  noctalia = lib.getExe config.programs.noctalia.package;
in
{
  config = {
    # required for the default Niri config
    programs.kitty.enable = true;

    services.polkit-gnome.enable = true;

    programs.niri.settings = with actions; {
      input = {
        keyboard = {
          xkb = {
            layout = "us,se";
            options = "ctrl:nocaps";
          };
          track-layout = "global";
        };

        touchpad.scroll-factor = 0.5;

        mouse = {
          accel-speed = -0.5;
          accel-profile = "adaptive";
          scroll-factor = 0.5;
        };

        focus-follows-mouse.enable = true;
        mod-key = "Super";
      };

      layout = {
        gaps = 4;
        default-column-width.proportion = 0.5;

        focus-ring = {
          enable = true;
          width = 2;
          active.gradient = {
            from = "#89b4fa";
            to = "#a6e3a1";
            angle = 45;
          };
          inactive.color = "#1e1e2e";
        };

        border.enable = false;
      };

      screenshot-path = "~/screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png";
      prefer-no-csd = true;

      xwayland-satellite.path = lib.getExe pkgs.xwayland-satellite;

      hotkey-overlay.skip-at-startup = true;

      window-rules = [
        {
          geometry-corner-radius = {
            top-left = 5.0;
            top-right = 5.0;
            bottom-right = 5.0;
            bottom-left = 5.0;
          };
          clip-to-geometry = true;
        }
        {
          matches = [
            {
              app-id = "firefox$";
              title = "^Picture-in-Picture$";
            }
          ];
          open-floating = true;
        }
        {
          # the noctalia settings window floats instead of tiling.
          matches = [
            { app-id = "dev.noctalia.Noctalia.Settings"; }
          ];
          open-floating = true;
        }
        {
          matches = [
            { is-window-cast-target = true; }
          ];
          focus-ring = {
            active.color = "#f38ba8";
            inactive.color = "#7d0d2d";
          };
        }
      ];

      binds = {
        "Mod+Return".action = spawn "ghostty" "+new-window";
        "Mod+I".action = spawn "firefox";
        "Mod+E".action = spawn "emacs";
        "Mod+Shift+Q".action = close-window;
        "Mod+Shift+M".action = quit;
        "Mod+F".action = spawn "thunar";
        "Mod+Shift+Space".action = toggle-window-floating;
        "Mod+Shift+K".action = switch-layout "next";
        "Mod+M".action = maximize-window-to-edges;
        "Mod+Shift+F".action = fullscreen-window;

        # noctalia's application launcher, clipboard history and control center
        "Mod+Space".action = spawn noctalia "msg" "panel-toggle" "launcher";
        "Mod+V".action = spawn noctalia "msg" "panel-toggle" "clipboard";
        "Mod+N".action = spawn noctalia "msg" "panel-toggle" "control-center";
        "Alt+Tab".action = focus-window-previous;

        "Mod+Shift+Left".action = move-column-left;
        "Mod+Shift+Right".action = move-column-right;
        "Mod+Shift+Up".action = move-window-up;
        "Mod+Shift+Down".action = move-window-down;

        "Mod+Ctrl+Left".action = move-workspace-to-monitor-left;
        "Mod+Ctrl+Right".action = move-workspace-to-monitor-right;

        "Mod+Left".action = focus-column-or-monitor-left;
        "Mod+A".action = focus-column-or-monitor-left;
        "Mod+Right".action = focus-column-or-monitor-right;
        "Mod+D".action = focus-column-or-monitor-right;
        "Mod+Up".action = focus-window-or-monitor-up;
        "Mod+W".action = focus-window-or-monitor-up;
        "Mod+Down".action = focus-window-or-monitor-down;
        "Mod+S".action = focus-window-or-monitor-down;

        "Mod+WheelScrollDown" = {
          cooldown-ms = 150;
          action = focus-workspace-down;
        };
        "Mod+WheelScrollUp" = {
          cooldown-ms = 150;
          action = focus-workspace-up;
        };

        "Mod+Shift+P".action = { screenshot-window = [ ]; };
        "Mod+Shift+A".action = { screenshot = [ ]; };
        "Mod+Alt+P".action = { screenshot-screen = [ ]; };
        "Mod+Ctrl+P".action = { screenshot-screen = [ ]; };

        "Mod+Shift+S".action = spawn noctalia "msg" "panel-toggle" "session";
        "Mod+Alt+L" = {
          allow-when-locked = true;
          action = spawn noctalia "msg" "session" "lock";
        };
        "Mod+Ctrl+Shift+F".action = toggle-windowed-fullscreen;
        "Mod+Alt+C".action = set-dynamic-cast-window;
        "Mod+Shift+C".action = set-dynamic-cast-monitor;
        "Mod+Ctrl+C".action = clear-dynamic-cast-target;

        "Mod+1".action = focus-workspace "1";
        "Mod+2".action = focus-workspace "2";
        "Mod+3".action = focus-workspace "3";
        "Mod+4".action = focus-workspace "4";
        "Mod+5".action = focus-workspace "5";
        "Mod+6".action = focus-workspace "6";
        "Mod+7".action = focus-workspace "7";
        "Mod+8".action = focus-workspace "8";
        "Mod+9".action = focus-workspace "9";

        "Mod+Shift+1".action = { move-window-to-workspace = "1"; };
        "Mod+Shift+2".action = { move-window-to-workspace = "2"; };
        "Mod+Shift+3".action = { move-window-to-workspace = "3"; };
        "Mod+Shift+4".action = { move-window-to-workspace = "4"; };
        "Mod+Shift+5".action = { move-window-to-workspace = "5"; };
        "Mod+Shift+6".action = { move-window-to-workspace = "6"; };
        "Mod+Shift+7".action = { move-window-to-workspace = "7"; };
        "Mod+Shift+8".action = { move-window-to-workspace = "8"; };
        "Mod+Shift+9".action = { move-window-to-workspace = "9"; };

        "Mod+Escape" = {
          allow-inhibiting = false;
          action = toggle-keyboard-shortcuts-inhibit;
        };
      };
    };
  };
}

Theme

I use the Catppuccin almost everywhere. The nix module integrates almost automatically everywhere (except gtk). You’ll notice the color values in multiple places outside this as well.

{ pkgs, ...}:
{
  gtk = {
    enable = true;
    colorScheme = "dark";
    theme = {
      name = "Catppuccin-GTK-Grey-Dark-Compact";
      package = (pkgs.magnetic-catppuccin-gtk.override {
        accent = [ "grey" ];
        shade = "dark";
        tweaks = [ "black" ];
        size = "compact";
      });
    };

    iconTheme.name = "Papirus-Dark";
    gtk4.extraConfig.gtk-interface-color-scheme = "dark";
  };

  catppuccin.enable = true;
  catppuccin.autoEnable = true;
  catppuccin.flavor = "mocha";
  catppuccin.accent = "blue";
  catppuccin.gtk.icon.enable = true;
  catppuccin.cursors.enable = true;
  catppuccin.cache.enable = true;
}

Terminal

Alacritty is my terminal program. The snippet below configures how it looks.

{
  programs = {
    ghostty = {
      enable = true;
      enableZshIntegration = true;
      systemd.enable = true;
      settings = {
        font-family = "Aporetic Sans Mono";
        font-size = 12;
        command = "zsh";
        keybind = [
          "ctrl+x>3=new_split:right"
          "ctrl+x>2=new_split:down"
          "ctrl+x>o=goto_split:next"
          "ctrl+x>k=close_surface"
        ];
        window-padding-x = 4;
        window-padding-y = 4;
        cursor-style = "block";
      };
    };
  };

  catppuccin.ghostty.enable = true;
  catppuccin.ghostty.flavor = "mocha";
}

Dev Tools

All the miscellaneous dev tools on this computer.

{ user, pkgs, ... }:
{
  programs = {
    vscode.enable = true;   # yes, sometimes i like to dabble
    ripgrep.enable = true;  # fast text search across projects
    btop.enable = true;     # even better task manager

    # fuzzy finder
    fzf = {
      enable = true;
      enableZshIntegration = true;
      enableBashIntegration = true;
    };

    # better cd
    zoxide = {
      enable = true;
      enableZshIntegration = true;
      enableBashIntegration = true;
    };

    # better ls
    eza = {
      enable = true;
      enableZshIntegration = true;
      enableBashIntegration = true;
    };

    # this is mainly for integration with nix flakes in individual projects
    direnv = {
      enable = true;
      enableZshIntegration = true;
      enableBashIntegration = true;
      nix-direnv.enable = true;
    };

    # zsh everywhere with oh-my-zsh
    zsh = {
      enable = true;
      oh-my-zsh = {
        enable = true;
        plugins = [ "git" ];
        theme = "robbyrussell";
      };

      shellAliases = {
        cd = "z";     # zoxide
        jq = "jaq";
        bd = "br"; # beads
      };

    };

    # git with lfs
    git = {
      lfs.enable = true;
      enable = true;
    };
  };
}

Other Settings

Some repeated info from the configuration.

Home User

home.username = "${user.username}";
home.homeDirectory = pkgs.lib.mkDefault "/home/${user.username}";
home.stateVersion = user.stateVersion;

Home Packages

A bunch of programs that I use.

home.packages = with pkgs; [
  audacity                 # audio recording
  zoom-us                  # meetings
  handbrake                # video transcoding
  xdg-utils                # utils, for screensharing
  vlc                      # media player
  discord                  # other chat
  slack                    # work chat
  pavucontrol              # audio control
  spotify                  # music player
  simple-scan              # scanner software
  pinta                    # image editor
  mpv                      # media player
];

programs.obs-studio.enable = true; # screen recording tool

Emacs

I practically live inside emacs. The configuration for it is a mix between init.el and the nix configuration. Nix allows me to install emacs packages as part of the configuration which is most of the following file. I install the nix community provided emacs overlay that lets me have the latest emacs with pgtk ui (for wayland). Comments describe the emacs package and what it does.

{ pkgs, ... }:
{
  programs.emacs = {
    enable = true;
    # install with tree sitter enabled
    package = (pkgs.emacs-git-pgtk.override { withTreeSitter = true; });
    extraPackages = epkgs: [
      # also install all tree sitter grammars
      epkgs.manualPackages.treesit-grammars.with-all-grammars
      epkgs.nerd-icons               # nerd fonts support
      epkgs.doom-modeline            # model line
      epkgs.diminish                 # hides modes from modeline
      epkgs.eldoc                    # doc support
      epkgs.pulsar                   # pulses the cursor when jumping about
      epkgs.which-key                # help porcelain
      epkgs.expreg                   # expand region
      epkgs.vundo                    # undo tree
      epkgs.puni                     # structured editing
      epkgs.avy                      # jumping utility
      epkgs.consult                  # emacs right click
      epkgs.vertico                  # minibuffer completion
      epkgs.marginalia               # annotations for completions
      epkgs.crux                     # utilities
      epkgs.magit                    # git porcelain
      epkgs.nerd-icons-corfu         # nerd icons for completion
      epkgs.corfu                    # completion
      epkgs.cape                     # completion extensions
      epkgs.orderless                # search paradigm
      epkgs.yasnippet                # snippets support
      epkgs.yasnippet-snippets       # commonly used snippets
      epkgs.rg                       # ripgrep
      epkgs.exec-path-from-shell     # load env and path
      epkgs.ghostel                  # better shell
      epkgs.nix-mode                 # nix lang
      epkgs.hcl-mode                 # hashicorp file mode
      epkgs.shell-pop                # quick shell popup
      epkgs.nixpkgs-fmt              # format nix files
      epkgs.f                        # string + file utilities
      epkgs.gptel
      epkgs.agent-shell              # acp-powered agent shell (uses claude-agent-acp)
      epkgs.catppuccin-theme         # catppuccin theme
      epkgs.eldoc-box                # docs in a box
      epkgs.sideline                 # mainly for flymake errors on the side
      epkgs.sideline-flymake         # mainly for flymake errors on the side
      epkgs.ben                      # support for loading envrc, async
    ];
  };

  home.sessionVariables = {
    EDITOR = "emacs";
    XDG_SCREENSHOTS_DIR = "~/screenshots";
    # zoxide's doctor check false-positives in non-interactive shells
    # (agents, scripts): the chpwd hook never fires without a prompt
    _ZO_DOCTOR = "0";
  };

  home.file = {
    emacs-init = {
      source = ./early-init.el;
      target = ".emacs.d/early-init.el";
    };

    emacs = {
      source = ./init.el;
      target = ".emacs.d/init.el";
    };
  };

  services.nextcloud-client = {
    enable = true;
  };
}

Early Initialization

There are some emacs settings that can be configured before the gui shows up. And some of them help increase performance and let the gui show up that much faster. These are listed here.

;;; package --- early init -*- lexical-binding: t -*-

;;; Commentary:
;;; Prevents white flash and better Emacs defaults

;;; Code:
(set-language-environment "UTF-8")

(setq-default
 default-frame-alist
 '((background-color . "#1e1e2e")
   (bottom-divider-width . 1)            ; Thin horizontal window divider
   (foreground-color . "#bac2de")        ; Default foreground color
   (fullscreen . maximized)              ; Maximize the window by default
   (horizontal-scroll-bars . nil)        ; No horizontal scroll-bars
   (left-fringe . 8)                     ; Thin left fringe
   (menu-bar-lines . 0)                  ; No menu bar
   (right-divider-width . 1)             ; Thin vertical window divider
   (right-fringe . 8)                    ; Thin right fringe
   (tool-bar-lines . 0)                  ; No tool bar
   (undecorated . t)                     ; Remove extraneous X decorations
   (vertical-scroll-bars . nil))         ; No vertical scroll-bars

 user-full-name "Sandeep Nambiar"                ; ME!

 ;; memory configuration
 ;; Higher garbage collection threshold, prevents frequent gc locks, reset later
 gc-cons-threshold most-positive-fixnum
 ;; Ignore warnings for (obsolete) elisp compilations
 byte-compile-warnings '(not obsolete)
 ;; And other log types completely
 warning-suppress-log-types '((comp) (bytecomp))
 ;; Large files are okay in the new millenium.
 large-file-warning-threshold 100000000
 ;; dont show garbage collection messages at startup, will reset later
 garbage-collection-messages nil
 ;; native compilation
 package-native-compile t
 native-comp-warning-on-missing-source nil
 native-comp-async-report-warnings-errors 'silent


 ;; Read more based on system pipe capacity
 read-process-output-max (max (* 10240 10240) read-process-output-max)

 ;; scroll configuration
 scroll-margin 0                                 ; Lets scroll to the end of the margin
 scroll-conservatively 100000                    ; Never recenter the window
 scroll-preserve-screen-position 1               ; Scrolling back and forth

 ;; frame config
 ;; Improve emacs startup time by not resizing to adjust for custom settings
 frame-inhibit-implied-resize t
 ;; Dont resize based on character height / width but to exact pixels
 frame-resize-pixelwise t

 ;; backups & files
 backup-directory-alist '(("." . "~/.backups/")) ; Don't clutter
 backup-by-copying t                             ; Don't clobber symlinks
 create-lockfiles nil                            ; Don't have temp files
 delete-old-versions t                           ; Cleanup automatically
 kept-new-versions 6                             ; Update every few times
 kept-old-versions 2                             ; And cleanup even more
 version-control t                               ; Version them backups
 delete-by-moving-to-trash t                     ; Dont delete, send to trash instead

 ;; startup
 inhibit-startup-screen t                        ; I have already done the tutorial. Twice
 inhibit-startup-message t                       ; I know I am ready
 inhibit-startup-echo-area-message t             ; Yep, still know it
 initial-scratch-message nil                     ; I know it is the scratch buffer!
 initial-buffer-choice nil
 inhibit-startup-buffer-menu t
 inhibit-x-resources t
 initial-major-mode 'fundamental-mode

 pgtk-wait-for-event-timeout 0.001               ; faster child frames

 ad-redefinition-action 'accept                  ; dont care about legacy things being redefined
 inhibit-compacting-font-caches t

 ;; tabs
 tab-width 4                                     ; Always tab 4 spaces.
 indent-tabs-mode nil                            ; Never use actual tabs.

 ;; rendering
 cursor-in-non-selected-windows nil              ; dont render cursors other windows

 ;; packages
 use-package-always-defer t

 load-prefer-newer t

 default-input-method nil

 use-dialog-box nil

 use-file-dialog nil

 use-package-expand-minimally t

 package-enable-at-startup nil

 use-package-enable-imenu-support t

 auto-mode-case-fold nil  ; No second pass of case-insensitive search over auto-mode-alist.

 package-archives '(("melpa"        . "https://melpa.org/packages/")
                    ("gnu"          . "https://elpa.gnu.org/packages/")
                    ("nongnu"       . "https://elpa.nongnu.org/nongnu/")
                    ("melpa-stable" . "https://stable.melpa.org/packages/"))

 package-archive-priorities '(("gnu"    . 99)
                              ("nongnu" . 80)
                              ("melpa"  . 70)
                              ("melpa-stable" . 50))
 )
 ;;; early-init.el ends here

Initialization

Now starts the main emacs configuration.

    ;;; package --- Summary - My minimal Emacs init file -*- lexical-binding: t -*-

    ;;; Commentary:
    ;;; Simple Emacs setup I carry everywhere

    ;;; Code:
(setq custom-file (locate-user-emacs-file "custom.el"))
(load custom-file 'noerror)            ;; no error on missing custom file

(require 'package)
(package-initialize)

(defun reset-custom-vars ()
  "Resets the custom variables that were set to crazy numbers"
  (setopt gc-cons-threshold (* 1024 1024 100))
  (setopt garbage-collection-messages t))

(use-package emacs
  :custom
  (native-comp-async-query-on-exit t)
  (read-answer-short t)
  (use-short-answers t)
  (enable-recursive-minibuffers t)
  (which-func-update-delay 1.0)
  (visible-bell nil)
  (custom-buffer-done-kill t)
  (whitespace-line-column nil)
  (x-underline-at-descent-line t)
  (imenu-auto-rescan t)
  (uniquify-buffer-name-style 'forward)
  (confirm-nonexistent-file-or-buffer nil)
  (create-lockfiles nil)
  (make-backup-files nil)
  (kill-do-not-save-duplicates t)
  (sentence-end-double-space nil)
  (treesit-enabled-modes t)
  (display-buffer-base-action
   '((display-buffer-reuse-window display-buffer-use-some-window)))
  :init
  ;; base visual
  (menu-bar-mode -1)                   ;; no menu bar
  (toggle-scroll-bar -1)               ;; no scroll bar
  (tool-bar-mode -1)                   ;; no tool bar either
  (blink-cursor-mode -1)               ;; stop blinking

  ;; font of the century
  (add-to-list 'default-frame-alist
               '(font . "Aporetic Sans Mono-12"))

  :bind
  (("C-<wheel-up>"   . pixel-scroll-precision) ; dont zoom in please, just scroll
   ("C-<wheel-down>" . pixel-scroll-precision) ; dont zoom in either, just scroll
   ("C-x k"          . kill-current-buffer))   ; kill the buffer, dont ask
  :hook
  (text-mode . delete-trailing-whitespace-mode)
  (prog-mode . delete-trailing-whitespace-mode)
  (after-init . global-display-line-numbers-mode) ;; always show line numbers
  (after-init . column-number-mode)               ;; column number in the mode line
  (after-init . size-indication-mode)             ;; file size in the mode line
  (after-init . pixel-scroll-precision-mode)      ;; smooth mouse scroll
  (after-init . electric-pair-mode)               ;; i mean ... parens should auto create
  (after-init . reset-custom-vars)
  )

(use-package autorevert
  :ensure nil
  :custom
  (auto-revert-interval 3)
  (auto-revert-remote-files nil)
  (auto-revert-use-notify t)
  (auto-revert-avoid-polling nil)
  (auto-revert-verbose t)
  :hook
  (after-init . global-auto-revert-mode))

(use-package recentf
  :ensure nil
  :commands (recentf-mode recentf-cleanup)
  :hook
  (after-init . recentf-mode)
  :custom
  (recentf-auto-cleanup 'never)
  (recentf-exclude
   (list "\\.tar$" "\\.tbz2$" "\\.tbz$" "\\.tgz$" "\\.bz2$"
         "\\.bz$" "\\.gz$" "\\.gzip$" "\\.xz$" "\\.zip$"
         "\\.7z$" "\\.rar$"
         "COMMIT_EDITMSG\\'"
         "\\.\\(?:gz\\|gif\\|svg\\|png\\|jpe?g\\|bmp\\|xpm\\)$"
         "-autoloads\\.el$" "autoload\\.el$"))

  :config
  ;; A cleanup depth of -90 ensures that `recentf-cleanup' runs before
  ;; `recentf-save-list', allowing stale entries to be removed before the list
  ;; is saved by `recentf-save-list', which is automatically added to
  ;; `kill-emacs-hook' by `recentf-mode'.
  (add-hook 'kill-emacs-hook #'recentf-cleanup -90))

(use-package savehist
  :ensure nil
  :commands (savehist-mode savehist-save)
  :hook
  (after-init . savehist-mode)
  :custom
  (savehist-autosave-interval 600)
  (savehist-additional-variables
   '(kill-ring                        ; clipboard
     register-alist                   ; macros
     mark-ring global-mark-ring       ; marks
     search-ring regexp-search-ring)))

(use-package hl-line
  :ensure nil
  :custom
  (hl-line-sticky-flag nil)
  (global-hl-line-sticky-flag nil)
  :hook
  (after-init . global-hl-line-mode))

(use-package saveplace
  :ensure nil
  :commands (save-place-mode save-place-local-mode)
  :hook
  (after-init . save-place-mode)
  :custom
  (save-place-limit 400))

(use-package nerd-icons
  :custom
  ;; disable bright icon colors
  (nerd-icons-color-icons nil))

(use-package doom-modeline
  :custom
  (inhibit-compacting-font-caches t)    ;; speed
  (doom-modeline-buffer-file-name-style 'relative-from-project)
  (doom-modeline-major-mode-icon nil)   ;; distracting icons, no thank you
  (doom-modeline-buffer-encoding nil)   ;; everything is utf-8 anyway
  (doom-modeline-buffer-state-icon nil) ;; the filename already shows me
  (doom-modeline-lsp nil)               ;; lsp state is too distracting, too often
  :hook (after-init . doom-modeline-mode))

(load-theme 'catppuccin :no-confirm)

(use-package diminish :demand t)         ;; declutter the modeline
(use-package eldoc
  :diminish eldoc-mode
  :custom
  (eldoc-echo-area-use-multiline-p nil)) ;; docs for everything

(use-package eldoc-box
  :defer t
  :config
  (set-face-background 'eldoc-box-border (catppuccin-color 'green))
  (set-face-background 'eldoc-box-body (catppuccin-color 'base))
  :bind
  (("M-h" . eldoc-box-help-at-point)))

(use-package pulsar
  :commands pulsar-global-mode pulsar-recenter-top pulsar-reveal-entry
  :init
  (defface pulsar-catppuccin
    `((default :extend t)
      (((class color) (min-colors 88) (background light))
       :background ,(catppuccin-color 'sapphire))
      (((class color) (min-colors 88) (background dark))
       :background ,(catppuccin-color 'sapphire))
      (t :inverse-video t))
    "Alternative Catppuccin face for `pulsar-face'."
    :group 'pulsar-faces)
  :custom
  (pulsar-face 'pulsar-catppuccin)
  :hook
  (after-init . pulsar-global-mode))

(use-package which-key
  :commands which-key-mode
  :diminish which-key-mode
  :hook
  (after-init . which-key-mode))

(use-package expreg
  :bind ("M-m" . expreg-expand))

(use-package vundo) ;; undo tree

;; better structured editing
(use-package puni
  :commands puni-global-mode
  :hook
  (after-init . puni-global-mode))

(use-package avy
  :bind
  ("M-i" . avy-goto-char-2)
  :custom
  (avy-background t))

(use-package consult
  :bind
  ("C-x b"   . consult-buffer)     ;; orig. switch-to-buffer
  ("M-y"     . consult-yank-pop)   ;; orig. yank-pop
  ("M-g M-g" . consult-goto-line)  ;; orig. goto-line
  ("M-g i"   . consult-imenu)      ;; consult version is interactive
  ("M-g r"   . consult-ripgrep)    ;; find in project also works
  :custom
  (consult-narrow-key "<"))

(use-package vertico
  :commands vertico-mode
  :custom
  (read-file-name-completion-ignore-case t)
  (read-buffer-completion-ignore-case t)
  (completion-ignore-case t)
  (enable-recursive-minibuffers t)
  (minibuffer-prompt-properties '(read-only t cursor-intangible t face minibuffer-prompt))
  :init
  (vertico-mode)
  :hook
  (minibuffer-setup-hook . cursor-intangible-mode))

(use-package marginalia
  :commands marginalia-mode
  :hook (after-init . marginalia-mode))

(use-package crux
  :bind
  ("C-c M-e" . crux-find-user-init-file)
  ("C-c C-w" . crux-transpose-windows)
  ("C-c M-d" . crux-find-current-directory-dir-locals-file)
  ("C-a"     . crux-move-beginning-of-line))

(use-package magit
  :bind (("C-M-g" . magit-status)
         :map project-prefix-map
         ("m" . magit-project-status))
  :config
  (add-to-list 'project-switch-commands '(magit-project-status "Magit") t))

(use-package nerd-icons-corfu
  :commands nerd-icons-corfu-formatter
  :defines corfu-margin-formatters)

(use-package corfu
  :commands global-corfu-mode
  :custom
  (corfu-cycle t)
  (corfu-auto t)
  (corfu-auto-delay  1)
  (corfu-auto-prefix 3)
  (corfu-separator ?_)
  :hook
  (after-init . global-corfu-mode)
  :config
  (add-to-list 'corfu-margin-formatters #'nerd-icons-corfu-formatter))

(use-package cape)

(use-package orderless
  :custom
  (completion-styles '(orderless partial-completion basic))
  (completion-category-defaults nil)
  (completion-category-overrides nil))

(use-package yasnippet
  :commands yas-global-mode
  :diminish yas-minor-mode
  :hook
  (after-init . yas-global-mode))

(use-package yasnippet-snippets :after yasnippet)

(use-package exec-path-from-shell
  :commands exec-path-from-shell-initialize
  :custom
  (exec-path-from-shell-arguments nil)
  :hook
  (after-init . exec-path-from-shell-initialize))

(use-package nixpkgs-fmt
  :custom
  (nixpkgs-fmt-command "nixfmt"))

(use-package ghostel
  :bind (("C-x m" . ghostel)
         :map ghostel-semi-char-mode-map
         ("C-s"  . consult-line)
         ("M-<backspace>" . ghostel-backward-kill-word)
         ("M-p" . (lambda () (interactive) (ghostel-send-key "p" "ctrl")))
         ("M-n" . (lambda () (interactive) (ghostel-send-key "n" "ctrl")))
         :map project-prefix-map
         ("t" . ghostel-project)
         ("T" . ghostel-project-list-buffers))
  :config
  (defun ghostel-send-C-k-and-kill ()
    "Send `C-k' to ghostel.
     Like normal Emacs `C-k'.  Kill to end of line and put content in kill-ring."
    (interactive)
    (kill-ring-save (point) (line-end-position))
    (ghostel-send-key "k" "ctrl"))

  (add-to-list 'project-switch-commands '(ghostel-project "Ghostel") t)
  (add-to-list 'project-switch-commands '(ghostel-project-list-buffers "Ghostel buffers") t)
  (add-to-list 'ghostel-eval-cmds '("magit-status-setup-buffer" magit-status-setup-buffer)))

(use-package ghostel-compile
  :hook (after-init . ghostel-compile-global-mode))

(use-package ghostel-comint
  :hook (after-init . ghostel-comint-global-mode))

(use-package f :demand t)

(use-package ben
  :diminish ben-global-mode
  :bind
  (:map ben-mode-map
        ("C-c b" . ben-command-map))
  :hook
  (after-init . ben-global-mode))

(use-package gptel)

(use-package sideline-flymake)
(use-package sideline
  :custom
  (sideline-backends-right '(sideline-flymake))
  :hook
  (flymake-mode . sideline-mode))

(use-package eglot
  :custom
  (eglot-extend-to-xref t)
  (eglot-ignored-server-capabilities '(:inlayHintProvider))
  (jsonrpc-event-hook nil)
  :hook
  (eglot-managed-mode . (lambda ()
                          (add-hook 'before-save-hook #'eglot-format-buffer nil t)))
  :bind
  (:map eglot-mode-map
        ("C-c l a" . eglot-code-actions)
        ("C-c l b" . flymake-show-buffer-diagnostics)
        ("C-c l r" . eglot-rename)
        ("C-c l h" . eldoc)
        ("C-c l g" . xref-find-references)
        ("C-c l p" . flymake-show-project-diagnostics)
        ("C-c l w" . eglot-reconnect)))

(use-package proced
  :custom
  (proced-auto-update-flag t)
  (proced-auto-update-interval 3)
  (proced-enable-color-flag t)
  (proced-show-remote-processes t))

;; extras
(use-package comp-run
  :ensure nil
  :config
  (push "tramp-loaddefs.el.gz" native-comp-jit-compilation-deny-list)
  (push "cl-loaddefs.el.gz" native-comp-jit-compilation-deny-list))

(use-package rust-ts-mode
  :ensure nil
  :mode "\\.rs\\'"
  :hook
  (rust-ts-mode . eglot-ensure))

(provide 'init)

;;; init.el ends here

Machines

Only a few more things left. Specifically the machine level extra settings.

Smallbox

The configuration for the laptop does not change much. Most changes are because the hardware is different.

System Level

Nothing specific for the laptop.

{ user, config, ... } : {
  imports =
    [
      ./hardware-configuration.nix
      ./network-configuration.nix
      ../../configuration
    ];

  <<smallbox-secrets>>
}

Wireguard

Both smallbox and homelab use wireguard to connect to the VPN. The shared peer public key and common settings (mtu, dns, keepalive) are extracted into mkWireguard so each machine only specifies what differs.

mkWireguard = { address, privateKeyFile, peerPublicKeyFile, presharedKeyFile, endpointFile, allowedIPs, listenPort ? null }:
  let
    ips = builtins.concatStringsSep "," allowedIPs;
    routes = builtins.concatStringsSep "\n" (map (ip: "ip route add ${ip} dev wg0 2>/dev/null || true") allowedIPs);
  in
    {
      inherit address privateKeyFile;
      mtu = 1300;
      dns = [ "1.1.1.1" "8.8.8.8" ];
      postUp = ''
      wg set wg0 peer "$(cat ${peerPublicKeyFile})" \
        preshared-key ${presharedKeyFile} \
        endpoint "$(cat ${endpointFile})" \
        allowed-ips ${ips} \
        persistent-keepalive 15
      ${routes}
    '';
      preDown = ''
      wg set wg0 peer "$(cat ${peerPublicKeyFile})" remove 2>/dev/null || true
    '';
    } // (if listenPort != null then { inherit listenPort; } else {});

Network Configuration

{ config, ... }:
let
  <<wireguard-config>>
in
{
  networking.wg-quick.interfaces.wg0 = mkWireguard {
    address = [ "172.30.0.2/32" "2a01:4f9:c014:e0de:ac1e::2/128" ];
    privateKeyFile = config.sops.secrets.wg_private_key.path;
    peerPublicKeyFile = config.sops.secrets.wg_peer_public_key.path;
    presharedKeyFile = config.sops.secrets.wg_preshared_key.path;
    endpointFile = config.sops.secrets.vpn_endpoint.path;
    allowedIPs = [ "172.30.0.1/32" "172.30.0.4/32" ];
  };

  networking.wg-quick.interfaces.wg1 = {
    address = [ "10.0.1.10/32" ];
    privateKeyFile = config.sops.secrets.wg_wavefunk_private_key.path;
    peers = [
      {
        publicKey = "a2wrqGLi/aw3mODD0Yal3V2ha6c6isN6HfDI1RLF83M=";
        endpoint = "vpn.wavefunk.io:51820";
        allowedIPs = [ "10.0.1.0/24" ];
        persistentKeepalive = 25;
      }
    ];
  };

}

Hardware

This is the most different. Mostly taken from hardware-configuration.nix setup at first install. As you might notice, there are a lot more kernel modules. This device has thunderbolt for example.

{
  hostname,
  pkgs,
  lib,
  modulesPath,
  user,
  ...
}:
{
  imports = [
    (modulesPath + "/installer/scan/not-detected.nix")
    ../../hardware/hardware.nix
  ];

  boot.initrd.availableKernelModules = [
    "xhci_pci"       # usb wake up (usb host controller)
    "thunderbolt"    # :/
    "nvme"           # support for the nvme disk in here
    "usb_storage"    # :/
    "sd_mod"         # hard drive controller
  ];

  boot.kernelParams = [ "amd_pstate=active"
                        "acpi.ec_no_wakeup=1"
                        # Force use of the thinkpad_acpi driver for backlight control.
                        # This allows the backlight save/load systemd service to work.
                        "acpi_backlight=native"
                        # Needed for touchpad to work properly (click doesn't register by pushing down the touchpad).
                        "psmouse.synaptics_intertouch=0"
                      ];
  boot.kernelModules = [ "kvm-amd" ];
  boot.loader.grub.efiSupport = true;
  boot.loader.grub.useOSProber = true; # detect windows since thats on a partition here
  boot.loader.grub.devices = [ "/dev/nvme0n1" ];

  fileSystems."/" = {
    device = "/dev/disk/by-uuid/0bda9355-76f4-4b55-9012-0a14a73ac6b9";
    fsType = "ext4";
  };

  boot.initrd.luks.devices."luks-f400c0ed-57e0-4b5a-b701-c1a10c19480f".device = "/dev/disk/by-uuid/f400c0ed-57e0-4b5a-b701-c1a10c19480f";

  fileSystems."/boot" = {
    device = "/dev/disk/by-uuid/5A5A-DEFE";
    fsType = "vfat";
    options = [ "fmask=0077" "dmask=0077" ];
  };

  # external disk - manual mount so missing travel disks don't block shell startup
  fileSystems."/home/${user.username}/external" = {
    device = "/dev/disk/by-uuid/18818348-1ee4-4fa5-9984-e4e01b9fa304";
    fsType = "ext4";
    options = [ "noauto" "nofail" ];
  };

  swapDevices = [];

  hardware.graphics = {
    enable = lib.mkDefault true;
    enable32Bit = lib.mkDefault true;

    extraPackages = with pkgs; [
      mesa.opencl # Enables Rusticl (OpenCL) support
      rocmPackages.clr.icd
    ];
  };

  hardware.amdgpu.initrd.enable = lib.mkDefault true;
  networking.hostName = hostname;

  # enalbe fingerprinting services
  services.fprintd.enable = true;
}

Home

This is mostly about configuring the monitor and key bindings. And laptop specific utilities.

{ config, pkgs, ... } :
let
  mainOutput = "PNP(AOC) Q3279WG5B 0x00000161";
  laptopOutput = "eDP-1";
in
{
  imports = [
    ../../home
  ];

  home.packages = with pkgs; [
    brightnessctl
    davinci-resolve
  ];

  programs.niri.settings = with config.lib.niri.actions; {
    outputs = {
      main = {
        name = mainOutput;
        scale = 1;
        position = {
          x = 0;
          y = 0;
        };
        focus-at-startup = true;
      };

      laptop = {
        name = laptopOutput;
        mode = {
          width = 2880;
          height = 1800;
          refresh = 60.000;
        };
        scale = 1.5;
        position = {
          x = 2560;
          y = 0;
        };
      };
    };

    workspaces = {
      "1".open-on-output = mainOutput;
      "2".open-on-output = mainOutput;
      "3".open-on-output = mainOutput;
      "4".open-on-output = mainOutput;
      "5".open-on-output = laptopOutput;
      "6".open-on-output = laptopOutput;
    };

    spawn-at-startup = [
      { command = [ "firefox" ]; }
      { command = [ "emacs" ]; }
      { command = [ "slack" ]; }
    ];

    window-rules = [
      {
        matches = [
          {
            at-startup = true;
            app-id = "firefox$";
          }
        ];
        open-on-workspace = "1";
      }
      {
        matches = [
          {
            at-startup = true;
            app-id = "emacs";
          }
        ];
        open-on-workspace = "2";
      }
      {
        matches = [
          {
            at-startup = true;
            app-id = "Slack";
          }
          {
            at-startup = true;
            app-id = "slack";
          }
        ];
        open-on-workspace = "5";
        open-maximized = true;
      }
    ];

    binds = {
      "XF86MonBrightnessUp" = {
        allow-when-locked = true;
        action = spawn "brightnessctl" "set" "5%+";
      };
      "XF86MonBrightnessDown" = {
        allow-when-locked = true;
        action = spawn "brightnessctl" "set" "5%-";
      };
      "XF86AudioRaiseVolume" = {
        allow-when-locked = true;
        action = spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "5%+";
      };
      "XF86AudioLowerVolume" = {
        allow-when-locked = true;
        action = spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "5%-";
      };
      "XF86AudioMute" = {
        allow-when-locked = true;
        action = spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SINK@" "toggle";
      };
      "XF86AudioMicMute" = {
        allow-when-locked = true;
        action = spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle";
      };
    };
  };
}

Secrets

Helper to reduce boilerplate for SSH key pairs managed by sops. Each call to mkSshKeyPair declares both the private and public key secrets with correct ownership and permissions.

sops.secrets = let
  mkSshKeyPair = name: localName: {
    "ssh/${name}/private" = {
      owner = "${user.username}";
      mode = "600";
      path = "/home/${user.username}/.ssh/${localName}";
    };
    "ssh/${name}/public" = {
      owner = "${user.username}";
      mode = "644";
      path = "/home/${user.username}/.ssh/${localName}.pub";
    };
  };
in
  (mkSshKeyPair "smallbox" "id_ed25519")
  // (mkSshKeyPair "wavefunk" "wavefunk")
  // (mkSshKeyPair "wavefunk_dev" "wavefunk_dev")
  // {
    wg_private_key = { # wireguard private key
      mode = "600";
      path = "/home/${user.username}/.ssh/wg_private_key";
    };
    wg_preshared_key = { # wireguard preshared key
      mode = "600";
      path = "/home/${user.username}/.ssh/wg_preshared_key";
    };
    wg_peer_public_key = {};   # wireguard VPN server public key
    vpn_endpoint = {};          # wireguard VPN server endpoint
    homelab_domain = {};        # homelab base domain
    wg_wavefunk_private_key = {
      mode = "600";
      path = "/home/${user.username}/.ssh/wg_wavefunk_private_key";
    };
  };

sops.templates."ssh-hosts" = {
  owner = "${user.username}";
  content = ''
    Host homelab
      HostName ${config.sops.placeholder.homelab_domain}
      User root
      IdentityFile ${config.sops.secrets."ssh/smallbox/private".path}
  '';
};

programs._1password.enable = true;
programs._1password-gui = {
  enable = true;
  polkitPolicyOwners = [ "${user.username}" ];
};

Homelab

System Level

{ config, lib, pkgs, user, ... }:

{
  imports =
    [
      ./hardware-configuration.nix
      ./disko-config.nix
      ./sops.nix
      ./security.nix
      ./networking.nix
      ./caddy.nix
      ./matrix.nix
    ];

  boot.loader.grub = {
    efiSupport = true;
    efiInstallAsRemovable = true;
  };

  services.openssh.enable = true;

  users.users.root.openssh.authorizedKeys.keys = [ user.sshPublicKey ];

  users.users.nambiar = {
    isNormalUser = true;
    description = "Sandeep Nambiar";
    extraGroups = [
      "wheel"          # can do sudo
    ];
  };

  environment.systemPackages = map lib.lowPrio [
    pkgs.curl
    pkgs.gitMinimal
  ];

  system.stateVersion = "26.05";
}

Homelab Hardware

{ config, lib, pkgs, modulesPath, ... }:

{
  imports = [
    (modulesPath + "/installer/scan/not-detected.nix")
    (modulesPath + "/profiles/qemu-guest.nix")
  ];

  networking.useDHCP = lib.mkDefault true;
  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

Homelab Security

{ config, pkgs, ... }:

{
  services.fail2ban = {
    enable = true;
    # Ban IPs for 1 hour after 5 failures within 10 minutes
    ignoreIP = [ "172.30.0.0/8" "192.168.0.0/16" ]; # Whitelist your local subnet
    bantime = "1h";
    bantime-increment.enable = true; # Increase ban time for repeat offenders

    jails = {
      sshd.settings = {
        maxretry = 3;
        findtime = "10m";
      };
    };
  };

  services.openssh.settings = {
    PasswordAuthentication = false;
    KbdInteractiveAuthentication = false; # Disables challenge-response
    PermitRootLogin = "prohibit-password"; # Allow root only via key
  };
}

Homelab Sops

{ config, pkgs, ... }:

{
  sops = {
    defaultSopsFile = ../../secrets/secrets.yaml;
    age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
  };

  sops.secrets.wg_homelab_key = { # wireguard private key
    mode = "600";
    path = "/root/.ssh/wg_private_key";
  };

  sops.secrets.wg_homelab_preshared_key = { # wireguard preshared key
    mode = "600";
    path = "/root/.ssh/wg_preshared_key";
  };

  sops.secrets.wg_peer_public_key = {};       # wireguard VPN server public key
  sops.secrets.homelab_peer_endpoint = {};     # wireguard peer endpoint for homelab
  sops.secrets.homelab_domain = {};            # homelab base domain
}

Networking

{ config, ... }:
let
  wg_port = 51821;
  <<wireguard-config>>
in
{
  networking.wg-quick.interfaces.wg0 = mkWireguard {
    address = [ "2a01:4f9:c014:e0de:ac1e::3/128" "172.30.0.4/32" ];
    privateKeyFile = config.sops.secrets.wg_homelab_key.path;
    peerPublicKeyFile = config.sops.secrets.wg_peer_public_key.path;
    presharedKeyFile = config.sops.secrets.wg_homelab_preshared_key.path;
    endpointFile = config.sops.secrets.homelab_peer_endpoint.path;
    allowedIPs = [ "172.30.0.1/32" "172.30.0.2/32" "172.30.0.3/32" ];
    listenPort = wg_port;
  };

  boot.kernel.sysctl."net.ipv4.ip_forward" = true;
  networking.firewall.allowedUDPPorts = [ wg_port ];
}

Caddy

Caddy configuration uses sops.templates to generate the Caddyfile at activation time, keeping domains out of the public repo.

{ config, pkgs, ... } :
let
  domain = config.sops.placeholder.homelab_domain;
in
{
  sops.secrets.cloudflare_token = {};

  sops.templates."caddy.env".content = ''
    CF_TOKEN=${config.sops.placeholder.cloudflare_token}
  '';

  sops.templates."Caddyfile" = {
    owner = "caddy";
    group = "caddy";
    content = ''
      {
        email admin@${domain}
      }

      (hardening) {
        header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        header Referrer-Policy same-origin
        header X-Content-Type-Options nosniff
      }

      (csp) {
        header Content-Security-Policy "default-src 'self'; img-src 'self' https://*.${domain}/; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-ancestors 'self'; frame-src 'self'; form-action 'self'; media-src 'self' https://*.${domain}/; sandbox allow-downloads allow-forms allow-same-origin allow-scripts"
      }

      *.${domain} {
        tls {
          dns cloudflare {$CF_TOKEN}
        }
      }

      health.${domain} {
        respond "Hi!"
      }

      <<caddy-matrix-host>>
    '';
  };

  services.caddy = {
    enable = true;

    package = pkgs.caddy.withPlugins {
      plugins = [ "github.com/mholt/caddy-ratelimit@v0.1.0" "github.com/caddy-dns/cloudflare@v0.2.2" ];
      hash = "sha256-SdgeHilPgV9dNDnjuz8ULQ1CexbyToSbZONvJkrzUWg=";
    };

    environmentFile = "${config.sops.templates."caddy.env".path}";
    configFile = config.sops.templates."Caddyfile".path;
  };

  networking.firewall.allowedTCPPorts = [ 443 80 ];
}

Matrix

The matrix domain is derived from the homelab domain sops secret. The synapse server_name and public_baseurl are injected via a sops template extra config file since NixOS module options are evaluated at build time.

The caddy reverse proxy block is a noweb reference (caddy-matrix-host) included in the Caddyfile sops template in the caddy section above.

chat.${domain} {
  reverse_proxy /_matrix/* localhost:8008
  reverse_proxy /_synapse/client/* localhost:8008

  reverse_proxy localhost:8008

  respond /.well-known/matrix/server "{\"m.server\": \"chat.${domain}:443\"}" 200

  respond /.well-known/matrix/client "{\"m.homeserver\": {\"base_url\": \"chat.${domain}\"}, \"m.identity_server\": {\"base_url\": \"https://vector.im\"}}" 200
}
{ config, pkgs, ... }:
let
  domain = config.sops.placeholder.homelab_domain;
  chatDomain = "chat.${domain}";
in
{
  services.postgresql.enable = true;
  services.postgresql.initialScript = pkgs.writeText "synapse-init.sql" ''
    CREATE DATABASE "matrix-synapse" WITH OWNER "matrix-synapse"
      TEMPLATE template0
      LC_COLLATE = "C"
      LC_CTYPE = "C";
  '';

  sops.secrets.matrix_shared_secret = {
    owner = config.systemd.services.matrix-synapse.serviceConfig.User;
    mode = "644";
  };

  sops.templates."synapse-domain.yaml" = {
    owner = config.systemd.services.matrix-synapse.serviceConfig.User;
    content = ''
      server_name: "${chatDomain}"
      public_baseurl: "https://${chatDomain}"
    '';
  };

  services.matrix-synapse = {
    enable = true;
    settings = {
      listeners = [{
        port = 8008;
        bind_addresses = [ "0.0.0.0" ];
        type = "http";
        tls = false;
        x_forwarded = true;
        resources = [{ names = [ "client" "federation" ]; compress = false; }];
      }];

      database = {
        name = "psycopg2";
        args = {
          user = "matrix-synapse";
          database = "matrix-synapse";
          host = "/run/postgresql";
        };
      };

      experimental_features = {
        msc3202_device_masquerading = true;
        msc3202_transaction_extensions = true;
        msc2409_to_device_messages_enabled = true;
      };
    };

    extraConfigFiles = [
      "/run/secrets/matrix_shared_secret"
      config.sops.templates."synapse-domain.yaml".path
    ];

    enableRegistrationScript = true;
  };
}

Homelab Disks

{
  disko.devices = {
    disk = {
      main = {
        type = "disk";
        device = "/dev/sda";
        content = {
          type = "gpt";
          partitions = {
            boot = {
              size = "1M";
              type = "EF02";
              priority = 1;
            };
            ESP = {
              size = "512M";
              type = "EF00";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
              };
            };
            root = {
              size = "100%";
              content = {
                type = "filesystem";
                format = "ext4";
                mountpoint = "/";
              };
            };
          };
        };
      };
    };
  };
}

Homelab Home

{ pkgs, user, ...} : {
  home.username = "${user.username}";
  home.homeDirectory = pkgs.lib.mkDefault "/home/${user.username}";
  home.stateVersion = user.stateVersion;

  programs.home-manager.enable =  true;
}

Homelab deploy-rs module

deploy.nodes.homelab = {
  hostname = "homelab";
  profiles.system = {
    user = "root";
    sshUser = "root";
    path = deployPkgs.deploy-rs.lib.activate.nixos self.nixosConfigurations.homelab;
  };
};

Extras

# AGENTS.md

This file provides guidance to AI Agents when working with code in this repository.

## Critical Rule

**Never read or edit `.nix` files directly.** All `.nix` files are generated from `README.org` via org-babel-tangle. `README.org` is the single source of truth — every change must be made there. Edits to `.nix` files will be silently overwritten on next tangle.

## Commands

```bash
# Tangle README.org into nix files (the CI workflow runs a more complete version with a startup block)
emacs README.org --batch -f org-babel-tangle

# Deploy to local machine
nixos-rebuild switch --flake .#smallbox

# Deploy to the remote machine
nix run github:serokell/deploy-rs -- .#homelab

# Edit encrypted secrets
sops secrets/secrets.yaml

# Update flake inputs
nix flake update

# Format nix code
nixfmt <file>

# Enter dev shell (provides nil, nixfmt, sops)
nix develop
# or: direnv is configured via .envrc
```

There are no tests or linters — validation happens at `nixos-rebuild` time.

## Architecture

This is a literate NixOS configuration. `README.org` contains org-mode source blocks that tangle into all `.nix` files. Noweb references (``) compose fragments across blocks — understand how they assemble before changing structure.

### Machines

- **smallbox** — primary workstation (x86_64-linux, NixOS, deployed locally)
- **homelab** — home server (NixOS, deployed via deploy-rs)

### Flake structure

`flake.nix` dynamically generates `nixosConfigurations` by mapping over a machines list. Each machine imports `machines/<name>/configuration.nix` and `machines/<name>/home.nix`. Shared modules live in `configuration/` (system-level) and `home/` (home-manager).

Key inputs: nixpkgs-unstable, home-manager, emacs-overlay, sops-nix, catppuccin, deploy-rs, disko.

### Secrets

Managed with sops-nix using age encryption. Key defined in `.sops.yaml`. Edit with `sops secrets/secrets.yaml`.

### CI

GitHub Actions workflow (`.github/workflows/tangle.yml`) auto-tangles on push to `README.org` and commits the generated files.

README Utils

Headers

This script adds a DO NOT MODIFY header to all the generated nix files.

(progn
  (defun add-tangle-headers ()
    (message "running in %s" (buffer-file-name))
    (when (string= (file-name-extension (buffer-file-name)) "nix")
      (delete-trailing-whitespace)
      (goto-char (point-min))
      (insert "# WARNING : This file was generated by README.org\n# DO NOT MODIFY THIS FILE!\n# Any changes made here will be overwritten.\n")
      (save-buffer))
    (save-buffer))
  (add-hook 'org-babel-post-tangle-hook 'add-tangle-headers))