How to use flakes as a sysadmin

2 month ago I posted a question on reddit about NixOS VMs / Container management in an attempt to use NixOS and it’s various tools to automate the deployment of my homelab. I (naively) thought that i could just replace Terraform / Ansible with NixOS flakes and deploy NixOS LXC container into my proxmox automatically with a CI pipeline whenever i push changes to my nix config, using deploy.rs.

One of the comments on my other post mentionned (microvm.nix)[https://github.com/astro/microvm.nix\], which is a tool to build NixOS VMs and run them on various hypervisor, notably QEMU / KVM. I tried it extensibly for a month now and i’m very happy with the result. It can build very small VMs, with a small footprint which why i used LXC in the first place so it’s a great replacement. So, if my experiments continues to be successful, i now plan to migrate my whole homelab to MicroVMs and write a Flake for each VM i want to deploy.

For the past 2 month i read a lot of documentation and articles about NixOS philosophy, Flakes and it’s usage… All of it was obscure to me and is now much more clear.

I now have :

  • A nixos-minimal-config flake, which contains a single module nixosModules.minimalConfig, which is a minimal configuration containing my user, SSH keys, locales, base packages… It is the equivalent of a cloud-init config.
  • A microvm-<SERVICE> flake, which output is divided in 3 parts :
    • A call to minimal-config.nixosModules.minimalConfig, which install my base config (the input is the git repo on which the flake is pushed)
    • A configuration part, which enable the desired service and configure it as in a configuration.nix file
    • A call to microvm.nixosModules.microvm, which configure my VM (CPU, memory, disk size, network interfaces…)

Basically it looks like this :

nixos-minimal-config/flake.nix :

{
  description = "NixOS minimal config flake";

  inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; };

  outputs = { self, nixpkgs, ... }@inputs: {
    nixosModules.minimalConfig = { config, pkgs, modulePath, ... }: {
        nix = {
          settings.experimental-features = [ "nix-command" "flakes" ];
          settings.trusted-users = [ "@wheel" ];
        };

        networking = {
          firewall = {
            enable = true;
            allowedTCPPorts = [ 22 ];
          };
        };

        time.timeZone = "Europe/Paris";
        console.keyMap = "fr";
        i18n = { defaultLocale = "fr_FR.UTF-8"; };

        security.sudo.wheelNeedsPassword = false;
        users = {
          users.<USER> = {
            isNormalUser = true;
            description = "Prénom Nom";
            extraGroups = [ "wheel" ];
            shell = pkgs.zsh;
            openssh.authorizedKeys.keys = [
              "sshkey1"
              "sshkey-gitea-action-runner"
            ];
            initialPassword = "initialpassword";
          };
        };

        programs = {
          zsh = {
            enable = true;
            shellAliases = {
              ll = "ls -l";
              lla = "ls -lah";
            };
          };
          tmux = {
            enable = true;
          };
        };

        nixpkgs.config.allowUnfree = true;
        environment ={
          localBinInPath = true;
            systemPackages = with pkgs; [
              vim
              ...
            ];
          };

          services.openssh = {
            enable = true;
            settings.PasswordAuthentication = false;
            settings.KbdInteractiveAuthentication = false;
            settings.PermitRootLogin = "no";
          };

          system.stateVersion = "24.05";
        };
    };
}

microvm-gitea/flake.nix :

{
  description = "NixOS in MicroVMs";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    microvm = {
      url = "github:astro/microvm.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    minimal-config.url = "git+https://git.repo/nixos_minimal_configuration";
  };

  outputs = { self, nixpkgs, microvm, minimal-config, ... }:
    let
      system = "x86_64-linux";
    in {
      packages.${system} = {
        default = self.packages.${system}.my-microvm;
        my-microvm = self.nixosConfigurations.my-microvm.config.microvm.declaredRunner;
      };

      nixosConfigurations = {
        my-microvm = nixpkgs.lib.nixosSystem {
          inherit system;
          modules = [
            minimal-config.nixosModules.minimalConfig

            ({ config, pkgs, ... }:
            {
              networking.hostName = "gitea";
              services.gitea = {
                enable = true;
                user = "<USER>";
                settings = {
                  server.HTTP_PORT = 3000;
                  server.ROOT_URL = "http://proxy.address/";
                  service.DISABLE_REGISTRATION= true;
                };
                database = {
                  createDatabase = false;
                  type = "postgres";
                  host = "db.address";
                  user = "gitea";
                  passwordFile = "/run/secrets/gitea/gitea-dbpass";
                };
              };
              networking.firewall.allowedTCPPorts = [ 3000 ];
            })

            microvm.nixosModules.microvm
            {
              microvm = {
                volumes = [ {
                  mountPoint = "/";
                  image = "/var/lib/microvms/my-microvm/root.img";
                  size = 8192;
                } ];
                shares = [
                  {
                    proto = "virtiofs";
                    tag = "ro-store";
                    source = "/nix/store";
                    mountPoint = "/nix/.ro-store";
                  }
                  {
                    proto = "virtiofs";
                    tag = "gitea-env";
                    source = "/home/tbarnouin/microvm_minimal_config/env/";
                    mountPoint = "/run/secrets/gitea";
                  }
                ];
                interfaces = [ {
                  type = "tap";
                  id = "vm-test1";
                  mac = "02:00:00:00:00:01";
                } ];

                hypervisor = "qemu";
                socket = "control.socket";
              };
              systemd.network.enable = true;

              systemd.network.networks."20-lan" = {
                matchConfig.Type = "ether";
                networkConfig = {
                  Address = [""];
                  Gateway = "";
                  DNS = [""];
                  IPv6AcceptRA = true;
                  DHCP = "no";
                };
              };
            }
          ];
        };
      };
    };
}

I wonder if it is the “right way” to use flakes ? Maybe my base config should be a single configuration file i can copy on every new flakes i write ? Or maybe every flake i write for a service should be an independent module, and i should juste call those 3 module onto a fourth flake gluing everything together ? Doing so, i think i reproduce some ansible mechanism i’m used to, and maybe it is not the NixOS way of doing things.

Also, i would like to template those flake so i can input variables on my services’ flake and pass them to the minimal_config one. That way i can pass a different username, add an SSH key or anything else. I don’t know any way to do this ? I also don’t if this is (again) a automatism i have from ansible and if there is another NixOS way to achieve it.

3 Likes

There is no real right or wrong answer here.
As in any programming it is a good thing that you split out common configuration.
It doesn’t really matter if the configuration lives inside the same repository or if it is an independent flake.
I personally have everything in one big repository for all my servers and notebooks.
However it wouldn’t be wrong to split it into multiple once.
E.g. one for custom packages, one for modules and one for system configurations.

What I personally do is write minimal modules that I then activate on the systems that need them.
Mostly they are just a collection of various predefined options configured to my liking.

E.g. the one here is my configuration for a Gitea service:

And then it get’s used here:

As you can see it has a parameter for the domain so that I can change that easily.

2 Likes

Nebucatnetzer is right, you should apply what better fits your needs.

Part of your questions is Don’t Repeat Yourself vs (Wrong Abstractions or PrematureGeneralization).

But if you’re sure to have a good abstraction, learning how to define your own options and overriding options could be handy.

Here is my personal opinion and everything subjective to my requirements:

You mention reddit, there is also another topic there, I like: The Dere Types. I’m fan of Dandere, maybe because I have only a single of system type. I’m also Java developer, so config separated by dots ({a.b.c=1; a.b.d=2;}) looks more like java properties for me than separated by blocks ({a={b={c=1; d=2;};};};}).

Let me check your code looks like with this style:

{
  description = "NixOS minimal config flake";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

  outputs = { self, nixpkgs, ... }@inputs: {
    nixosModules.minimalConfig = { config, pkgs, modulePath, ... }: {
      nix.settings.experimental-features = [ "nix-command" "flakes" ];
      nix.settings.trusted-users = [ "@wheel" ];

      networking.firewall.enable = true;
      networking.firewall.allowedTCPPorts = [ 22 ];

      time.timeZone = "Europe/Paris";
      console.keyMap = "fr";
      i18n.defaultLocale = "fr_FR.UTF-8";

      security.sudo.wheelNeedsPassword = false;
      users.users.<USER>.isNormalUser = true;
      users.users.<USER>.description = "Prénom Nom";
      users.users.<USER>.extraGroups = [ "wheel" ];
      users.users.<USER>.shell = pkgs.zsh;
      users.users.<USER>.initialPassword = "initialpassword";
      users.users.<USER>.openssh.authorizedKeys.keys = [
         "sshkey1"
         "sshkey-gitea-action-runner"
      ];

      programs.tmux.enable = true;
      programs.zsh.enable = true;
      programs.zsh.shellAliases.ll = "ls -l";
      programs.zsh.shellAliases.lla = "ls -lah";

      nixpkgs.config.allowUnfree = true;
      environment.localBinInPath = true;
      environment.systemPackages = with pkgs; [
         vim
         ...
      ];

      services.openssh.enable = true;
      services.openssh.settings.PasswordAuthentication = false;
      services.openssh.settings.KbdInteractiveAuthentication = false;
      services.openssh.settings.PermitRootLogin = "no";

      system.stateVersion = "24.05";
    };
  };
}

Another subjective point is, don’t embed modules in flakes, import them. Flakes are complex, mixing modules make them harder to read. Funny enough, I follow this recommendation by not following this recommendation, as you can see here. I’m saying that because:

outputs = {self, nixpkgs, ...}@inputs: { 
  nixosModules.m = { config, pkgs, lib, ... }: { config.imports = [ ./m/default.nix ]; };
  # ^- output -^   ^------------ module as function ------------------------------------^

  # Split the args aren't an obligation
  nixosModules.m = args: { config.imports = [ ./m/default.nix ]; };

  # Modules doesn't need to be a function if I don't need the args
  nixosModules.m = { config.imports = [ ./m/default.nix ]; };
  # ^- output -^   ^------------ module as attr -----------^

  # directory path always import default.nix
  nixosModules.m = { config.imports = [ ./m ]; };

  # `config.` is only required if I have an `options.` in the same module.
  nixosModules.m = { imports = [ ./m ]; };

  # I can use the dot if its an object
  nixosModules.m.imports = [ ./m ];
  # ^- output -^^---- module ----^

  # if it was a single file could be even simpler
  nixosModules.m = ./m;
  # ^- output -^   ^-- module as path

  # The nixos will accept it like this [
  nixosConfiguration.m = nixpkgs.lib.nixosSystem {
    modules = [ self.nixosModules.m ];  # or modules = [ nixosModules.m ] if we add rec
    # ...
  };

  # I would not suggest split files if your config was simple.
  nixosModules.n.services.gitea.enable = true;
  nixosConfiguration.n = nixpkgs.lib.nixosSystem {
    modules = [ self.nixosModules.n ];
    # ...
  };
};

Maybe this last point, is the reason you are using multi-repo instead of mono-repo. If you ask the nix way about “multi x mono”, nixpkgs would say mono, but flakes is designed mostly to make multi easier, although is more like you could than you should. Most CI can run different deploys based on files or directories that changed if it is a concern.

Thanks a lot for your answer, i like the way you’re managing all of the modules and different configs. It’s way cleaner than what i was trying to do.

I think i’ll focus on a more mono-repo paradigm, mostly because everything i’m doing is super simple (importing different config bricks into a unified flake). For know i’m thinking a single nixos-microvms repo, with a subfolder containing every module i wrote (minimal-config and every services) and a flake.nix declaring my microvms config and importing the required module for each VMs.

I try to keep it simple, functional programming is still foreign for me so I prefer to keep things so that I understand it and only slowly add new functionality.
I could add code to import all the modules automatically but I prefer to do it manually at the moment.

There is some code that helps with making a computer or a Raspi under ./lib but that isn’t originally from me It comes from this playlist especially the last two videos: https://www.youtube.com/watch?v=QKoQ1gKJY5A&list=PL-saUBvIJzOkjAw_vOac75v-x6EzNzZq-.

In addition I recently introduced a bit of code that loops over an attributes set to make the definition of my systems easier.

However I used a manual approach for years before that.

1 Like