Shrink Scala Docker Images

Khimananda Oli 9 min read Programming and Languages
Shrink Scala Docker Images

By Khimananda Oli | Last reviewed: August 2026

Default Scala Docker images often exceed 800MB because they bundle the full JDK, build tools, and uncompressed dependencies, causing slow CI pipelines and inflated cloud bills. To effectively shrink Scala Docker images, you must decouple the build environment from the runtime using multi-stage builds, generate a single fat JAR with sbt-assembly, and select a minimal base like Eclipse Temurin or Google Distroless. This approach routinely reduces image footprints by over 90% while maintaining full application functionality and improving security posture.

Source Codebuild.sbt + src/DependenciesBuild Stage (JDK)eclipse-temurin:21-jdksbt compilesbt assembly~800MB LayerFat JAR Artifacttarget/app.jar~30MB Single FileRuntime Stagedistroless/java21COPY app.jarFinal: ~90MB
Multi-stage architecture to shrink Scala Docker images: build artifacts are isolated from the minimal production runtime

How do you configure multi-stage builds to shrink Scala Docker images?

Multi-stage builds are the single most effective technique to shrink Scala Docker images. The core principle is simple: use a heavy image containing the JDK and sbt toolchain to compile your application, then discard it entirely. Only the compiled bytecode artifact gets copied into a lightweight runtime image. This separation ensures your production container never carries compilers, source code, or build caches.

Defining the build stage correctly

Your first stage should use an official JDK image matching your target Scala version. For Scala 3.x on Java 21, eclipse-temurin:21-jdk-alpine provides a good balance of compatibility and size. Avoid using generic openjdk tags; always pin specific versions for reproducible builds. Install sbt via the package manager rather than downloading scripts at build time to leverage layer caching effectively.

# Build stage
FROM eclipse-temurin:21-jdk-alpine AS builder
RUN apk add --no-cache bash curl
WORKDIR /app
COPY project/build.properties project/plugins.sbt ./project/
COPY build.sbt .
RUN sbt update
COPY src ./src
RUN sbt assembly

A common mistake is copying the entire source tree before running dependency resolution. By copying only build.sbt and the project/ directory first, then running sbt update, you create a cached layer for dependencies. Source changes won't invalidate this expensive step unless your dependency graph actually changes. This pattern is critical when you need to shrink Scala Docker images in CI environments where build speed matters as much as final size.

Configuring the runtime stage

The second stage starts fresh with a minimal base. Copy only the assembled JAR from the builder stage. Never copy the entire target/ directory, which contains test classes, intermediate compilation outputs, and metadata. Set explicit user permissions and working directories to follow security best practices aligned with container image scanning standards.

# Runtime stage
FROM gcr.io/distroless/java21-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=builder /app/target/scala-3.*/app-assembly.jar ./app.jar
USER nonroot:nonroot
ENTRYPOINT ["java", "-jar", "app.jar"]

This configuration produces a read-only, rootless container with no shell or package manager. If you need debugging capabilities temporarily, maintain a separate debug tag using the standard distroless image with busybox included. Production deployments should always use the :nonroot variant to minimize attack surface.

Why does sbt-assembly matter when you shrink Scala Docker images?

Without sbt-assembly, your Dockerfile must copy hundreds of individual JAR files from the Ivy cache or Coursier directory. Each file becomes a separate filesystem entry, increasing inode usage and preventing optimal layer compression. More critically, managing classpaths across dozens of volumes adds complexity and failure points. The sbt-assembly plugin solves this by merging all dependencies and application classes into a single executable JAR.

Configuring merge strategies for fat JARs

Naive assembly configurations fail when multiple dependencies include conflicting resources like reference.conf or service loader files. You must define explicit merge strategies in build.sbt to handle these collisions deterministically. Concatenation works for configuration files; deduplication handles license texts; exclusion removes unnecessary metadata.

// build.sbt assembly configuration
assembly / assemblyMergeStrategy := {
  case PathList("META-INF", "services", _*) => MergeStrategy.concat
  case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard
  case PathList("META-INF", _*)            => MergeStrategy.discard
  case "reference.conf"                    => MergeStrategy.concat
  case _                                   => MergeStrategy.first
}

assembly / assemblyJarName := "app-assembly.jar"
assembly / test := {}

Disabling tests during assembly (assembly / test := {}) prevents redundant test execution in CI pipelines where testing already occurred in a prior stage. This alone can shave minutes off builds when you repeatedly shrink Scala Docker images during development iterations. Always verify merge behavior locally before relying on it in automated pipelines.

Handling native libraries and platform-specific deps

Some Scala libraries bundle native binaries for multiple architectures. When targeting Linux containers exclusively, exclude Windows and macOS natives to reduce JAR size. Use assembly filters or custom merge strategies to strip unused platform binaries. For projects using Netty, RocksDB, or similar libraries with native components, consult their documentation for container-specific exclusion patterns.

App Classescom.example.*Dependency Areference.confDependency Breference.confMerge Strategiesconcat: reference.confdiscard: META-INF/*first: conflictsexclude: natives/win*Fat JAR OutputSingle executableMerged configsNo duplicatesLinux-only natives~30MBReady forDocker COPY
sbt-assembly merge strategy workflow: resolving conflicts and stripping unnecessary files to produce minimal fat JARs

Which base image helps most when you shrink Scala Docker images?

Base image selection determines your minimum achievable size and security baseline. Three options dominate production Scala deployments in 2026, each with distinct trade-offs between size, compatibility, and operational flexibility. Your choice depends on whether you prioritize absolute minimal footprint, debugging capability, or ecosystem support.

Base ImageTypical SizeShell AccessPackage ManagerBest For
gcr.io/distroless/java21-debian12:nonroot~90MBNoNoProduction security-first deployments
eclipse-temurin:21-jre-alpine~120MBYes (ash)apkTeams needing runtime debugging
amazoncorretto:21-alpine~130MBYes (ash)apkAWS-native workloads with Corretto optimizations
eclipse-temurin:21-jre-jammy~220MBYes (bash)aptLegacy apps requiring glibc compatibility

Distroless images remove everything except the JRE and essential system libraries. There is no shell, no package manager, and no user accounts beyond nonroot. This eliminates entire categories of CVEs and satisfies strict compliance requirements for SOC 2 and ISO 27001 audits. However, troubleshooting requires attaching debug sidecars or using ephemeral debug containers in Kubernetes. For teams new to distroless, start with Alpine-based Temurin images and migrate once your observability stack matures. See our guide on Kubernetes secrets management for complementary security practices.

Understanding JVM ergonomics in minimal containers

Minimal base images sometimes lack timezone data, locale settings, or cryptographic providers that Scala applications expect. If your application throws ZoneRulesProvider exceptions or TLS handshake failures in distroless, you likely need to explicitly add tzdata or specify security providers in your JAR manifest. Test thoroughly in staging before adopting distroless in production. Most modern Scala frameworks handle these edge cases gracefully, but legacy codebases may require adjustments.

How do you optimize layers and caching to shrink Scala Docker images faster?

Image size isn't the only metric that matters; build speed directly impacts developer productivity and CI costs. Optimizing layer ordering ensures that expensive operations like dependency downloads only rerun when necessary. Each instruction in your Dockerfile creates a new layer, and Docker caches layers until an instruction changes. Structure your Dockerfile to maximize cache hits.

  1. Copy dependency manifests first: Place build.sbt and project/*.sbt files before source code. Run sbt update immediately after to cache the dependency resolution layer.
  2. Separate compilation from assembly: Run sbt compile in its own layer if you want faster feedback loops during development builds. Assembly can reuse compiled classes without recompilation.
  3. Use .dockerignore aggressively: Exclude .git, target, IDE files, and documentation. Every byte copied unnecessarily invalidates downstream caches.
  4. Leverage BuildKit cache mounts: Mount Coursier and sbt cache directories as persistent volumes across builds. This avoids re-downloading dependencies even when the dependency layer is invalidated.
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk-alpine AS builder
RUN apk add --no-cache bash
WORKDIR /app
COPY project/build.properties project/plugins.sbt ./project/
COPY build.sbt .
RUN --mount=type=cache,target=/root/.cache/coursier \
    --mount=type=cache,target=/root/.sbt \
    sbt update
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/coursier \
    --mount=type=cache,target=/root/.sbt \
    sbt assembly

BuildKit cache mounts persist across builds even when layers are rebuilt, dramatically speeding up iterative development. Enable BuildKit by setting DOCKER_BUILDKIT=1 or configuring it as default in your daemon settings. This technique pairs exceptionally well with CI build caching strategies for end-to-end pipeline optimization.

Verifying image contents and size regressions

Add automated checks to prevent size creep. Use dive or crane in CI to inspect layer contents and enforce size budgets. Fail builds that exceed thresholds or introduce unexpected files. Track image size as a metric alongside test coverage. Over months of development, small regressions compound; automated gates catch them before they reach production.

Naive Build850MBFull JDK+ Source+ All DepsAlpine JRE280MBSlim JRE+ Fat JARTemurin Alpine120MBOptimized JRE+ Merged JARDistroless90MBMinimal Runtime+ Stripped JARProgressive Optimization →
Size reduction comparison: progressive optimization steps to shrink Scala Docker images from 850MB to 90MB

What security benefits come from smaller Scala containers?

Reducing image size inherently reduces attack surface. Fewer packages mean fewer CVEs to track and patch. Distroless images eliminate shells and package managers that attackers exploit for lateral movement. Smaller images also scan faster with tools like Trivy or Grype, enabling more frequent security checks without blocking pipelines. In regulated environments handling financial or health data, minimal containers simplify compliance evidence collection for SOC 2 automation.

Beyond vulnerability reduction, minimal containers enforce immutability. Without package managers, operators cannot install ad-hoc debugging tools that alter runtime behavior. Configuration drift becomes impossible. Debugging happens through structured logs, metrics, and traces rather than interactive shell sessions. This discipline improves reliability and makes incidents easier to reproduce. Pair minimal images with read-only root filesystems and dropped Linux capabilities for defense-in-depth.

Shrink Scala Docker Images for Production Success

Implementing these techniques transforms bloated 800MB Scala containers into lean, secure artifacts under 100MB. Start with multi-stage builds and sbt-assembly as your foundation, then graduate to distroless bases once your observability supports shell-less debugging. Measure image size in every CI run and treat regressions as bugs. The cumulative effect is faster deployments, lower egress costs, and reduced compliance burden. If your team needs help optimizing container workflows or establishing audit-ready infrastructure, reach out to discuss your specific requirements.

Frequently Asked Questions

Eclipse Temurin JRE Alpine or UBI Micro are currently the smallest production-ready bases. They provide necessary glibc compatibility while keeping uncompressed layer sizes under 90MB, unlike standard Debian or Ubuntu images which often exceed 300MB before adding application artifacts.

It creates a single fat JAR containing all dependencies, eliminating the need to copy entire Maven or Ivy cache directories into the container. This reduces layer count and allows Docker to cache the dependency layer separately from frequently changing application code layers.

Yes, native images eliminate the JVM entirely, producing standalone binaries often under 50MB. However, Scala reflection and macro usage require extensive configuration hints, making builds complex and potentially fragile compared to standard JIT-compiled JAR deployments on minimal JRE bases.

You are likely copying build tools, source code, or full JDKs into the final stage. Use multi-stage builds to compile in a heavy SDK image and copy only the assembled JAR or native binary to the runtime stage, discarding all intermediate build artifacts permanently.

No, Scala runs on the JVM so native symbol stripping has negligible effect. Focus instead on excluding transitive test dependencies, removing unused modules via sbt-assembly merge strategies, and deleting unnecessary locale data or timezone files from the base JRE layer during build.

Absolutely. Jlink creates custom runtime images containing only required Java modules. For typical Scala web apps, this can reduce JRE footprint by 40-60% compared to full distributions, though you must explicitly include java.sql and other modules that Scala libraries implicitly depend on.

Use MergeStrategy.discard for META-INF license files, signatures, and module-info.class duplicates. Configure first or last strategies for conflicting service loader files. Excluding these metadata artifacts prevents duplicate entries that unnecessarily inflate the final assembly JAR size by several megabytes.

Run dive or docker-squash to inspect individual layer sizes and file trees. These tools reveal hidden bloat like cached package manager indexes, temporary compilation outputs, or redundant library copies that standard docker history commands cannot display with sufficient granularity for optimization decisions.

Yes, Google distroless Java images remove shells, package managers, and system utilities, reducing attack surface and size simultaneously. They work well with Scala fat JARs but complicate debugging since no interactive shell exists; use ephemeral debug containers in Kubernetes 2026 for troubleshooting instead.

CDS does not shrink the image itself but reduces startup memory and time by pre-processing class metadata. Generate shared archives during the Docker build phase and include them in the final image to improve runtime efficiency without increasing storage footprint beyond the archive file size.

Typically 50-70% smaller. Alpine uses musl libc resulting in base images around 5MB plus JRE, while Debian slim starts near 80MB. Test thoroughly though, as some Scala JNI libraries assume glibc and fail silently or crash on musl-based Alpine systems.

Rarely worth the effort for server-side Scala. These tools target Android and struggle with Scala's complex bytecode patterns, reflection usage, and implicit conversions. Sbt-assembly exclusion rules and careful dependency management achieve better size reductions with significantly lower risk of runtime failures.

Copy dependency JARs first in a separate layer, then copy application classes last. This ensures rebuilding after code changes reuses the cached dependency layer, speeding up CI pipelines dramatically even if total image size remains unchanged across builds.

Minimally. The -Yno-generic-signatures flag slightly reduces class file metadata, saving kilobytes not megabytes. Real size gains come from runtime optimization and dependency pruning, not compiler flags. Focus optimization efforts on the deployment artifact rather than compilation output characteristics.

Add a post-build step comparing new image size against a baseline threshold using docker image inspect formatting. Fail the pipeline if size increases exceed 5%, forcing developers to investigate regressions immediately rather than accumulating bloat gradually across multiple pull requests over time.