
Table of Contents
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.
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.
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 Image | Typical Size | Shell Access | Package Manager | Best For |
|---|---|---|---|---|
| gcr.io/distroless/java21-debian12:nonroot | ~90MB | No | No | Production security-first deployments |
| eclipse-temurin:21-jre-alpine | ~120MB | Yes (ash) | apk | Teams needing runtime debugging |
| amazoncorretto:21-alpine | ~130MB | Yes (ash) | apk | AWS-native workloads with Corretto optimizations |
| eclipse-temurin:21-jre-jammy | ~220MB | Yes (bash) | apt | Legacy 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.
- Copy dependency manifests first: Place
build.sbtandproject/*.sbtfiles before source code. Runsbt updateimmediately after to cache the dependency resolution layer. - Separate compilation from assembly: Run
sbt compilein its own layer if you want faster feedback loops during development builds. Assembly can reuse compiled classes without recompilation. - Use .dockerignore aggressively: Exclude
.git,target, IDE files, and documentation. Every byte copied unnecessarily invalidates downstream caches. - 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.
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.