Infinite recursion, option not found errors attempting to use the declarative-flatpak module (non-flake)

So I’m trying to use the declarative-flatpak module to declare flatpak packages in my configuration.nix, and am running into the issues mentioned in the tile (infinite recursion, option not found), seemingly no matter what I do. I’ve been trying to troubleshoot this on and off for days, and have not made any progress, so any help would be much appreciated.

I’m importing the module with npins as instructed in the readme, with the following command:

$ npins add github --name flatpaks in-a-dil-emma declarative-flatpak


Below is a minimized version of my configuration.nix:

{ config, pkgs, flatpaks, ... }:

{

    imports = [
        # Declarative flatpak - version tracked with npins
        # (flatpaks + "/nixos")
        # ^ This errors as infrec

        # Include the results of the hardware scan.
        ./hardware-configuration.nix

        # npins directory, sources.json below
        ./npins

    ];

    modules = [
        # (flatpaks + "/nixos")
    ];

    # ^v Both of these error with option flatpaks does not exist

    # specialArgs =  { inherit flatpaks; };

    ## Flatpak declarative
    services.flatpak = {
        remotes = {
            "flathub" = "https://dl.flathub.org/repo/flathub.flatpakrepo";
        };
        packageDir = ~/.var/app
        packages = [
            "flathub:app/io.gitlab.adhami3310.Converter//stable"
        ];
    };

    system.stateVersion = "25.05";
}


npins sources.json:

{
  "pins": {
    "flatpaks": {
      "type": "GitRelease",
      "repository": {
        "type": "GitHub",
        "owner": "in-a-dil-emma",
        "repo": "declarative-flatpak"
      },
      "pre_releases": false,
      "version_upper_bound": null,
      "release_prefix": null,
      "submodules": false,
      "version": "v4.1.1",
      "revision": "5cc75706e08549f516f0b65ac1e0b10adfff8c1c",
      "url": "https://api.github.com/repos/in-a-dil-emma/declarative-flatpak/tarball/v4.1.1",
      "hash": "1wqnd3m0257v0l60pm5nbpvx9fy6rzwz3cvfn04sqf9wyagdpbpa"
    },
    "nixpkgs": {
      "type": "Channel",
      "name": "nixpkgs-unstable",
      "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre901289.ee09932cedce/nixexprs.tar.xz",
      "hash": "0fhp30vs6cavr1547j0kzr46p7lsm1ylxj2qqp87a3jz6n542mhb"
    }
  },
  "version": 5
}


Errors:
(I can provide the full stack traces if necessary)

When module is imported through imports:

       error: infinite recursion encountered
       at /nix/store/abqqznkscq7d2b1y49x4al0pzc7xif6f-nixos-25.05/nixos/lib/modules.nix:652:66:
          651|       extraArgs = mapAttrs (
          652|         name: _: addErrorContext (context name) (args.${name} or config._module.args.${name})
             |                                                                  ^
          653|       ) (functionArgs f);


When module is imported through modules or with specialArgs:

       error: The option `flatpaks' does not exist. Definition values:
       - In `/etc/nixos/npins':
           {
             hash = "1wqnd3m0257v0l60pm5nbpvx9fy6rzwz3cvfn04sqf9wyagdpbpa";
             outPath = "/nix/store/h0zzqvgx5lk1jlnhqn6nfpkrggpm70y7-source";
             pre_releases = false;
             release_prefix = null;
           ...


Thank you for the help in advance!

Did you set that up with _module.args? That explicitly leads to infrec, specialArgs exists precisely to dodge this problem.

Share the rest of your code if you can’t figure it out; the issue isn’t in this file.

I’m not entirely sure what you mean, but I don’t recall having touched that before. Is there a step I’m missing that’s necessary to use external modules? What I’ve done is run the npins command and add flatpaks to the attribute set, as well as try to import the module in the various ways described.

I did try to use specialArgs to import the module, and it threw the same error as when importing in the modules set, though maybe I’m using it wrong? I did

specialArgs = { inherit flatpaks; };

I have my configuration split up over a few files, imported in the imports set:

# System config
{ config, pkgs, ... }:

{

    imports = [
        # Include the results of the hardware scan.
        ./../hardware-configuration.nix
    ];

    # Bootloader.
    boot.loader.systemd-boot.enable = true;
    boot.loader.efi.canTouchEfiVariables = true;

    # Use latest kernel.
    boot.kernelPackages = pkgs.linuxPackages_latest;

    networking.hostName = "nixos"; # Define your hostname.
    # networking.wireless.enable = true;  # Enables wireless support via wpa_supplicant.

    # Enable networking
    networking.networkmanager.enable = true;

    # Set your time zone.
    time.timeZone = "time/zone"

    # Select internationalisation properties.
    i18n.defaultLocale = "en_US.UTF-8";

    i18n.extraLocaleSettings = {
        LC_ADDRESS = "en_US.UTF-8";
        LC_IDENTIFICATION = "en_US.UTF-8";
        LC_MEASUREMENT = "en_US.UTF-8";
        LC_MONETARY = "en_US.UTF-8";
        LC_NAME = "en_US.UTF-8";
        LC_NUMERIC = "en_US.UTF-8";
        LC_PAPER = "en_US.UTF-8";
        LC_TELEPHONE = "en_US.UTF-8";
        LC_TIME = "en_US.UTF-8";
    };

    # Enable the X11 windowing system.
    services.xserver.enable = true;

    # Enable the GNOME Desktop Environment.
    services.xserver.displayManager.gdm.enable = true;
    services.xserver.desktopManager.gnome.enable = true;

    # Configure keymap in X11
    services.xserver.xkb = {
        layout = "us";
        variant = "";
    };

    # Enable CUPS to print documents.
    services.printing.enable = true;

    # Enable sound with pipewire.
    services.pulseaudio.enable = false;
    security.rtkit.enable = true;
    services.pipewire = {
        enable = true;
        alsa.enable = true;
        alsa.support32Bit = true;
        pulse.enable = true;
        # If you want to use JACK applications, uncomment this
        #jack.enable = true;
    };
}
# Additional configurations

{ config, pkgs, ... }:

{
    imports = [
        ./../hardware-configuration.nix
    ];

    # Define user account
    users.users.me = {
        isNormalUser = true;
        description = "name";
        extraGroups = [ "networkmanager" "wheel" ];
    };

    # Enable newstyle commands and flakes for experimentation
    nix.settings.experimental-features = [ "nix-command" "flakes" ];

    # Extra services:
    services.fprintd.enable = true;

    # Configure git
    programs.git.config = {
        user.name = "user";
        user.email = "email@example.com";
    };

    #Add a swap file, to prevent crashes
    swapDevices = [ {
        device = "/var/lib/swapfile";
        size = 8*1024;
    } ];

    # Automatic cleanup weekly (equiv. to nix-collect-garbage --delete-older-than 30d)
    nix.gc = {
        automatic = true;
        dates = "weekly";
        options = "--delete-older-than 30d";
    };

    # Automatic update weekly
    system.autoUpgrade = {
        enable = true;
        dates = "weekly";
        channel = "https://channels.nixos.org/nixos-25.05";
        allowReboot = true;
        # See logs with journalctl -e -u nixos-upgrade.service
        flags = [ "-L" ];
        rebootWindow = {
            lower = "01:00";
            upper = "05:00";
        };
    };

    system.stateVersion = "25.05";
}

Ah, right, I see. The npins/default.nix is not a NixOS module. Get rid of the flatpaks in your module arguments, remove that npins line, and instead do something like:

{ config, pkgs, ... }:
let
  sources = import ./npins;
in {
  imports = [
    "${sources.flatpaks}/nixos"
  ];
}

You might also want to look at how you’re getting nixpkgs; your current config doesn’t use the one from your npins.

Will try this and get back to you momentarily

Yes! No errors, thank you very much.
If I may, I’m curious about how this works. What I think is happening is we’re pulling in the ./npins directory as it’s own option (probably not the right word?), then sourcing the flatpaks module through that option, rather than directly. Since the npins/default.nix doesn’t act as a module, if it’s imported as a module, does nix try to evaluate it in a way that produces the breakage?
Now I’m confusing myself. I would be grateful if you could try to explain, for the benefit of my curiosity.

Yeah, I was wondering about that. I believe currently I’m using nix-channel for the stable branch and this snippet in my packages sub-configuration to access a few things on unstable:

# Import the unstable branch to use for some packages
let
    unstable = import
    (builtins.fetchTarball https://github.com/nixos/nixpkgs/tarball/nixpkgs-unstable)
    # reuse the current configuration
    { config = config.nixpkgs.config; };
in

Would this be easier/more streamlined to do through npins?

I tried writing up a full explanation, but you’re basically asking “how does NixOS work” :wink: It’s far too much for a discourse post.

Have a read through the nix language basics and module system sections of nix.dev. You’ll need to understand the difference between things nix does (variables and import) and what NixOS does (modules, options and attributes like imports).

Your wording is indeed off - ./npins isn’t an “option” or anything, it’s simply an element of the imports list. NixOS will attempt to treat anything you put in the imports list as a module (implicitly calling import on it if it’s a file/directory - and if it’s a directory that will implicitly import the default.nix file in the directory instead), and merge it into your configuration.

Kinda. Nix evaluates it just fine, and it produces a normal attrset. The function call evaluates to something like this (in fact, it should be exactly this, since I used the values from your sources.json):

{
  flatpaks = builtins.fetchTarball {
    url = "https://api.github.com/repos/in-a-dil-emma/declarative-flatpak/tarball/v4.1.1";
    sha256 = "1wqnd3m0257v0l60pm5nbpvx9fy6rzwz3cvfn04sqf9wyagdpbpa";
  };

  nixpkgs = builtins.fetchTarball {
    url = "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre901289.ee09932cedce/nixexprs.tar.xz";
    sha256 = "0fhp30vs6cavr1547j0kzr46p7lsm1ylxj2qqp87a3jz6n542mhb";
  };
}

Since attrsets are perfectly valid “modules” (see the modules tutorial for a more thorough explanation), NixOS will attempt to incorporate that into your configuration as if you intended to set options to these values. It will then give you an error, because there is no flatpaks option. I.e., you get this result:

There is in fact a nixpkgs option, so you don’t get an error for that, but if you only had nixpkgs in your npins you’d get an error about it being the wrong type.


The infinite recursion on the other hand is a bit more complicated to explain. This happens because NixOS modules are evaluated through a Fixed-point combinator - Wikipedia - basically a function calling itself with its own arguments. The concept is a bit mind-bendy, and a feature of lazily-evaluated functional programming languages; it’s not too complex once you reason through it by hand once, though. I’ll leave learning how that works as an exercise to the reader.

What you need to understand, though, is that the function arguments given to the module are defined by the module itself through the _module.args option. If you simply write this:

{ undefinedVariable, ... }: {
  imports = [
    undefinedVariable
  ];
}

… even though undefinedVariable has no definition anywhere in the system, NixOS will attempt to evaluate the function with itself recursively, because to retrieve the value of undefinedVariable from the _module.args option it needs to evaluate every possible module imports, which includes the (completely undefined) variable itself.

… I did tell you it’s a bit complicated to explain.

But the gist is basically that your configuration could never have worked in the infrec case, you simply never defined a flatpaks argument for your module.

If you instead set specialArgs.flatpaks at the top-level eval somehow (which I’m not sure is possible with traditional nix), the recursion could have been cut short because the module system skips _module.args in that case. This is typically how flakes shove flake inputs into the module system - I’m sure you could build something similar for npins, but I haven’t seen an example of that.

See my evaluation of your npins/default.nix further up? Looks remarkably similar, doesn’t it? Basically, all npins does is just manage writing and updating that code for you.

I strongly recommend using npins over writing these stanzas by hand. This is for two reasons:

  1. If you litter these kinds of stanzas all over your code by hand you will never update them because it’s kind of a PITA and then you run insecure software.
  2. You - and pretty much everyone else who doesn’t know exactly what they’re doing - are currently doing it horribly wrong.

The gist is that the builtins.fetch* functions are special functions that just create store paths with the result of downloading the given url. Unlike pretty much every other way of interacting with nix, these outputs are not hashed by default.

This means that, currently, every hour nix will download a new version of that tarball, and it will constantly re-evaluate the hash and try to recreate store paths. You’ve thrown reproducibility out of the window, and your builds will be quite slow, only saved by the tarball cache.

To not commit this insanity, you have to give the builtins.fetch* functions an attrset argument - instead of just a URL (and in fact, you should not use non-string URLs, those are deprecated), and specify the hash attribute.

npins does this for you, so you cannot commit this - ridiculously common - mistake. They also make referring to and updating the channel release tarballs instead of raw GitHub artifacts feasible, so you don’t need to fetch tarballs from GitHub and get the command-not-found database, which is currently missing from your nixpkgs instance.

As a start, you could replace that bit of your code with:

{ config, pkgs, ... }:
let
  sources = import ./npins;
  unstable = import sources.nixpkgs { inherit (config.nixpkgs) config; };
in {
  imports = [
    "${sources.flatpaks}/nixos"
  ];

  # e.g.
  environment.systemPackages = [
    unstable.curl
  ];
}

… but that only works if you put all the unstable stuff in the same module in which you import your npins. You could pass unstable to _module.args so other modules can depend on it through their arguments:

# configuration.nix
{ config, pkgs, ... }:
let
  sources = import ./npins;
  unstable = import sources.nixpkgs { inherit (config.nixpkgs) config; };
in {
  imports = [
    "${sources.flatpaks}/nixos"

    ./curl.nix
  ];

  _module.args = { inherit unstable; };
}
# curl.nix
{ unstable, ... }: {
  environment.systemPackages = [ unstable.curl ];
}

… but now I’m going beyond basic configuration structure. And of course, now you know how the infrec thing happens, never refer to unstable in imports if you do that :wink:

I’d also softly suggest figuring out how to replace the nixpkgs you get from nix-channel with one from your npins. This would complete the reproducibility story, and make it so you only need to run one command to update (npins update) - assuming you also make your nixos channel refer to that tarball so your other nix commands continue to get updates. That said, I don’t actually know how to do that off the top of my head, so I don’t blame you for not having figured that out.

2 Likes

Haven’t tested but this should likely work:

  1. Add the channel to your pins:
    npins add channel nixos-25.11 --name nixpkgs
    
  2. Replace any uses of <nixpkgs> in your nixos config with sources.nixpkgs (i.e. nixpkgs from your pins - check your code).
  3. Set NIX_PATH:
    { config, pkgs, ... }:
    let
      sources = import ./npins;
    in {
      nix.nixPath = [
        "nixpkgs=${sources.nixpkgs}"
        "nixos-config=/etc/nixos/configuration.nix" # preserving the default nixos-config value
      ];
    }
    
  4. Rebuild.

At this point you could even disable the nix-channel command entirely (nix.channel.enable = false;)

And when you need to upgrade to the next stable nixpkgs version in 6 months, you can simply run step 1 again (no need to remove the old nixpkgs pin first.)

1 Like

I’m fairly sure the default channel name for nixos is nixos, so that should probably be nixos=, but it’s quite possible it also falls back to nixpkgs, since I’ve never seen issues from people renaming that.

Probably worth being correct here, though, since the outdated system nixos channel will continue to exist and might pop up in some edge cases.

The default value of nix.nixPath is

  [
    "nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixos"
    "nixos-config=/etc/nixos/configuration.nix"
    "/nix/var/nix/profiles/per-user/root/channels"
  ]

(when not using flakes for the nixos config)

The whole root nixos channel thing is coming from that first entry.
i.e. <nixpkgs> will point to the root’s nixos channel. It’s still an entry called nixpkgs, though, and all code would still use <nixpkgs> - which is also what nix would rely on by default in the absence of flakes.

(And we’re blasting the default anyway by setting nix.nixPath ourselves.)

1 Like

It’s also included as nixos via that bit, though; fair enough, now I finally have a full explanation for why they can be used interchangeably!

1 Like

Right, you could also set nixos as well in that option, or do some fancy mapping from all the sources attribute names to NIX_PATH entries.
nixos-config would also be worth preserving here.

I’m pretty sure it actually is, with your exact code, since lists are merged additively.

… I think ideally we’d just have npins support in nixos-rebuild, as well as in nixpkgs like what we have for flakes. Maybe best to have this discussion elsewhere :wink:

It’s not, because the default is priority 1500 vs manually setting it has priority 100.

EDIT: decided to just update my example to include it, for clarity.

1 Like

It would need two rebuilds until the new nixpkgs propagates because it gets passed as pkgs.

See: Pinning NixOS with npins, or how to kill channels forever without flakes - jade's www site

1 Like

Well, the explanation you provided has plenty enough that I’ll be chewing on it for a while, haha!
Seriously, though thank you for taking the time and providing those resources, I think I’ll be revisiting this post often as I attempt to understand nix better…

After spending a bit too long reading wikipedia, watching videos about lambda calculus, and playing with the examples from the nix.dev page you linked, I think I have a very vague understanding of this idea… This looks kinda cool, though, if obtuse, so I’m definitely going to investigate that more.

Ah, so that’s why it always seems to forget it had already downloaded the damn thing. Thanks for the code snippet, will definitely implement this!

As for getting the stable channel through npins, that does seem like a good idea. Thank you @waffle8946 for the example, and I will also check out the article you linked, @eblechschmidt.

Yeah, it really sucks that there are so many snippets of people doing this floating around the web. Even nix.dev suggests it.

It gives the impression that nix’ caching is brittle (or nonexistent) and that the builds aren’t reproducible at all. I’ve seen at least a handful newbies declare nix broken because of this (and they’re arguably right, it shouldn’t even be possible to write code that does this).

1 Like