What I Wish I Knew When Using NixOS

Table of Contents

1. Preface

NixOS is really unique in that you have one single configuration directory dictates the entire system. The directory would contain .nix files that imports each other. But there are some aspects that I could not understand easily, and were hard to search because I couldn't think of the keyword that could uniquely identify the issue I am having. One example is "What is the difference between installing programs with home.packages, environment.systemPackages, and programs.*?". For me, it looked like they did the exact same thing. That is why I recorded some "hard to search" issues I had when using NixOS, and write an article about it.

Note that this article assumes that the reader is somewhat familiar with NixOS configuration system. It handles some "how-to"s that I wanted to know, but couldn't until much later.

2. Configuration

2.1. What is the difference between installing programs with home.packages, environment.systemPackages, and programs.*?

The difference between these three are as follows:

  • home.packages: A way to download a binary in Home Manager as a user. The binary will not be available for other users.
  • environment.systemPackages: A way to download a binary for all users.
  • programs.*: Adds the binary to your system, either using environment.systemPackages or home.packages, and does some extra setup stuff that you would have done anyways

Here is an example from Wireshark:

 1: {
 2:   config,
 3:   lib,
 4:   pkgs,
 5:   ...
 6: }:
 7: 
 8: let
 9:   cfg = config.programs.wireshark;
10:   wireshark = cfg.package;
11: in
12: {
13:   options = {
14:     programs.wireshark = {
15:       enable = lib.mkOption {
16:         type = lib.types.bool;
17:         default = false;
18:         description = ''
19:           Whether to add Wireshark to the global environment and create a 'wireshark'
20:           group. To configure what users can capture, set the `dumpcap.enable` and
21:           `usbmon.enable` options. By default, users in the 'wireshark' group are
22:           allowed to capture network traffic but not USB traffic.
23:         '';
24:       };
25:       # ...  
26:     };
27:   };
28: 
29:   config = lib.mkIf cfg.enable {
30:     environment.systemPackages = [ wireshark ];
31:     users.groups.wireshark = { };
32:     # ...  
33:   };
34: }  

You can see the following:

  • An option programs.wireshark.enable is created at line 15, and its type is boolean
  • cfg is config.programs.wireshark (see line 9), meaning cfg.enable is config.programs.wireshark.enable
  • If cfg.enable is true, then wireshark is added to your config by the code at line 30

As a result, setting programs.wireshark.enable to true adds wireshark to your system, and does some extra work. For example, line 31 creates a new Linux group named wireshark.

My personal choice for installing a package is as follows:

  • Use programs.* whenever possible because it means less headache
  • If that doesn't exist, or don't do/allow what I want it to do, use home.package because most binaries do not need to be available to all users
  • If I frequently use a binary as superuser, install it using environment.systemPackages

2.2. How to add extra options for personal use?

You need to include this into your config. I use this code below to specify what directories need to be backed up via Restic. The option will be called custom.backups.backblaze, and its value will be a list of strings, each string representing the directory that needs to be backed up. Once this value is set, my restic config reads the value from it.

 1: { lib, ... }:
 2: {
 3:   # This creates an option named "custom". You can refer back to this with `config.custom`.
 4:   options.custom = lib.mkOption {
 5:     # This is where we define the type of this option. We are going to set it as submodule, which is basically a set (Things that look like { ... }).  
 6:     type = lib.types.submodule {
 7:       # In this { ... }, we are going to create an option within named "backups". Again, you can refer back to this with `config.custom.backups`.  
 8:       options.backups = lib.mkOption {
 9:         # Same thing as above  
10:         type = lib.types.submodule {
11:           # We create an option named "backblaze" within.
12:           options.backblaze = lib.mkOption {
13:             # The value of `custom.backups.backblaze` will be a list ([ ... ]) of strings.
14:             type = (lib.types.listOf lib.types.str);
15:           };
16:         };
17:       };
18:     };
19:     # This sets the default value of this particular option.  
20:     default = {
21:       backups.backblaze = [ ];
22:     };
23:   };
24: }

Once you have included this, you can set the value of this option with the following:

{ config, ... }: {  
  custom.backups.backblaze = [
    "${config.home.homeDirectory}/.podman/gitea"
  ];
}  

If you want to read back from this value to use it somewhere else, you can refer back to it by adding config. in front of it.

{ config, username, ... }: {  
  services.restic.backups = {
    backblaze = {
      # ...  
      paths =
        config.custom.backups.backblaze
        ++ config.home-manager.users."${username}".custom.backups.backblaze; # This is how you access your home-manager config
                                                                             # It takes in `username` as a parameter because home-manager config is user-specific  
    };
  };  
}  

There is also one thing you need to know: There are two allowed formats of config. The one below is what you are probably most familiar with, and therefore what you probably use:

{
  imports = [ <some imports> ];
  my-favorite-option = true;
}

This one is used less often, but is still valid:

{
  imports = [ <some imports> ];
  options = { <some custom options> };
  config = {
    my-favorite-option = true;
  };
}

If you want to create an option in the same file you are setting a bunch of config options, you much choose between these:

  • Separate options.* into another file, and import it via imports = [<here>];
  • Put option definition into options.*, and put the rest (except imports) into config = {}

Separating options.* into a separate would create config files that look like this:

# /server/packages/restic.nix  
imports = [
  ./restic/options.nix
];
users.users."${user}".isNormalUser = true;
# ...  
# /server/packages/restic/options.nix  
{ lib, ... }:
{
  options.custom = lib.mkOption {
    type = lib.types.submodule {
      options.backups = lib.mkOption {
        type = lib.types.submodule {
          options.backblaze = lib.mkOption {
            type = (lib.types.listOf lib.types.str);
          };
        };
      };
    };
    default = {
      backups.backblaze = [ ];
    };
  };
}

Not doing so will give you this error message:

This is caused by introducing a top-level `config' or `options' attribute.  

3. Flakes In Config

3.1. How do I add extra parameters to the imported modules?

specialArgs can be used to add extra arguments in your configuration.nix. This is only available in Flake config.

# flake.nix  
{  
  ...  
  outputs = { nixpkgs, ... } @ inputs : {  
    nixosConfigurations.main = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      specialArgs = {
        # If you alread have a variable named `hostname`, then you can replace the code below with `inherit hostname`.  
        hostname = "main-device";  
      };  
      modules = [ ./nix/configuration.nix ];
    };
  };  
}  

Then you can access it within configuration.nix with this code:

{ hostname, ... }:
{
  # This is equivalent to `networking.hostName = "main-device";`.  
  networking.hostName = hostname;
}  

3.2. What are the difference between adding new arguments using specialArgs and adding new options using options = { ... } in terms of making a certain value available everywhere?

If you read this and this, you are probably thinking that using specialArgs and using options = { ... } are somewhat similar. Once you create a custom option via options = { ... } and set a value, you can access them through config.*. They both allow you to set a certain value at a single point, and make it available everywhere. options = { ... } have a lot more complex syntax, and therefore it doesn't look very appealing.

The biggest difference is that values set with specialArgs is read-only, whereas options = { ... } is not. Let's say you are trying to solve this problem:

I want to create a command that echos all the Podman containers I have declared in my config.

You can technically do this with both. But you cannot make it modular with specialArgs. Whenever you add or remove a container, you would have to modify specialArgs at flake.nix. In other words, if you forget to change specialArgs, the list of containers you actually have may go out of sync. On the other hand, when using options = { ... }, you can enforce modularity. You can structure the config so that if you include a certain .nix file that contains a Podman configuration, the list of containers gets updated automatically.

First, I define custom.podman.containers. It will be a list of strings.

{ lib, ... }:
{
  options.custom = lib.mkOption {
    type = lib.types.submodule {
      options.podman = lib.mkOption {
        type = lib.types.submodule {
          options.containers = lib.mkOption {
            type = (lib.types.listOf lib.types.str);
          };
        };
      };
    };
  };
}

Then in the file that contains individual Podman configuration, you add this particular line, for every Podman configuration. Remember that if an list option is set multiple times, they are automatically merged. As a result, custom.podman.containers would contain a list of container names.

{ ... }:
{
  custom.podman.containers = [ "<Put container name here>" ];
}  

Finally, I read from this option to generate a short script.

{ config, pkgs, ... }:
let  
  podmanStatus = pkgs.writeShellScriptBin "podman-status" ''
    echo ${builtins.concatStringsSep " " config.custom.podman.containers}
  '';
in
{  
  home.packages = with pkgs; [
    podmanStatus
  ];
} 

I used builtins.concatStringsSep " " config.custom.podman.containers to create a single string from the list of container names, and then I simply echo that. I then add it as one of the packages for my system.

This is a lot cleaner because the code that inserts an element into the list of containers is contained with the container config itself. If you remove a container config file, the part that inserts the container into the list of containers is also removed. The opposite is also true.

Of course, the list of containers would go out of sync if you forget to add custom.podman.containers = [ "<Put container name here>" ];.

4. Troubleshooting

Created: 2025-10-29 Wed 13:02

Validate