My subflake does not build properly? or How to package monorepo submodules using Nix?

So it turns out that Nix doesn’t have clear project organization recommendations. Clear truths are that nix-build targets ./default.nix in a given directory and nix-shell targets ./shell.nix. The structure that nixpkgs provides is what most people base their configurations off of, but Nix itself is not largely opinionated.

So I figured out how to avoid subflakes by designing my own project structure.
I now know how to use a single flake.nix to package multiple submodules.
Hopefully this post will help others design their solution quicker.

Here is a link to the open source project.

Nix Flake Scripts

The following commands can be used in a shell with Nix installed and Flakes enabled.
These commands work from any subdirectory in the repository too.
Because Nix manages all dependencies, it is the only tool required to be installed manually.

Command Description
nix run alias for nix run .#start -- dev
nix run .#start start the app locally
nix run .#deploy deploy the app
nix run .#init initialize the app for deployment
nix develop start a shell with access to all dependencies
nix develop .#submodule start a shell with access to only dependencies for a specific submodule

Scripts are declared in flake.nix:

{
  description = "Project.";
  inputs = {
    # get new revision # using:           git ls-remote https://github.com/<repo_path> | grep HEAD
    # pin dependency to revision # like:  url = "github:numtide/flake-utils?rev=#"
    nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
    flake-utils.url = "github:numtide/flake-utils";
  };
  outputs = { self, nixpkgs, flake-utils, ... }: let
    name = "project";
    version = "0.0.0";
    utils = flake-utils;
  in utils.lib.eachDefaultSystem (
    system: let
      pkgs = import nixpkgs { inherit system; };
      submodule = import ./submodule { inherit pkgs; };
      # anotherSubmodule = import ./anotherSubmodule { inherit pkgs; };
    in rec {
      packages = {
        # help
        # init
        # deploy
        start = pkgs.callPackage ./scripts/start.nix {
          inherit name version;
          webServer = submodule;
          # database = anotherSubmodule;
        };
        default = packages.start;
      };
      apps = {
        # help
        # init
        # deploy
        start = utils.lib.mkApp { drv = packages.start; };
        default = utils.lib.mkApp { drv = packages.start.override { cliArgs = [ "dev" ]; }; };
      };
      devShells = {
        submodule = submodule.devShells.default;
        # anotherSubmodule = anotherSubmodule.devShells.default;
        root = pkgs.mkShell {
          packages = [
            # examples:
            # pkgs.podman
            # pkgs.gzip
            # pkgs.skopeo
          ];
        };
        default = pkgs.mkShell {
          inputsFrom = [
            devShells.root
            devShells.submodule
            # devShells.anotherSubmodule
          ];
        };
      };
    }
  );
}

Each script is defined in scripts/. For example, scripts/start.nix:

{
  pkgs,
  name,
  version,
  webServer,
  # database,
  cliArgs ? []
}: pkgs.writeShellApplication {
  name = "${name}-start-${version}";
  runtimeInputs = [
    webServer
  ];
  text = ''
    set -- "$@" ${pkgs.lib.strings.concatStringsSep " " cliArgs}
    ${pkgs.lib.getExe webServer} "$@"
  '';
}

Then each submodule can declare its own scripts in submodule/default.nix:

{
  pkgs ? import <nixpkgs> {}
}: let
  # I pull in name and version numbers from a Node.js `package.json`, but you could set those manually instead
  nodePackage = builtins.fromJSON (builtins.readFile ./package.json);
  name = nodePackage.name;
  version = nodePackage.version;
in rec {
  packages = {
    start = pkgs.callPackage ./scripts/start.nix {
      inherit pkgs name version;
    };
    default = packages.start;
  };
  devShells.default = import ./shell.nix { inherit pkgs; };
}

To be compatible with nix-shell, we also provide submodule/shell.nix:

{
  pkgs ? import <nixpkgs> {}
}: pkgs.mkShell {
  packages = [
    # examples:
    # pkgs.pnpm
    # pkgs.nodejs
    # pkgs.nix-prefetch-docker
  ];
}

And individual scripts are defined in submodule/scripts/. For example:

{
  pkgs,
  name,
  version,
}: let
  start = {
    main = pkgs.writeShellApplication {
      name = "${name}-start-main-${version}";
      runtimeInputs = [];
      text = ''
        echoerror() {
          echo "Error in start-main:" "$@" 1>&2;
        }
        interpret_args() {
          while [[ $# -gt 0 ]]; do
            case $1 in
              *)
                additional_args+=("$1")
                shift
              ;;
            esac
          done
          set -- "''${additional_args[@]}"
        }
        main() {
          interpret_args "$@"
          if [[ -n "''${script:-}" ]]; then
            "$script" "''${additional_args[@]}"
          else
            echoerror "No valid script identified among arguments:" "''${additional_args[@]}"
            exit 1
          fi
        }
        main "$@"
      '';
    };
    # TODO list other scripts here that main can call, then install them in installPhase
  };
in pkgs.stdenv.mkDerivation rec {
  pname = "${name}-start";
  inherit version;
  src = ./.;
  phases = [ "installPhase" ];
  buildInputs = [ start.main ];
  installPhase = ''
    mkdir -p $out/bin/
    cp ${pkgs.lib.getExe start.main} $out/bin/main.sh
    chmod +x $out/bin/main.sh
  '';
  meta.mainProgram = "main.sh";
}

Because sub-modules take arguments into a bash script from the parent module’s bash script, you can perform any branching logic in here. Modules are hot-swappable too, as long as you make a small compatibility layer using Nix as I’ve described.

Finally, I can use Nix to package and develop in my monorepo.

Why do this?

I need to use Nix (dockerTools.streamLayeredImage) to package my project into Docker images. It seems convenient to use Nix to deploy my project to the Docker containers too, and so packaging the entire project using Nix just makes sense.

Using Nix as the package manager means the software Just Works™ on everyone’s machine once they install Nix and enable Flakes.

Nix is highly configurable. Capable of version pinning, patching, and sub-scripting at every step, it is the most script-able package manager I’ve ever used.