Minimal Scala Container Images using Nix
Posted on 2025-08-25
Recently I've been working on a Scala backend as a side project. I decided to deploy it as a Docker image for portability reasons. My hosting provider supports running JARs, but I wanted something that I could host anywhere if I decided to move away.
There are already articles about generating Docker images for a Scala project even with Nix, so why am I writing another one? The reason is that when I followed them, I ended up with a 722 MB Docker image! I found this to be unnecessarily big which motivated me to look for ways to reduce it. So this article is about building a minimal Docker image for a Scala project using Nix. Most of it can be applied to any program that runs on JVM (Java, Kotlin, etc.) as well.
Containerization of a JVM application feels a bit strange because one has to also bundle the JVM to execute the JAR, so it's essentially virtualization over virtualization, which also most probably runs on a virtual machine.
Side note: If you just want to the see the end result, you can jump directly to the last section.
Anyway... let's start.
First attempt
In order to containerize an sbt
project one has to:
- Build a JAR with all the dependencies included. This is called "über JAR". Normally JARs don't include their dependencies and load them at runtime, similar to how shared libraries work.
- Bundle it with a JVM (Java Virtual Machine). JVM applications need a virtual machine to be executed.
- Package the whole thing into a Docker-compatible container image.
Side note: If you are not familiar with sbt (Scala Build Tool), it's the de-facto build tool for Scala projects.
I am going to do all steps with Nix, since that gives me reproducible builds and I already use it for development.
My first attempt was the following:
let
repository = builtins.fetchTarball {
url = "https://github.com/zaninime/sbt-derivation/archive/master.tar.gz";
};
sbt-derivation = import "${repository}/overlay.nix";
app = sbt-derivation.mkSbtDerivation.${system} {
pname = "app";
version = "0.0.1";
src = ./.;
depsSha256 = "sha256-06Qog8DyDgisnBhUQ9wW46WqqnhGXlakI1DSuFHkriQ=";
buildInputs = with pkgs; [ sbt jdk23 makeWrapper ];
buildPhase = "sbt assembly";
installPhase = ''
mkdir -p $out/bin
mkdir -p $out/share/java
cp src/app/target/scala-3.*/*.jar $out/share/java
makeWrapper ${pkgs.jdk23_headless}/bin/java $out/bin/scala-app \
--add-flags "-cp \"$out/share/java/*\" org.app.Application"
'';
};
app-container = pkgs.dockerTools.buildImage {
name = "app-container";
tag = "latest";
copyToRoot = [ app pkgs.busybox ];
config = { Cmd = [ "/bin/${app.pname}" ]; };
};
in
app-container
Explanation:
- sbt-derivation is a convenience utility to generate Nix derivations for
sbt
projects. - sbt-assembly lets us generate "über JAR" with all the necessary dependencies.
makeWrapper
creates a binary that wraps the JAR with ajava
binary so it looks like a regular binary from the outside. The binary comes from jdk23_headless package.
After I built the container, I was pushing it into the hosting provider's registry, but I noticed it took a lot of time to upload it. My home connection is not really fast (I would expect better from Germany), so having to wait 15 minutes to deploy a new version of the application was very annoying. I checked the image size locally, and I saw:
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/app-container latest f0e2ad8f1167 55 years ago 722 MB
Ignore the obviously incorrect CREATED 55 years ago
, the image was 722 MB! This is huge for a project like this. Annoyed by it, I went to check out the contents of the image. Using docker
/podman
one can export the image filesystem into a .tar
archive:
aiono ❯ docker create localhost/app-container:latest
0b08b8de863228c8211d7c844a3e84a9b03c5032f68ee582e1e0fca6caee0244
aiono ❯ TMP_DIR=$(mktemp -d)
aiono ❯ docker export 0b08b8de863228c8211d7c844a3e84a9b03c5032f68ee582e1e0fca6caee0244 > "$TMP_DIR/image.tar"
aiono ❯ mkdir "$TMP_DIR/image"
aiono ❯ tar -xf "$TMP_DIR/image.tar" -C "$TMP_DIR/image"
Then I checked the contents of the image using du:
aiono ❯ du -sh $TMP_DIR/image/*
1,2M /tmp/tmp.3xnIHXwY1l/image/bin
4,0K /tmp/tmp.3xnIHXwY1l/image/default.script
0 /tmp/tmp.3xnIHXwY1l/image/linuxrc
651M /tmp/tmp.3xnIHXwY1l/image/nix
1,2M /tmp/tmp.3xnIHXwY1l/image/sbin
37M /tmp/tmp.3xnIHXwY1l/image/share
It seems like most of the size comes from /nix/store
which is not surprising. What's under /share
should be the JVM. So it seems like the problem is not in the JAR since it's just 37 MB. Let's verify:
aiono ❯ du -sh /tmp/tmp.3xnIHXwY1l/image/share/java/*
37M /tmp/tmp.3xnIHXwY1l/image/share/java/scala-app-assembly-0.1.0.jar
Correct!
Let's see what are the largest directories under /nix/store
:
aiono ❯ du -sh $TMP_DIR/image/nix/store/* | sort -h | tail -n 10
484K /tmp/tmp.3xnIHXwY1l/image/nix/store/mk9nhl6b48gpqhdbjy9ir16wrz6r3qn6-lcms2-2.17
628K /tmp/tmp.3xnIHXwY1l/image/nix/store/ncdwsrgq6n6161l433m4x34057zq0hhf-libidn2-2.3.8
1,2M /tmp/tmp.3xnIHXwY1l/image/nix/store/skijwg3cx0hkl5p2l5l4zz898glxi644-busybox-1.36.1
1,7M /tmp/tmp.3xnIHXwY1l/image/nix/store/00zrahbb32nzawrmv9sjxn36h7qk9vrs-bash-5.2p37
2,0M /tmp/tmp.3xnIHXwY1l/image/nix/store/vm18dxfa5v7y3linrg1x1q9wx41bkxwf-libunistring-1.3
2,0M /tmp/tmp.3xnIHXwY1l/image/nix/store/w753b87diqcja7gc3kifydxdfpi967ns-libjpeg-turbo-3.1.0
9,6M /tmp/tmirectories undep.3xnIHXwY1l/image/nix/store/l7d6vwajpfvgsd3j4cr25imd1mzb7d1d-gcc-14.3.0-lib
31M /tmp/tmp.3xnIHXwY1l/image/nix/store/q4wq65gl3r8fy746v9bbwgx4gzn0r2kl-glibc-2.40-66
37M /tmp/tmp.3xnIHXwY1l/image/nix/store/778xsjch86fyv4qdzznqyihcw7s5r029-scala-app-0.0.1
566M /tmp/tmp.3xnIHXwY1l/image/nix/store/w7rphym6zk35wsx3aknbn3y7srj3x5qa-openjdk-headless-23.0.2+7
The winner is definitely openjdk-headless
. Almost all of the 722 MB comes from it. So that will be the first thing we will try to reduce. Second is odd; scala-app
is the package for our app, but we already have a copy of our app in /share
! So it looks like a duplication, but first focus on our JDK package.
Minimal Java Runtime Environments
My first mistake was to bundle a full Java Development Kit (JDK) with the application. JDKs are used to build a JVM app but to run it, you only need Java Runtime Environment (JRE). I searched for jre
in Nix Search. The second option was jre_minimal
, which sounded very promising. So I made the following change:
- makeWrapper ${pkgs.jdk23_headless}/bin/java $out/bin/scala-app \
+ makeWrapper ${pkgs.jre_minimal}/bin/java $out/bin/scala-app \
Let's see how our image size changed:
aiono ❯ nix-build
aiono ❯ cat result | docker load
aiono ❯ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/app-container latest 0b25de30d43c 55 years ago 508 MB
It's down to 508 MB. Still very big but at least we managed to trim it down 200 MB. Let's try to run our app:
aiono ❯ nix-build
aiono ❯ docker run --expose 4041 --network host localhost/app-container:latest
Exception in thread "main" java.lang.NoClassDefFoundError: sun/misc/Unsafe
at ...
Not good. It seems like we need sun.misc.Unsafe
but jre_minimal
doesn't come with it. We need to fix this.
I don't remember how I found it, but this section in the nixpkgs reference shows how to use jre_minimal
. Turns out, jre_minimal
strips out all the standard modules to provide a minimal JRE, so you need to provide which libraries you want. Under the hood, it uses jlink to generate a minimal JRE.
How do we know which modules we need? Thankfully, there is a tool for that called jdeps. We can run it in our assembled jar to see which dependencies we need.
aiono ❯ jdeps --ignore-missing-deps --list-reduced-deps result/share/java/server-assembly-0.1.0.jar
java.base
java.desktop
java.managementcipher suites
java.naming
java.security.jgss
java.security.sasl
java.sql
jdk.unsupported
Side note: In my case, I later realized that I needed some more modules for my application to actually work. These were jdk.crypto.ec
and jdk.crypto.cryptoki
. Without these, I couldn't make requests to some websites which requires encryption algorithms provided from these modules. In case you see javax.net.ssl.SSLHandshakeException: Received fatal alert: insufficient_security
adding these may solve your issues.
Let's add those:
let
repository = builtins.fetchTarball {
url = "https://github.com/zaninime/sbt-derivation/archive/master.tar.gz";
};
sbt-derivation = import "${repository}/overlay.nix";
app = let
# Define custom JRE 👇
jre = pkgs.jre_minimal.override {
modules = [
"java.base"
"java.desktop"
"java.logging"
"java.management"
"java.naming"
"java.security.jgss"
"java.security.sasl"
"java.sql"
"java.transaction.xa"
"java.xml"
"jdk.unsupported"
];
};
in
sbt-derivation.mkSbtDerivation.${system} {
# ...
installPhase = ''
mkdir -p $out/bin
mkdir -p $out/share/java
cp src/app/target/scala-3.*/*.jar $out/share/java
# Use custom JRE 👇
makeWrapper ${jre}/bin/java $out/bin/scala-app \
--add-flags "-cp \"$out/share/java/*\" org.app.Application"
'';
};
in
# ...
Let's build:
aiono ❯ nix-build
aiono ❯ cat result | docker load
aiono ❯ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/app-container latest 54a30dcb4708 55 years ago 596 MB
Got a bit bigger, but hopefully at least it runs:
aiono ❯ docker run --expose 4041 --network host localhost/app-container:latest
Server is listening at '/127.0.0.1:4041'
Yes! It successfully runs now.
While we made some progress, still it's far away from an appropriate size. The bulk of the size still comes from the JRE.
So I kept reading around, I noticed that I didn't notice an important part of the jre_minimal
derivation. While I optimized the modules we need, I didn't pick an appropriate JDK. We can override the jdk
attribute of the derivation for that.
let
repository = builtins.fetchTarball {
url = "https://github.com/zaninime/sbt-derivation/archive/master.tar.gz";
};
sbt-derivation = import "${repository}/overlay.nix";
app = let
jre = pkgs.jre_minimal.override {
modules = [
"java.base"
"java.desktop"
"java.logging"
"java.management"
"java.naming"
"java.security.jgss"
"java.security.sasl"
"java.sql"
"java.transaction.xa"
"java.xml"jdk to J
"jdk.unsupported"
];
# Set JDK to headless 👇
jdk = pkgs.jdk21_headless;
};
in
...
Let's build our image again:
aiono ❯ nix-build
aiono ❯ cat result | docker load
aiono ❯ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/app-container latest ed070c56e715 55 years ago 239 MB
239 MB! That seems promising. Let's run and hopefully it doesn't crash:
aiono ❯ docker run --expose 4041 --network host localhost/app-container:latest
Server is listening at '/127.0.0.1:4041'
Great! We have come a long way from 722 MB to 239 MB.
dockerTools.copyToRoot
Gotchas
I wrote before that the application JAR is duplicated. It appears both under /share
and /nix/store
paths in the file system of the container. Let's look into how we defined our container image:
let
# ...
app-container = pkgs.dockerTools.buildImage {
name = "app-container";
tag = "latest";
# What does it do 🤔
copyToRoot = [ app pkgs.busybox ];
config = { Cmd = [ "/bin/${app.pname}" ]; };
};
in
# ...
We duplicated the JAR because we tell Nix to copy the contents of the packages given in copyToRoot
to the root of the container image. What we actually want to do is to put symlinks to the root that point to /nix/store
because everything we need is already there.
The following change fixes the problem:
let
# ...
app-container = pkgs.dockerTools.buildImage {
name = "app-container";
tag = "latest";
# Use buildEnv 👇
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ app pkgs.busybox ];
pathsToLink = [ "/bin" ];
};
config = { Cmd = [ "/bin/${app.pname}" ]; };
};
in
# ...
There are two changes we made here:
- We wrapped the packages with
pkgs.buildEnv
which allows us to generate symlinks to/nix/store
via thepathsToLink
attribute. - We only included
/bin
inpathsToLink
so everything else (such as/share
) won't be put to the root of the image from the packages.
For some reason, pkgs.buildEnv
is heavily underdocumented. The best documentation I could find was here which doesn't even exclusively talk about buildEnv
. But essentially it's a simpler version of mkShell
. Using it we can create an environment with the packages we want. In our case the important part is that it allows us to generate symlinks to the actual content in the /nix/store
.
Again, let's build the image to see its size:
aiono ❯ nix-build
aiono ❯ cat result | docker load
aiono ❯ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/app-container latest 5cfef9f22c2a 55 years ago 198 MB
We got down to 198 MB from 239 MB.
Comparison with other approaches
After I had something I considered acceptable, I wanted to compare it with other approaches. By following this article I was able to build an image with size of 123 MB, which is better than 198 MB but I think it's not that bad. Also, with this approach the filesystem is a lot more cluttered and there are many unwanted binaries lying in /bin
. Apart from the size these can cause complications. But this approach shows that there are still things I could possibly improve.
Compared to other articles like https://zendesk.engineering/using-nix-to-develop-and-package-a-scala-project-cadccd56ad06 (568 MB) and https://dev.to/fialhorenato/how-to-create-slim-docker-java-images-using-a-minimal-jre-3a20 (349 MB) this approach leads to much smaller images.
Final derivation
In the end, we have the following derivation:
let
repository = builtins.fetchTarball {
url = "https://github.com/zaninime/sbt-derivation/archive/master.tar.gz";
};
sbt-derivation = import "${repository}/overlay.nix";
app = let
jre = pkgs.jre_minimal.override {
# NOTE: What you need to put here depends on your application dependencies
modules = [
"java.base"
"java.desktop"
"java.logging"
"java.management"
"java.naming"
"java.security.jgss"
"java.security.sasl"
"java.sql"
"java.transaction.xa"
"java.xml"
"jdk.unsupported"
# These modules are necessary for establishing SSL connections.
# Otherwise I get "javax.net.ssl.SSLHandshakeException: Received fatal alert: insufficient_security"
"jdk.crypto.ec"
"jdk.crypto.cryptoki"
];
jdk = pkgs.jdk21_headless;
}; in
sbt-derivation.mkSbtDerivation.${system} {
pname = "app";
version = "0.0.1";
src = ./.;
depsSha256 = "sha256-06Qog8DyDgisnBhUQ9wW46WqqnhGXlakI1DSuFHkriQ=";
buildInputs = with pkgs; [ makeWrapper ];
buildPhase = "sbt assembly";
installPhase = ''
mkdir -p $out/bin
mkdir -p $out/share/java
cp src/app/target/scala-3.*/*.jar $out/share/java
makeWrapper ${jre}/bin/java $out/bin/scala-app \
--set JAVA_HOME ${jre} \
--add-flags "-cp \"$out/share/java/*\" org.app.Application"
'';
};
app-container = pkgs.dockerTools.buildImage {
name = "app-container";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ app pkgs.busybox ];
pathsToLink = [ "/bin" ];
};
config = { Cmd = [ "/bin/${app.pname}" ]; };
}; in
app-container
Conclusion
In conclusion, I was able to reduce the container size from 722 MB to 198 MB with the changes I mentioned. Thanks to Nix, creating a minimal image is really convenient because the build system does the most of the heavy lifting to figure out the necessary packages. Java also has very good tooling to create a minimal JRE, just enough for the application to run. I believe there is still room for improvement to reduce the size, but it is already good enough for my use case.
With modern tools and workflows we easily forget how much waste we produce because most of the time it's not noticeable unless you are looking for it. Some of the waste makes sense, memory and CPU are not the only resources we have, developer time and time to implement new changes are also very valuable resources which a lot of the times more important than hardware resources. But still I think it's worthwhile to spend some time for reducing the waste and inefficiency in our software. These times are opportunities to learn new things, and also it can be helpful for other resources along with hardware efficiency.