How to package a pnpm project

Anyone here that has packaged a pnpm project? SvelteKit to be more specific.

The devbox approach looks reasonable.
The flake is wrote makes me want to cry and does not even work correctly.

Any pointer to an example or some best practice - especially in regards to the locked dependencies?

If you use my flake template (nix flake init -t github:bitbloxhub/flake-template) i made the following that you can put in nix/node.nix:

{
  perSystem =
    {
      pkgs,
      ...
    }:
    {
      make-shells.default = {
        packages = [
          pkgs.nodejs_25
          pkgs.pnpm_10
        ];

        shellHook = ''
          export PATH=$(pwd)/node_modules/.bin/:$PATH
        '';
      };

      packages.default =
        let
          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "your-project-name-pnpm-deps";
            version = "your-version";
            src = ../.;
            pnpm = pkgs.pnpm_10;
            fetcherVersion = 3; # See https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
            # Change the hash when pnpm-lock.yaml updates, you can get the hash by setting it to "" and running `nix build .#default`
            hash = "";
          };
        in
        pkgs.stdenv.mkDerivation {
          pname = "your-project-name";
          version = "your-version";
          src = ../.;
          inherit pnpmDeps;

          nativeBuildInputs = [
            pkgs.nodejs_25
            pkgs.pnpmConfigHook
            pkgs.pnpm_10
            pkgs.makeWrapper
          ];

          buildPhase = ''
            pnpm build
          '';

          installPhase = ''
            cp -r build $out
            makeWrapper ${pkgs.nodejs_25}/bin/node $out/bin/your-project-name --add-flag $out/index.js
          '';
        };
    };
}

You can then run nix build .#default and run result/bin/your-project-name.

I’ve packaged a few pnpm-based projects (including SvelteKit), and the key is leaning on the lockfile rather than fighting it. Using pnpm fetch + a frozen lockfile (--frozen-lockfile) during the build step usually keeps dependencies deterministic. For Nix/Devbox, generating the dependency store ahead of time and treating it as immutable helps a lot.

I also found it useful to look at tooling examples outside the usual Nix docs—sometimes approaches used in build tooling projects translate surprisingly well to pnpm workflows, especially around reproducible installs and caching.

Hope that helps a bit until you find a clean flake setup.

Thanks for the input. What I have come up so far is below.

It does kind of does the job - but the re-build behaviour isn’t quite right yet.

When I change something in the app sources, the app derivation of course changes. But the runtime dependencies are also getting re-evaluated - which should not be necessary unless the package.json and/or lock file changes.

Not to mention - that’s really verbose and would probably be the same for every such project.

Any pointers on how to improve this?

{
  description = "SvelteKit hello world";

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

  outputs =
    {
      self,
      nixpkgs,
    }:
    let
      supportedSystems = [
        "x86_64-linux"
        "aarch64-linux"
        "x86_64-darwin"
        "aarch64-darwin"
      ];

      forEachSystem = nixpkgs.lib.genAttrs supportedSystems;

      # create the runtime pnpm dependencies derivation (without dev deps)
      mkRunDeps =
        pkgs:
        let
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
        in
        pkgs.stdenv.mkDerivation {
          pname = "simple-deps-run";
          version = self.rev or self.dirtyRev or "dev";

          src = ./.;

          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "simple-deps-run";
            version = self.rev or self.dirtyRev or "dev";
            src = ./.;
            hash = "sha256-g+hBI7+JGOhYiKi6pnH1Chjya8z7zjlEGC5FKPjw/8Q=";
            pnpm = pnpm;
            fetcherVersion = 3;
          };

          nativeBuildInputs = [
            nodejs
            pnpm
            pkgs.pnpmConfigHook
          ];

          buildPhase = ''
            runHook preBuild
            pnpm install --frozen-lockfile --prod
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall
            mkdir -p $out
            cp -r node_modules $out/
            runHook postInstall
          '';
        };

      # create the dev pnpm dependencies derivation (without runtime deps)
      mkDevDeps =
        pkgs:
        let
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
        in
        pkgs.stdenv.mkDerivation {
          pname = "simple-deps-dev";
          version = self.rev or self.dirtyRev or "dev";

          src = ./.;

          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "simple-deps-dev";
            version = self.rev or self.dirtyRev or "dev";
            src = ./.;
            hash = "sha256-g+hBI7+JGOhYiKi6pnH1Chjya8z7zjlEGC5FKPjw/8Q=";
            pnpm = pnpm;
            fetcherVersion = 3;
          };

          nativeBuildInputs = [
            nodejs
            pnpm
            pkgs.pnpmConfigHook
          ];

          buildPhase = ''
            runHook preBuild
            pnpm install --frozen-lockfile --dev
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall
            mkdir -p $out
            cp -r node_modules $out/
            runHook postInstall
          '';
        };

      mkPackage =
        pkgs:
        let
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
          runDeps = mkRunDeps pkgs;
        in
        pkgs.stdenv.mkDerivation {
          pname = "simple";
          version = self.rev or self.dirtyRev or "dev";

          src = ./.;

          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "simple-deps";
            version = self.rev or self.dirtyRev or "dev";
            src = ./.;
            hash = "sha256-g+hBI7+JGOhYiKi6pnH1Chjya8z7zjlEGC5FKPjw/8Q=";
            pnpm = pnpm;
            fetcherVersion = 3;
          };

          nativeBuildInputs = [
            nodejs
            pnpm
            pkgs.pnpmConfigHook
          ];

          buildPhase = ''
            runHook preBuild
            pnpm install --frozen-lockfile
            pnpm build
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall

            mkdir -p $out/app
            cp -r build/* $out/app/

            # Link to the production node_modules derivation for any runtime deps
            ln -s ${runDeps}/node_modules $out/app/node_modules

            mkdir -p $out/bin
            cat > $out/bin/simple <<EOF
#!/usr/bin/env bash
cd $out/app
exec ${nodejs}/bin/node $out/app/index.js
EOF
            chmod +x $out/bin/simple

            runHook postInstall
          '';

          meta = {
            description = "SvelteKit hello world";
            maintainers = [ ];
          };
        };
    in
    {
      packages = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
        in
        {
          default = mkPackage pkgs;
          run-deps = mkRunDeps pkgs;
          dev-deps = mkDevDeps pkgs;
        }
      );

      apps = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          pkg = mkPackage pkgs;
        in
        {
          default = {
            type = "app";
            program = "${pkg}/bin/simple";
          };
        }
      );

      devShells = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
        in
        {
          default = pkgs.mkShell {
            buildInputs = [
              nodejs
              pnpm
            ];

            shellHook = ''
              echo "node $(node --version)"
              echo "pnpm $(pnpm --version)"
            '';
          };
        }
      );
    };
}

OK so it does not look like you need mkRunDeps and mkDevDeps. IIRC, Vite’s build process should handle bundling all external runtime dependencies. If you do need them, your build process is probably messed up, however if there is something i am missing and you truely need this, i would first:

  • put a single pnpmDeps in the let block and inherit it instead of all fetchPnpmDeps invocations, because they have the same src, hash, etc…
  • create a factory function that has the common parts of mkRunDeps and mkDevDeps

Other than that, it looks decent.

Indeed. But this separation is not for vite. The idea is to improve the caching.

If the lock file has not changed, the derivations for the runtime or devtime dependencies should not have changed. So the build would only need to run vite to build the final bundle. The dependencies should then come straight from the nix store.

TBH: just packaging all the deps in one derivation that holds the node_modules could also be enough.

What I am after is a fast build.
Am I overcomplicating things?

Yes, I would say you are.

They basically DO come straight from the nix store, since fetchPnpmDeps builds a cache. pnpm install just symlinks/copies them to node_modules. pnpm install is usually not the bottleneck in most packages. Actually, it looks like your strategy may be slower because pnpmConfigHook runs pnpm install automatically (source).

1 Like

Wow, talking about pre-mature optimizations :roll_eyes:
Removing the separate dependency management gives actually a 20-30% speed bump.

{
  description = "SvelteKit hello world";

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

  outputs =
    {
      self,
      nixpkgs,
    }:
    let
      supportedSystems = [
        "x86_64-linux"
        "aarch64-linux"
        "x86_64-darwin"
        "aarch64-darwin"
      ];

      forEachSystem = nixpkgs.lib.genAttrs supportedSystems;

      mkPackage =
        pkgs:
        let
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
        in
        pkgs.stdenv.mkDerivation {
          pname = "simple";
          version = self.rev or self.dirtyRev or "dev";

          src = ./.;

          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "simple-deps";
            version = self.rev or self.dirtyRev or "dev";
            src = ./.;
            hash = "sha256-g+hBI7+JGOhYiKi6pnH1Chjya8z7zjlEGC5FKPjw/8Q=";
            pnpm = pnpm;
            fetcherVersion = 3;
          };

          nativeBuildInputs = [
            nodejs
            pnpm
            pkgs.pnpmConfigHook
          ];

          buildPhase = ''
            runHook preBuild
            pnpm build
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall

            mkdir -p $out/app
            cp -r build/* $out/app/

            pnpm install --frozen-lockfile --prod
            cp -r node_modules $out/app/node_modules

            mkdir -p $out/bin
            cat > $out/bin/simple <<EOF
#!/usr/bin/env bash
cd $out/app
exec ${nodejs}/bin/node $out/app/index.js
EOF
            chmod +x $out/bin/simple

            runHook postInstall
          '';

          meta = {
            description = "SvelteKit hello world";
            maintainers = [ ];
          };
        };
    in
    {
      packages = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
        in
        {
          default = mkPackage pkgs;
        }
      );

      apps = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          pkg = mkPackage pkgs;
        in
        {
          default = {
            type = "app";
            program = "${pkg}/bin/simple";
          };
        }
      );

      devShells = forEachSystem (
        system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          nodejs = pkgs.nodejs_22;
          pnpm = pkgs.pnpm_10;
        in
        {
          default = pkgs.mkShell {
            buildInputs = [
              nodejs
              pnpm
            ];

            shellHook = ''
              echo "node $(node --version)"
              echo "pnpm $(pnpm --version)"
            '';
          };
        }
      );
    };
}

But it’s still quite verbose and quite a bit of c&p for the build and install phase.

I still don’t quite understand what the pnpmConfigHook is actually needed for.

…and all this copying looks a little concerning:

            cp -r build/* $out/app/
            ...
            cp -r node_modules $out/app/node_modules

You can probably remove this section, considering that all node_modules dependencies should be bundled in the built version. You can definitely remove the pnpm install bit, because as i said already pnpmConfigHook runs it already. If you did need this, you would set NODE_PATH in your executable’s node invocation.

pnpmConfigHook basically just does the following:

  1. Extracts the cache produced by fetchPnpmDeps and is available through the pnpmDeps attribute, which is passed through to the derivation environment.
  2. Configures pnpm to use that extracted cache so we don’t try to get things from the network, which fails because nix builds are blocked from network access unless we know the hash of our output.
  3. Runs pnpm install.
  4. Runs patchShebangs on node_modules to make sure things in node_modules/.bin use the right interpreter.

This is basically just standard in Nix builds, we have to copy the results from the ephemeral build sandbox to the store.

Also, please mark an answer as the solution if it solved your problem :wink:.

1 Like

This here seems to work pretty well.

{
  description = "sveltekit hello world";

  inputs = {
    # nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
  };

  outputs =
    {
      self,
      nixpkgs,
    }:
    let
      systems = [
        "x86_64-linux"
        # "aarch64-linux"
        # "x86_64-darwin"
        "aarch64-darwin"
      ];

      nodejsVersion = "nodejs_24";
      pnpmVersion = "pnpm_10";

      # to pin a specific nodejs version
      # overlay_nodejs = final: prev: {
      #   nodejs_24 = prev.nodejs_24.overrideAttrs (old: rec {
      #     version = "24.12.0";
      #     src = final.fetchurl {
      #       url = "https://nodejs.org/dist/v${version}/node-v${version}.tar.xz";
      #       hash = "sha256-bT6JGgFrkPbGoZ6ly8nJDFfu+RmGcLqT8E+oKvAldK4=";
      #     };
      #   });
      # };

      forEachSystem = nixpkgs.lib.genAttrs systems;

      pkgsFor = system: import nixpkgs {
        inherit system;
        # overlays = [ overlay_nodejs ];
      };

      mkPackage =
        pkgs:
        let
          nodejs = pkgs.${nodejsVersion};
          pnpm = pkgs.${pnpmVersion};
        in
        pkgs.stdenv.mkDerivation {
          pname = "simple";
          version = self.rev or self.dirtyRev or "dev";

          src = ./.;

          pnpmDeps = pkgs.fetchPnpmDeps {
            pname = "simple-deps";
            version = self.rev or self.dirtyRev or "dev";
            src = ./.;
            hash = "sha256-g+hBI7+JGOhYiKi6pnH1Chjya8z7zjlEGC5FKPjw/8Q=";
            pnpm = pnpm;
            fetcherVersion = 3;
          };

          nativeBuildInputs = [
            nodejs
            pnpm
            pkgs.pnpmConfigHook
          ];

          buildPhase = ''
            runHook preBuild
            pnpm build
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall

            mkdir -p $out/app
            cp -r build/* $out/app/

            mkdir -p $out/bin
            cat > $out/bin/simple <<EOF
#!/usr/bin/env bash
cd $out/app
exec ${nodejs}/bin/node $out/app/index.js
EOF
            chmod +x $out/bin/simple

            runHook postInstall
          '';

          meta = {
            description = "sveltekit hello world";
            maintainers = [ ];
          };
        };
    in
    {

      # nix build
      # nix run
      packages = forEachSystem (
        system:
        let
          pkgs = pkgsFor system;
        in
        {
          default = mkPackage pkgs;
        }
      );

      apps = forEachSystem (
        system:
        let
          pkgs = pkgsFor system;
          pkg = mkPackage pkgs;
        in
        {
          default = {
            type = "app";
            program = "${pkg}/bin/simple";
          };
        }
      );

      # nix develop
      devShells = forEachSystem (
        system:
        let
          pkgs = pkgsFor system;
          nodejs = pkgs.${nodejsVersion};
          pnpm = pkgs.${pnpmVersion};
        in
        {
          default = pkgs.mkShell {
            buildInputs = [
              nodejs
              pnpm
            ];

            shellHook = ''
              echo "node $(node --version)"
              echo "pnpm $(pnpm --version)"
            '';
          };
        }
      );
    };
}

I still think it’s a little much boilerplate code.
Question is how this could be improved further.

And it seems like fetchPnpmDeps does cache, but I don’t feel fully in control. My guess is that any changed flake input causes a re-eval. Although it should (debatable) only depend on the pnpm-lock.yaml

Also, please mark an answer as the solution if it solved your problem

When the time has come, I will :wink:

For a Nix flake, this seems like a standard amount of boilerplate. At this point, flake-parts would just be replacing the current boilerplate withflake-parts boilerplate.

Fixed-output derivations (like fetchPnpmDeps) should not rebuild unless the hash is changed, nixpkgs updates, or the pname changes.
FODs do always evaluate, but the derivation hash should be the same and thus skip rebuild.

If you see pnpm install-like logs in the output, it’s probably just installing from the cache produced by fetchPnpmDeps.

This looks basically like a perfect flake for a SvelteKit+pnpm project though, I can’t see anything that can really be improved.

Thanks for the feedback.

This is boilerplate that IMO should not be required to be copied into every single project:

          buildPhase = ''
            runHook preBuild
            pnpm build
            runHook postBuild
          '';

          installPhase = ''
            runHook preInstall

            mkdir -p $out/app
            cp -r build/* $out/app/

            mkdir -p $out/bin
            cat > $out/bin/simple <<EOF
#!/usr/bin/env bash
cd $out/app
exec ${nodejs}/bin/node $out/app/index.js
EOF
            chmod +x $out/bin/simple

            runHook postInstall
          '';

If there was something that I want to control it would be just the node and the pnpm install executions.

I was surprised that (what probably was) a change in nixpkgs triggered a longer eval. The next run was again immediate. But I guess this does make sense. Any input will cause this IIUC.
Since it’s still using the cache it should be alright.

This is just how most Nix things work, you can replace the cat > $out/bin/simple and chmod bit with my makeWrapper invocation from here, and remove runHook invocations if you don’t think people consuming the project’s flake will want to use those hooks. Other than that, you just have to deal with copy+pasting that. It’s really not that bad compared to other nix boilerplate (see: crane boilerplate, crate2nix boilerplate).