Containerizing a Spring Boot App

Reviewed & published by Brayan K

Package your app into a portable image with a Dockerfile, speed up rebuilds with layered jars, skip the Dockerfile entirely using buildpacks, and configure everything through environment variables.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Before You Start

You should be able to build a runnable Spring Boot jar and understand its profiles and external configuration. Basic familiarity with the command line and Docker concepts helps.

What You'll Learn

The Big Idea — A Shipping Container for Your App

💡 Analogy: A Docker image is a standardized shipping container: your app, its JRE, and everything it needs are sealed inside, so it runs the same on your laptop, a colleague's machine, or a cloud server. The Dockerfile is the packing list. Layered jars are like packing the heavy, unchanging machinery at the bottom and your daily paperwork on top — when only the paperwork changes, you don't repack the whole container. Buildpacks are a professional packing service that loads the container correctly for you. And the environment variables are the shipping label that tells the same container which port to dock at.

Build the container once; the label (env vars) decides where and how it runs.

1️⃣ Config From the Environment

Containers follow the twelve-factor rule: one immutable image, configured at runtime via environment variables. Spring Boot reads them automatically; the demo mimics that lookup-with-default.

public class Main {
    // Containers follow TWELVE-FACTOR config: the SAME image runs everywhere, and
    // settings come from ENVIRONMENT VARIABLES at runtime — never baked in. Spring
    // Boot reads env vars automatically; we mimic that lookup-with-default here.
    static String env(String key, String defaultValue) {
        String v = System.getenv(key);
        return (v == null || v.isBlank()) ? defaultValue : v;
    }

    public static void main(String[] args) {
        // In a container you'd set these with: docker run -e PORT=9090 ...
        String port = env("PORT", "8080");
        String dbUrl = env("SPRING_DATASOURCE_URL", "jdbc:h2:mem:dev");

        System.out.println("listening on port: " + port);
        System.out.println("datasource url   : " + dbUrl);
        System.out.println("same image, different env -> different config");
    }
}

2️⃣ Layered Jars and the Build Cache

Docker caches image layers by content. Put rarely-changing dependencies in their own layers and your frequently-changing app code last, so a routine code change only rebuilds the small top layer. The demo models that cache decision.

3️⃣ A Layered, Multi-Stage Dockerfile

Build with the JDK, run on a slim JRE. Extract the Spring Boot jar into layers and copy the least-changing ones first to maximize cache hits.

FROM eclipse-temurin:21-jre
COPY --from=builder /app/dependencies/ ./    # rarely changes -> cached
COPY --from=builder /app/application/ ./     # your code -> copied last
# A LAYERED Dockerfile for a Spring Boot app. Multi-stage: build with the JDK,
# run on a slim JRE. Layering deps separately maximizes Docker's cache hits.

# --- stage 1: extract the layered jar ---
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY target/app.jar app.jar
# Spring Boot can split the jar into layers for better caching:
RUN java -Djarmode=layertools -jar app.jar extract

# --- stage 2: lean runtime image (JRE only, no compiler) ---
FROM eclipse-temurin:21-jre
WORKDIR /app
# Copy least-changing layers FIRST so they stay cached across rebuilds:
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./           # your code: copied LAST

EXPOSE 8080                                        # documents the port
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

4️⃣ No Dockerfile Needed — Buildpacks

Spring Boot's spring-boot:build-image (Maven) and bootBuildImage (Gradle) use Cloud Native Buildpacks to produce an optimized, layered, non-root image — no Dockerfile to maintain.

# THE EASY WAY: Cloud Native Buildpacks. No Dockerfile to write or maintain —
# Spring Boot's plugin builds an optimized, layered OCI image for you.

# Maven:
mvn spring-boot:build-image -Dspring-boot.build-image.imageName=demo/orders:1.0

# Gradle:
./gradlew bootBuildImage --imageName=demo/orders:1.0

# Then run it, supplying config via environment variables (NOT baked in):
docker run --rm -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/orders \
  demo/orders:1.0

# Buildpacks pick a sensible JRE, layer the app, and run as a NON-ROOT user
# automatically — best practices without a hand-written Dockerfile.

🧠 Quick Recall

Predict before you peek.

Answer: the application layer (your own code) — it changes most often, so it's the top, most-frequently-rebuilt layer.

Answer: use buildpacks via mvn spring-boot:build-image or ./gradlew bootBuildImage.

Answer: from environment variables at runtime (e.g. SPRING_DATASOURCE_URL binds to spring.datasource.url).

🎯 YOUR TURN — Env Config With Defaults

Implement env(key, default) and print the resolved profile and port.

public class Main {
    // YOUR TURN: read config from the environment with sensible defaults, the way
    // a containerized Spring Boot app does. Implement env(key, default) and print
    // the resolved profile and port.
    //
    // Expected output (when no env vars are set):
    //   profile: default
    //   port: 8080

    // static String env(String key, String def) { ... }

    public static void main(String[] args) {
        // System.out.println("profile: " + env("SPRING_PROFILES_ACTIVE", "default"));
        // System.out.println("port: " + env("PORT", "8080"));
    }
}

🧩 MINI-CHALLENGE — Simulate the Layer Cache

Given the first changed layer index, mark every layer from there onward as REBUILD and count them.

import java.util.*;

public class Main {
    // MINI-CHALLENGE: simulate Docker's layer cache. Given the layers (in order)
    // and the index of the FIRST changed layer, every layer from that index
    // onward must be rebuilt (cache is invalidated downward); earlier ones are
    // cached. Print each layer's status and the rebuild count.
    //
    // Expected output:
    //   dependencies: cached
    //   loader: cached
    //   application: REBUILD
    //   rebuilt: 1

    public static void main(String[] args) {
        List<String> layers = List.of("dependencies", "loader", "application");
        int firstChanged = 2;   // index of the first changed layer
        // For each index i: cached if i < firstChanged else REBUILD.
        // Count and print the rebuilds.
    }
}

Common Errors

Pro Tips

📋 Quick Reference

ConceptSyntax / ideaNote
base imageFROM eclipse-temurin:21-jreSlim runtime
copy artifactCOPY target/app.jar app.jarOr layered dirs
run commandENTRYPOINT ["java","-jar",...]Startup process
port docEXPOSE 8080Publish with -p
layered jar-Djarmode=layertools extractBetter caching
layer orderdeps first, app lastMax cache hits
buildpacks (mvn)spring-boot:build-imageNo Dockerfile
buildpacks (gradle)bootBuildImageNo Dockerfile
runtime configdocker run -e KEY=VALUEEnv vars
relaxed bindingSPRING_DATASOURCE_URL-> spring.datasource.url

📚 Resources / Keep Learning

🎉 Lesson Complete!

You can now write a multi-stage, layered Dockerfile, explain why layer order maximizes the build cache, build images without a Dockerfile using buildpacks, and configure the same image across environments with environment variables.

Practice quiz

What does a Dockerfile describe?

  • A database schema
  • The steps to build a container image
  • A REST endpoint
  • A unit test

Answer: The steps to build a container image. A Dockerfile is a recipe of instructions (FROM, COPY, ENTRYPOINT, etc.) that build a reproducible container image.

Which Dockerfile instruction sets the base image?

  • RUN
  • FROM
  • CMD
  • LABEL

Answer: FROM. FROM declares the base image the new image is built on, such as a JRE image for running a Spring Boot jar.

Which instruction defines the command that runs when the container starts?

  • ENTRYPOINT
  • WORKDIR
  • ENV
  • EXPOSE

Answer: ENTRYPOINT. ENTRYPOINT (or CMD) specifies the process the container runs, e.g. java -jar app.jar.

Why split a Spring Boot jar into layers in the image?

  • To encrypt the jar
  • To make the jar smaller than the source
  • So unchanged layers (dependencies) are cached and only changed layers rebuild/ship
  • Because Docker requires exactly four layers

Answer: So unchanged layers (dependencies) are cached and only changed layers rebuild/ship. Layered jars put rarely-changing dependencies in their own layers, so Docker's layer cache avoids re-shipping them on each rebuild.

In a layered Spring Boot jar, which layer changes most often?

  • dependencies
  • spring-boot-loader
  • snapshot-dependencies
  • application (your own classes/resources)

Answer: application (your own classes/resources). Your application code changes most frequently, so it belongs in the last, most-often-rebuilt layer.

What does Spring Boot's buildpacks support (mvn spring-boot:build-image) do?

  • Runs your tests only
  • Builds an optimized OCI image WITHOUT you writing a Dockerfile
  • Deploys to production automatically
  • Generates JavaDoc

Answer: Builds an optimized OCI image WITHOUT you writing a Dockerfile. spring-boot:build-image uses Cloud Native Buildpacks to produce a sensible, layered image with no hand-written Dockerfile.

A benefit of buildpacks over a hand-written Dockerfile is...

  • They never produce images
  • They require root by default
  • They only work offline
  • They apply best practices (layering, JRE choice, non-root) automatically

Answer: They apply best practices (layering, JRE choice, non-root) automatically. Buildpacks encode best practices — appropriate base image, layering, and a non-root user — so you don't maintain a Dockerfile.

How should configuration like the database URL be supplied to a container?

  • Hard-coded in the Dockerfile
  • Via environment variables at runtime
  • Recompiled into the jar each time
  • In a comment

Answer: Via environment variables at runtime. Twelve-factor config: pass settings as environment variables so the same image runs in any environment without rebuilding.

Spring Boot maps an env var SPRING_DATASOURCE_URL to which property?

  • server.port
  • logging.level
  • spring.datasource.url
  • info.app.name

Answer: spring.datasource.url. Relaxed binding maps SPRING_DATASOURCE_URL to spring.datasource.url, so env vars override properties.

Why prefer a JRE base image over a full JDK for the final runtime image?

  • The JRE can't run jars
  • A smaller runtime image (no compiler/tools) is leaner and has less attack surface
  • The JDK is required at runtime
  • It makes builds slower

Answer: A smaller runtime image (no compiler/tools) is leaner and has less attack surface. You compile with the JDK but only need a JRE to run; a slimmer runtime image is smaller and more secure.

Continue this course

Frequently asked questions

What is a Dockerfile and what goes in one for Spring Boot?

A Dockerfile is a text recipe of instructions that build a container image. For a Spring Boot app it typically starts FROM a Java base image, COPYs in your built jar (or its extracted layers), optionally sets a WORKDIR and EXPOSE for documentation, and ends with an ENTRYPOINT that launches the app — commonly java -jar app.jar or the Spring Boot JarLauncher. A good Dockerfile uses a multi-stage build: a JDK stage to prepare the artifact and a slimmer JRE stage to actually run it, keeping the final image small.

What are layered jars and why do they matter for Docker?

Docker images are made of stacked layers, and Docker caches each layer by content — if a layer hasn't changed, it's reused rather than rebuilt or re-uploaded. A Spring Boot 'layered jar' splits the application into layers ordered by how often they change: dependencies and the loader (rarely change) go first, and your own application classes (change on every commit) go last. Because you copy the rarely-changing layers earlier in the Dockerfile, a routine code change only invalidates and rebuilds the small top layer, making image builds and pushes dramatically faster.

How do buildpacks differ from writing a Dockerfile?

Cloud Native Buildpacks build an OCI container image from your source or jar without you authoring or maintaining a Dockerfile. Spring Boot wires them in via the build plugin: mvn spring-boot:build-image or ./gradlew bootBuildImage. The buildpack automatically chooses an appropriate JRE, applies sensible layering, and configures the image to run as a non-root user — best practices baked in. The trade-off is less fine-grained control than a hand-written Dockerfile, but for most applications buildpacks produce a better, more secure image with far less effort.

How should I pass configuration to a containerized Spring Boot app?

Follow the twelve-factor principle: build one immutable image and supply environment-specific configuration at runtime via environment variables, not baked into the image. Spring Boot's relaxed binding maps an environment variable like SPRING_DATASOURCE_URL to the property spring.datasource.url, and SPRING_PROFILES_ACTIVE selects a profile. So you run the same image in dev, staging, and prod, passing different -e values (or a secrets manager) for each. This keeps images reusable, avoids leaking secrets into the image, and makes promotion between environments trivial.

Why use a JRE base image instead of a full JDK for the runtime?

You need the full JDK (with the compiler and developer tools) to build your application, but only a Java Runtime Environment to run the resulting jar. Basing the final runtime image on a JRE rather than a JDK yields a smaller image — fewer megabytes to store, push, and pull — and a reduced attack surface, since the compiler and extra tooling aren't present. A multi-stage Dockerfile captures this neatly: build in a JDK stage, then copy only the runnable artifact into a slim JRE stage for production.

Related lessons