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.