Dockerize a Scala App with Multi-Stage Builds

Khimananda Oli 6 min read Programming and Languages
Dockerize a Scala App with Multi-Stage Builds

By Khimananda Oli | Last reviewed: August 2026

Shipping JVM applications often results in bloated containers that slow down deployments and expand your attack surface. When you Dockerize a Scala app with multi-stage builds, you separate the heavy compilation environment from the lean runtime, producing artifacts that are both secure and fast to deploy. This approach is standard for modern teams managing microservices on Kubernetes or ECS. For broader context on optimizing these workflows, see our guide on how to reduce Docker image size with multi-stage builds.

Stage 1: Build (SDK)Source Code + SBTCompile & TestFat JAR / Staged DirStage 2: Runtime (JRE)COPY --from=builderMinimal Production ImageResultsSize: ~180MB vs 900MBNo SBT/JDK/GitReduced CVE SurfaceFaster K8s Pulls
Multi-stage build architecture isolating Scala compilation from the production runtime environment

How do you configure a Dockerfile to Dockerize a Scala app with multi-stage builds?

The core mechanism relies on naming your first stage and referencing it in the second. For Scala projects using sbt, the most reliable pattern involves generating a self-contained artifact in the builder stage and copying only that artifact forward. Avoid copying raw source code into the runtime stage, as this defeats the purpose of layer separation and bloats the final image with unnecessary metadata.

Defining the Builder Stage

Your builder stage needs a full JDK and sbt. While some teams install sbt manually, using a base image that includes it or installing it via package manager ensures reproducibility. In 2026, Eclipse Temurin remains the preferred OpenJDK distribution due to its consistent licensing and multi-arch support. Always pin specific versions rather than using latest tags to prevent surprise breakages during CI runs.

# Stage 1: Builder
FROM eclipse-temurin:21-jdk AS builder

# Install sbt (example for Debian-based JDK images)
RUN apt-get update && apt-get install -y curl gnupg && \
    echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | tee /etc/apt/sources.list.d/sbt.list && \
    curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | apt-key add && \
    apt-get update && apt-get install -y sbt

WORKDIR /app
COPY project/build.properties project/plugins.sbt ./project/
COPY build.sbt .
RUN sbt update

COPY src ./src
RUN sbt assembly

This sequence leverages Docker layer caching effectively. By copying dependency files (build.sbt, project/) before source code, the expensive sbt update step only reruns when dependencies actually change. Source code changes, which happen frequently, trigger only the final compilation layer. This optimization alone can cut CI build times by minutes per commit.

Defining the Runtime Stage

The runtime stage should contain nothing but the JRE and your application binary. Use COPY --from=builder to extract the assembled JAR. Set explicit user permissions and avoid running as root, which is critical for passing security audits like SOC 2 or ISO 27001.

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app

COPY --from=builder /app/target/scala-*/my-app-assembly.jar app.jar

USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Note the use of Alpine Linux here. While glibc-based distributions like Ubuntu or Debian are safer for certain native libraries, Alpine’s musl libc keeps the base image under 80MB. If your Scala application depends on JNI libraries that require glibc, swap jre-alpine for jre-jammy and adjust the user creation commands accordingly. The trade-off between size and compatibility must be validated against your specific dependency tree.

Why should you use sbt-native-packager instead of manual assembly?

While sbt-assembly produces a single fat JAR, it creates operational friction at scale. Fat JARs cannot leverage Docker layer caching for dependencies; any code change forces a complete re-push of the entire artifact. The sbt-native-packager plugin solves this by staging dependencies into separate directories, enabling granular layer caching and more sophisticated startup scripts.

  • Layer Caching: Dependencies rarely change compared to business logic. Native packager separates them, so rebuilding after a code change only adds a thin layer containing your classes.
  • Startup Scripts: Automatically generates bash/bat scripts with sensible JVM defaults, signal handling, and PID management, reducing boilerplate in your ENTRYPOINT.
  • Docker Integration: Provides built-in tasks like Docker/publishLocal that generate optimized multi-stage Dockerfiles automatically based on your build configuration.
  • Security Compliance: Facilitates non-root execution patterns and proper file permission mapping out of the box, aligning with container hardening standards.

To enable this, add the plugin to project/plugins.sbt and configure your build.sbt:

// project/plugins.sbt
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4")

// build.sbt
enablePlugins(JavaAppPackaging, DockerPlugin)

Docker / packageName := "my-scala-service"
Docker / maintainer := "[email protected]"
dockerBaseImage := "eclipse-temurin:21-jre-alpine"
dockerExposedPorts := Seq(8080)
dockerUpdateLatest := true

Running sbt Docker/publishLocal now generates a production-ready image without writing a single line of Dockerfile manually. However, understanding the underlying Dockerfile structure remains essential for debugging and customization. Teams working with complex observability requirements should also review instrumenting apps with OpenTelemetry to ensure agents are correctly injected into these staged builds.

Traditional: sbt-assemblyAll Deps + App Code MergedSingle 150MB Fat JAR LayerCode Change = Full RebuildCache Invalidated EntirelyPush 150MB Every DeploySlow Rollouts, High BandwidthOptimized: native-packagerLayer 1: Dependencies (Cached)Layer 2: Config/ResourcesLayer 3: App Classes (Thin)Code Change = New Thin Layer OnlyDeps Layer Reused From CachePush <5MB Per Deploy
Comparison of layer caching efficiency between fat JAR and staged directory approaches

How does multi-stage building impact Scala container performance and security?

The difference between a naive single-stage build and a properly configured multi-stage build is measurable across three dimensions: storage cost, deployment velocity, and compliance posture. In production environments serving Nepali and global audiences alike, these metrics directly affect user experience and operational budgets.

MetricSingle-Stage (Naive)Multi-Stage (Optimized)Impact
Final Image Size850–1,200 MB160–220 MB~80% reduction in registry storage and pull time
CVE ExposureHigh (includes gcc, git, python, sbt)Low (JRE + app only)Fewer false positives in Trivy/Grype scans
Cold Start TimeSlower (larger filesystem overlay)Faster (minimal layers to mount)Improved autoscaling responsiveness
Build Cache EfficiencyHigh (granular dependency separation)CI feedback loop reduced by 30–60%
Compliance ReadinessRequires extensive justificationAligns with CIS BenchmarksSmoother SOC 2 / ISO 27001 audits

Security teams consistently flag single-stage JVM images because they retain compilers, shell utilities, and package managers. These tools provide attackers with post-exploitation capabilities if your application is compromised. Multi-stage builds enforce a clean separation that satisfies the principle of least privilege at the infrastructure level. When combined with read-only root filesystems and non-root users, this pattern forms the foundation of defensible container security.

What are common mistakes when Dockerizing Scala applications?

Even experienced engineers stumble over subtle issues specific to the Scala ecosystem. Avoiding these pitfalls saves hours of debugging in staging environments.

  1. Ignoring .dockerignore: Without a proper .dockerignore, your build context includes target/, .git/, and IDE files. This inflates the initial COPY operation and can leak secrets or stale artifacts into the builder stage. Always exclude everything except source and build definitions.
  2. Using latest Tags: Base images change without notice. A JDK update might introduce behavioral changes or break native library compatibility. Pin to specific digests or semantic versions (e.g., eclipse-temurin:21.0.3_9-jre-alpine) and update deliberately.
  3. Running as Root: Default Docker behavior runs processes as UID 0. This violates virtually every container security benchmark. Always create a dedicated user in the runtime stage and switch via USER before the ENTRYPOINT.
  4. Missing Health Checks: Containers without HEALTHCHECK directives rely solely on process exit codes. Add JVM-aware health endpoints and configure Docker to poll them, ensuring orchestrators detect application-level failures, not just crashes.
  5. Overlooking JVM Memory Flags: Container runtimes don’t automatically communicate memory limits to the JVM. Use -XX:+UseContainerSupport (enabled by default in JDK 10+) and set explicit heap percentages relative to container limits to prevent OOM kills.

For teams managing database-backed Scala services, ensuring connection pool resilience within containers is equally important. Review PostgreSQL administration essentials to align your application’s database interaction patterns with container lifecycle constraints.

Start: Choose Base ImageUses JNI / Native Libs?NOYESAlpine (musl)✓ Smallest footprint (~80MB)✓ Fewer CVEs⚠ Test thoroughlyUbuntu/Debian (glibc)✓ Full compatibility✓ Predictable behavior⚠ Larger size (~200MB)Recommended DefaultUse When Required
Decision framework for selecting Alpine versus glibc-based JRE images for Scala containers

Deploying Your Optimized Scala Container

When you Dockerize a Scala app with multi-stage builds correctly, the resulting artifact integrates cleanly with modern orchestration platforms. Whether deploying to Amazon EKS, Azure AKS, or a local Kubernetes cluster via k3s, the principles remain identical: immutable artifacts, minimal privileges, and observable behavior. Remember to configure resource requests and limits based on actual JVM profiling, not guesses. For teams implementing progressive delivery, these lean images pair exceptionally well with blue-green and canary deployment strategies, as smaller images reduce the window of vulnerability during traffic shifting.

If you’re refining your team’s containerization practices or need an audit of your current Scala deployment pipeline, reach out to discuss your infrastructure. Practical optimization starts with accurate baselines and ends with automated enforcement.

Frequently Asked Questions

Multi-stage builds separate compilation from runtime, producing significantly smaller final images. This reduces attack surface and deployment time by excluding JDK, sbt, and source code from the production container.

Use eclipse-temurin:21-jdk-alpine for building and eclipse-temurin:21-jre-alpine for runtime. Alpine keeps images small while Temurin provides stable, long-term support for modern Scala versions running on JVM 21.

Copy build.sbt and project files first, then run sbt update before copying source code. This layer caching prevents re-downloading dependencies on every code change, dramatically speeding up CI pipeline build times.

Yes. Run sbt docker:stage in the build stage to generate optimized scripts and layouts. Copy only the staged output directory to the runtime stage, avoiding full sbt installation in production images entirely.

Optimized multi-stage Scala images typically range between 80MB and 150MB depending on dependencies. Unoptimized single-stage builds often exceed 800MB due to included build tools and full JDK distributions.

Build the fat JAR using sbt assembly in the first stage, then copy only the resulting JAR file to the runtime stage. This avoids carrying intermediate compilation artifacts and reduces final image bloat significantly.

Absolutely. Running jlink in the build stage creates a custom JRE containing only required modules. This can reduce runtime base image size by 40-60% compared to standard JRE distributions for Scala applications.

Never bake configuration into images. Use environment variables, mounted config files, or external secret managers. Keep the Docker image immutable across staging and production environments for reliable deployments.

sbt compilation is memory-intensive. Add JAVA_OPTS with -Xmx2g or higher to the RUN instruction in your build stage. Insufficient heap space during compilation is the most common build failure cause.

Use docker build --progress=plain to see full output. Temporarily add RUN ls commands after each COPY to verify file paths. Check sbt logs carefully since errors often occur during dependency resolution or compilation phases.

Yes, but requires careful testing. Native images eliminate JVM overhead and produce sub-50MB containers with instant startup. However, reflection-heavy Scala libraries may need extensive configuration hints to work correctly at runtime.

Scan images with trivy or grype in CI pipelines. Use minimal base images, run as non-root user, and regularly update dependencies. Multi-stage builds inherently improve security by excluding build tools from production containers.

Limited parallelization helps. Use sbt concurrentRestrictions to control thread usage matching container CPU limits. Over-parallelizing causes memory pressure and slower builds due to garbage collection overhead in constrained environments.

Order layers by change frequency: base image, system packages, sbt plugins, build.sbt, project files, source code. This maximizes cache hits since source changes most frequently while dependencies remain stable longer.

Implement HTTP health endpoints returning 200 when ready. Configure HEALTHCHECK with appropriate intervals considering JVM warmup time. Avoid TCP-only checks since they pass before application initialization completes successfully.