Packaging Leiningen Apps with Nix

A bit of background on integrating Nix with dynamic dependency management, and a short guide on how to use clj-nix.

As I dive deeper into using Nix for my own software as opposed to "just" using NixOS to configure my systems, I recently found myself wanting to package a Clojure application with Nix for the first time. And since I'm one of the people who don't really see the benefit in leaving behind a working, easy-to-use tool like Leiningen for the archaic "write your own build scripts"-style of the official Clojure tooling, I needed to find a way to nixify Clojure applications that works for Leiningen-based projects.

Finding the right tool

A little bit of a goose chase ensued as I was trying to find something that worked. Joshua recommended clj-nix but pointed out that this was for Clojure CLI-based projects only. Helpfully, clj-nix has a list of "similar tools" in its documentation, so I looked through those as well:

  • dwn is not really about creating Nix derivations for Clojure apps but merely using Nix under the hood of its own interface. It works with Leiningen but hasn't been touched in 5 years and looked a little too idiosyncratic for my tastes.
  • clj2nix and clojure-nix-locker don't seem to support Leiningen
  • mvn2nix doesn't do anything Clojure-specific, instead being built for Maven projects. It still looked like the most promising option.

mvn2nix essentially generates a lock file from the dependencies in a Maven pom.xml file and then uses that to prefetch all the dependencies. In case you didn't know, the fundamental problem with "just" running the code to build a Clojure application from a Nix derivation is that Nix builds are run in a sandbox that doesn't allow network requests (so any dependencies must be downloaded in advance).

So, my idea for employing mvn2nix was this:

  1. Generate a pom.xml file from the Leiningen project using lein pom
  2. Generate a lock file from that using mvn2nix
  3. Use mvn2nix functionality to build a local repository containing all the required dependencies and use Leiningen to build the project with that

This sounded simple enough, so I started playing around with it. However, I encountered some problems:

  • mvn2nix doesn't support SNAPSHOT versions for libraries
  • No matter what you try to do with Leiningen, it always tries to load any and all plugins declared. Additionally, there is no native way to generate a pom.xml for plugins like there is for regular dependencies, so prefetching plugins wasn't directly possible with mvn2nix.

At this point, I figured that it might make sense to write a Leiningen plugin specifically for building Nix apps. It could hook directly into the project definition, adjust relevant options, define a special profile etc., while still making use of mvn2nix under the hood for the heavy lifting. But before I opened that can of worms, I decided to go back to clj-nix for inspiration. And there it was, at the bottom of its lockfile documentation:

Leiningen projects are supported. Use the --lein option to add the project.clj dependencies to the lock file.

A quick sequence of people in fancy clothes facepalming. First, a group of three, then some more, then an entire audience of people

So, in short: I had just spent hours of my time to come up with a hack to support a use case that was already supported by the most popular tooling out there. The moral of the story, I suppose, is to at least do a proper keyword search in a tool's documentation before dismissing it.

Fixing a couple of things

So, throw clj-nix at my project and be done with it? Well, when I tried that, it didn't work. It turned out there were a few bugs left in clj-nix's Leiningen code, some of which I ended up fixing myself:

  • The withLeiningen option in the clj-nix module was impossible to use due to a mistake in the option validation logic
  • Furthermore, withLeiningen was supposed to add Leiningen to the build environment, but it didn't actually do anything
  • The code to download dependencies/generate the lock file merged any custom profiles but ignored the Leiningen user profile which was required to set a custom local repository (also there was no option to customise the profiles used for this purpose, which I added too)

Also, it turned out that the handling of snapshots was wonky and didn't work properly for Leiningen. The maintainer of the tool was so kind to investigate and fix that one.

After all this was done, it actually worked. There are some inconveniences left with clj-nix's Leiningen support (which is fair given the maintainer doesn't actually use Leiningen), but these are no deal-breakers for me.

How to package a Leiningen app

Ok, so let's say you have your own Leiningen project and want to package it with Nix. Here's what you can do now:

  1. Copy the flake.nix template from clj-nix to your project root
  2. Adjust the flake to your needs (see clj-nix module options)
    • change name and main-ns (namespace with your -main function)
    • disable nativeImage for now (go there when the basics are working)
    • add withLeiningen = true;
    • add buildCommand = "lein uberjar";'
  3. Generate a lock file: nix run github:jlesquembre/clj-nix#deps-lock -- --lein.
    set --lein-profiles if you need to control the profiles to be used, e.g. if you want an uberjar at the end, it'd make sense to specify --lein-profiles uberjar.
  4. If you're using git, git add deps-lock.json flake.nix
  5. nix build should work now! (hopefully)

Example

Here's an example flake.nix, from my instant-poll Discord bot:

{
  inputs = {
    flake-utils.url = "github:numtide/flake-utils/v1.0.0";
    nixpkgs.url = "github:NixOS/nixpkgs/24.05";
    clj-nix.url = "github:jlesquembre/clj-nix";
  };

  outputs = { self, flake-utils, clj-nix, nixpkgs, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      {
        packages = rec {
          default = instant-poll;
          instant-poll = clj-nix.lib.mkCljApp {
            pkgs = nixpkgs.legacyPackages.${system};
            modules = [
              {
                projectSrc = ./.;
                name = "instant-poll";
                # This must be the same as `:main` in project.clj
                main-ns = "instant-poll.handler";
                buildCommand = "lein uberjar";
                withLeiningen = true;
              }
            ];
          };
        };
      }
    );
}
Tags:

Comments

Comments for this post are available on chaos.social. If you have an account somewhere on the Fediverse (e.g. on a Mastodon, Misskey, Peertube or Pixelfed instance), you can use it to add a comment yourself.

Posts from my blogroll

This is probably the most I will ever pretend

Anything can be anything! Until the next game starts, of course.

via Cassidy Williams August 30, 2025

Trump Jr.-advised prediction markets invite bets on president’s demise

President Trump’s deregulatory agenda emboldened prediction markets to push boundaries around permitted event contracts. Now sites advised by his son are allowing bets on his death.

via Citation Needed September 02, 2025

I gotta make music

all day every day

via Todepond dot com September 02, 2025

Generated by frenring