~ / track D / clojure in practice

Shipping Clojure: uberjars, native binaries, bundles

Intermediate

A Clojure program that runs at the REPL is the easy half. The hard half is turning that REPL session into a thing somebody else can run — a single file you can scp to a server, a Docker image, a .js bundle for a browser, a CLI tool a teammate can apt install. This lesson is the map of how Clojure code becomes an artifact, what tools you reach for, and the trade-offs at each fork.

The four destinations

Most Clojure code ends up in one of four shapes:

  1. An uberjar — a single .jar file with your code, every dependency, and a main class. Runs anywhere there's a JVM. The default for server-side Clojure.
  2. A native binary — GraalVM/Babashka compiled. Fast startup, no JVM required, smaller deploy. Good for CLIs and lambdas.
  3. A JavaScript bundle — ClojureScript compiled with the Google Closure Compiler. Ships to a browser or a Node runtime.
  4. A library on Clojars / Maven Central — a versioned .jar other Clojure projects depend on via deps.edn or project.clj.

Each has its own packaging tool. The thing they share: the source is the same Clojure code; the artifact is the difference.

Uberjars with tools.build

The modern path: a build.clj script that uses clojure.tools.build to compile, package, and write the jar. A minimal version, sketched:

(ns build
  (:require [clojure.tools.build.api :as b]))
 
(def lib    'myorg/myapp)
(def version "1.2.3")
(def class-dir "target/classes")
(def basis  (b/create-basis {:project "deps.edn"}))
(def uber-file (format "target/%s-%s-standalone.jar"
                       (name lib) version))
 
(defn clean [_] (b/delete {:path "target"}))
 
(defn uber [_]
  (clean nil)
  (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir})
  (b/compile-clj {:basis basis :src-dirs ["src"] :class-dir class-dir})
  (b/uber {:class-dir class-dir
           :uber-file uber-file
           :basis basis
           :main 'myapp.core}))

You run it with clj -T:build uber. The output is a single fat jar you can launch with java -jar target/myapp-1.2.3-standalone.jar.

Two design choices to be aware of:

  • AOT or not. compile-clj AOT-compiles the namespaces you point it at into Java bytecode. Without AOT, the jar still works — it compiles on first run, which adds a few seconds of startup. AOT pays off for long-running servers; for short-lived scripts the AOT step actively hurts.
  • The main class. Setting :main writes a Main-Class manifest entry so java -jar finds your -main function. That namespace needs to be AOT'd; everything else can stay source.

When Leiningen still makes sense

lein uberjar does the same thing in one command if your project is already a project.clj codebase. Lein is heavier than tools.build, but the workflow ("lein uberjar then scp the jar") is unbeatable for older codebases and for people who don't want to write Clojure to build Clojure.

The community has converged on tools.build + deps.edn for new projects; Lein is fine for everything else and isn't going anywhere.

Native binaries: Babashka and GraalVM

If your program is a script — a few thousand lines, no long-running state, sensitive to startup time — a JVM jar is the wrong shape. Two options:

  • Babashka. Pre-built native interpreter that runs .clj files directly: bb my-script.clj. Startup is 4-30ms. Subset of clojure.core with most common libs baked in. The right answer for shell-script-shaped problems.
  • GraalVM native-image. Compiles a JVM Clojure project to a single static binary. More work to set up (reflection config, AOT, build pipeline), but you can ship anything Clojure can express. The right answer for CLIs and serverless functions where cold-start matters.

The trade between them: Babashka starts faster than you can blink but forces you into its library set; GraalVM gives you the full ecosystem but needs careful build configuration.

ClojureScript: shadow-cljs (and the alternatives)

For the browser you compile to JavaScript. shadow-cljs is the modern default — driven by an shadow-cljs.edn config that's a sibling to deps.edn — and handles npm dependencies, REPL connection, and source maps as one workflow.

;; shadow-cljs.edn (sketch)
{:source-paths ["src"]
 :dependencies [[reagent "1.2.0"]]
 :builds
 {:app {:target :browser
        :output-dir "public/js"
        :modules {:main {:init-fn myapp.core/start}}}}}

shadow-cljs watch app gives you a dev build with hot reload; release app produces an optimized bundle ready to ship. Two older alternatives — figwheel-main and raw cljs.main — still work and you'll see them in established codebases.

Publishing a library

If the artifact is a .jar for other Clojure projects to depend on, the destination is Clojars (community) or Maven Central (more setup, broader discoverability). The flow:

  1. Build a slim jar (not an uberjar) with tools.build's jar task.
  2. Sign it (Maven Central requires GPG signatures; Clojars doesn't).
  3. Deploy with deps-deploy (Clojars) or tools.build's install / deploy (Maven Central).

The library design principles from [[library-design]] start mattering the moment somebody else can require you — semver discipline, narrow public surface, no surprise breaking changes.

Deploy targets

The artifact is half the question; where it runs is the other half. A quick map of common pairings:

ShapeRuns on
UberjarA VM, a container, AWS Lambda (with the JVM layer), Heroku, anything that runs java
Native binary (GraalVM)The same places, minus the JVM requirement; lambda cold-starts shrink from seconds to ms
Babashka scriptA CI box, a sidecar, a cron line
ClojureScript bundleA static host, a CDN edge, an Electron app

Containerization is orthogonal: you can wrap any of the four in a Dockerfile. For the JVM path, multi-stage builds keep the final image small (build stage has the Clojure CLI; runtime stage is just eclipse-temurin:21-jre

  • the jar).

Check yourself

? quiz

You're shipping a CLI tool that has to start in under 100ms. Which artifact shape is best?

Exercise

You've been asked to ship a Clojure project two ways: as an uberjar your ops team can deploy, and as a Babashka script your support team can run locally. Sketch the top-level layout of the project — what files exist, where the entry point lives, and which code (if any) has to differ between the two builds.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new