Spring Boot Tutorial
Building Container Images with Buildpacks
Writing a good Dockerfile for a Java application is harder than it looks: choose a secure base image, use a JRE rather than a full JDK, run as a non-root user, split dependencies into layers for fast rebuilds, configure memory for containers, and keep everything patched. Cloud Native Buildpacks do all of this for you.
Spring Boot's Maven and Gradle plugins build an optimised OCI image with Paketo buildpacks using a single command — no Dockerfile required. This lesson covers building and running images, configuring the Java version and JVM options, publishing to a registry, native images with buildpacks, and when a hand-written Dockerfile still makes sense.
What Buildpacks Do
The buildpack detects a Spring Boot application, provides a supported JRE, extracts the jar into layers (dependencies, Spring Boot loader, snapshot dependencies, application classes) so that code changes only rebuild the small application layer, configures a memory calculator that sizes the heap to the container's limits, runs as a non-root user, and adds metadata such as an SBOM. The base images are regularly patched, and rebuilding picks up new security fixes.
Building an Image
Run mvn spring-boot:build-image or ./gradlew bootBuildImage. A Docker-compatible daemon (Docker Desktop, Podman) must be available. Configure the image name, the Java version (BP_JVM_VERSION), runtime JVM options (BPE_DELIM_JAVA_TOOL_OPTIONS/JAVA_TOOL_OPTIONS) and whether to publish in the plugin configuration or on the command line.
Dockerfile vs Buildpacks
Buildpacks are the fastest path to a secure, efficient image and suit most services. A hand-written multi-stage Dockerfile (see the Docker lesson) gives complete control — useful when you need extra OS packages, a specific distroless base, or your platform standardises on Dockerfiles. Either way, use the extracted/layered jar layout and a JRE base.
Examples
Building and running the image
mvn spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/webnest/shop:1.0.0
docker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod --memory=768m ghcr.io/webnest/shop:1.0.0
[INFO] Building image 'ghcr.io/webnest/shop:1.0.0'
[INFO] > Pulling builder image 'docker.io/paketobuildpacks/builder-noble-java-tiny:latest'
[INFO] [creator] Paketo Buildpack for BellSoft Liberica 11.x
[INFO] [creator] BellSoft Liberica JRE 21.0.x: Contributing to layer
[INFO] [creator] Paketo Buildpack for Spring Boot 5.x
[INFO] [creator] Creating slices from layers index: dependencies, spring-boot-loader, snapshot-dependencies, application
[INFO] Successfully built image 'ghcr.io/webnest/shop:1.0.0'
Calculated JVM Memory Configuration: -Xmx360M -XX:MaxMetaspaceSize=120M ... (Total Memory: 768M)
Started ShopApplication in 3.1 seconds
Configuring the image in pom.xml
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>ghcr.io/webnest/${project.artifactId}:${project.version}</name>
<env>
<BP_JVM_VERSION>25</BP_JVM_VERSION>
<BPE_DELIM_JAVA_TOOL_OPTIONS xml:space="preserve"> </BPE_DELIM_JAVA_TOOL_OPTIONS>
<BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:+UseZGC</BPE_APPEND_JAVA_TOOL_OPTIONS>
</env>
<publish>false</publish>
</image>
<docker>
<publishRegistry>
<username>${env.REGISTRY_USER}</username>
<password>${env.REGISTRY_TOKEN}</password>
</publishRegistry>
</docker>
</configuration>
</plugin>
# publish in CI
mvn spring-boot:build-image -Dspring-boot.build-image.publish=true
[INFO] Successfully built image 'ghcr.io/webnest/shop:1.0.0'
[INFO] Successfully published image 'ghcr.io/webnest/shop:1.0.0'
A GraalVM native image with buildpacks
# Requires the native profile (included in spring-boot-starter-parent) and GraalVM-compatible code
mvn -Pnative spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/webnest/shop-native:1.0.0
docker run --rm -p 8080:8080 ghcr.io/webnest/shop-native:1.0.0
Started ShopApplication in 0.068 seconds (process running for 0.071)
(image contains a native executable, no JVM; build takes several minutes)
Rebuilding after a code change reuses dependency layers
docker history ghcr.io/webnest/shop:1.0.1 --format "{{.Size}}\t{{.CreatedBy}}"
2.1MB application layer <- only this changed
0B snapshot-dependencies
420kB spring-boot-loader
78MB dependencies (reused from the previous image)
... JRE and base layers (reused)
Common Mistakes
- Running containers without a memory limit, or with a heap larger than the limit, causing the kernel to kill the JVM.
- Shipping a full JDK and build tools in production images when a JRE suffices.
- Publishing images tagged only "latest", making rollbacks and audits difficult.
- Never rebuilding images, so base image security fixes are never picked up.
- Expecting build-image to work without a running Docker-compatible daemon.
Key Points to Remember
- mvn spring-boot:build-image / gradle bootBuildImage build OCI images with Paketo buildpacks.
- Buildpacks provide a patched JRE, layered jars, a container-aware memory calculator and non-root users.
- Configure the image name, Java version and JVM options in the plugin or on the command line.
- Use -Pnative with build-image for GraalVM native images.
- Tag images with versions and rebuild regularly for security patches.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.