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

How I fell in love with calendar.txt

The more I learn about Unix tools, the more I realise we are reinventing everyday Rube Goldberg’s wheels and that Unix tools are, often, elegantly enough. Months ago, I discovered calendar.txt. A simple file with all your dates which was so simple and s…

via Ploum.net September 03, 2025

One of the last, best hopes for saving the open web and a free press is dead

The Google ruling is a disaster. Let the AI slop flow and the writers, journalists and creators get squeezed.

via Blood in the Machine September 04, 2025

Continental Dips: notes on a Scandisaster

Behold! I have returned from the continent.

via Young Vulgarian September 05, 2025

Generated by frenring