~ / track D / clojure in practice
Shipping Clojure: uberjars, native binaries, bundles
IntermediateA 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:
- An uberjar — a single
.jarfile with your code, every dependency, and a main class. Runs anywhere there's a JVM. The default for server-side Clojure. - A native binary — GraalVM/Babashka compiled. Fast startup, no JVM required, smaller deploy. Good for CLIs and lambdas.
- A JavaScript bundle — ClojureScript compiled with the Google Closure Compiler. Ships to a browser or a Node runtime.
- A library on Clojars / Maven Central — a versioned
.jarother Clojure projects depend on viadeps.ednorproject.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-cljAOT-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
:mainwrites aMain-Classmanifest entry sojava -jarfinds your-mainfunction. 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
.cljfiles 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:
- Build a slim jar (not an uberjar) with
tools.build'sjartask. - Sign it (Maven Central requires GPG signatures; Clojars doesn't).
- Deploy with
deps-deploy(Clojars) ortools.build'sinstall/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:
| Shape | Runs on |
|---|---|
| Uberjar | A 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 script | A CI box, a sidecar, a cron line |
| ClojureScript bundle | A 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.