1. bootBuildImage and Dockerfile: the role
Buildpacks no longer look like an abstraction: they are a managed packaging path that still ends with a regular OCI/Docker image. Now the question becomes practical: what does this path look like in a regular Spring Boot project so that a second parallel build universe does not appear.
The answer is bootBuildImage. It is a Spring Boot plugin Gradle task that builds an image through buildpacks and leaves the result in the local Docker daemon. It does not read or replace the Dockerfile: it is just a second official way to package the same service, only inside the familiar ./gradlew workflow.
2. How bootBuildImage works: inputs and result
If you treat bootBuildImage as magic, it will definitely bite you. So let us do what Docker taught us to do from the very beginning of the course: build an observable model of “input → process → output” so that we understand what exactly is happening.
As input, bootBuildImage takes your Spring Boot project (its dependencies and build settings), and in practice the artifact that must be packaged (usually bootJar). Then Gradle starts the build through buildpacks: inside a special builder image, the steps for detecting the application type and packaging are executed. The result is a full OCI/Docker-compatible image that appears in your local Docker daemon — that is, you can do everything with it that you would do with an image from a Dockerfile: docker image ls, docker run, docker logs.
It is convenient to keep this in mind as a simple diagram:
flowchart TD
A[Gradle project] --> B[bootJar]
B --> C[bootBuildImage]
C --> D["builder image: build according to buildpacks rules"]
D --> E["final OCI image in the local Docker daemon"]
To make it even less “blurred,” let us put together a mini table:
| Question | Short answer for practice |
|---|---|
| Where does bootBuildImage live? | In the Spring Boot plugin Gradle tasks (org.springframework.boot). |
| What does it not do? | It does not read your Dockerfile and does not execute FROM/COPY/ENTRYPOINT from it. |
| What does it do? | It starts the buildpacks build process and creates a Docker-compatible image. |
| Where does the result appear? | In the local Docker daemon (as a regular image). |
If you remember only one sentence from this section, let it be this: bootBuildImage is “build me an image,” but not through Dockerfile, rather through buildpacks, and put the result into Docker as a regular image.
3. Running bootBuildImage and environment requirements
In the Gradle world there is a beautiful thing: almost everything looks the same. It does not matter whether you are running tests or building a jar — it is just the command ./gradlew <taskName>. That is why, in a learning project, you want to run bootBuildImage as directly as possible so that your brain does not overheat on “wait, we do not need to do this through docker build, right?”.
The basic command is:
# Build a Docker-compatible image through buildpacks
./gradlew bootBuildImage
In practice, on the first run two things usually happen that may surprise a beginner. First, the task may take longer than expected because Docker pulls the builder/run images (that is the normal “price of the first time”). Second, if the Docker daemon is not running, you will see an error like “cannot connect to Docker” — and that is normal too: the image must physically be loaded somewhere, and for that you need a working Docker.
To avoid turning this into guesswork, keep this short idea in mind: bootBuildImage depends on your Docker runtime, because the result is a real image that must end up in the local Docker environment.
4. Configuring the image in bootBuildImage
Image name: imageName
When you work on a project that has several packaging options (the Dockerfile path and the buildpacks path), the image name becomes not a “nice-to-have option,” but a way to keep your sanity. Otherwise you build one thing, run another, and somehow Spring is to blame (as usual, right).
The minimal useful setting in bootBuildImage is to explicitly set the final image name. In build.gradle.kts it looks like this:
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
tasks.named<BootBuildImage>("bootBuildImage") {
// Explicitly set the image name and tag so we do not confuse it with the image from the Dockerfile build
imageName.set("docker-java-catalog-service:buildpacks")
}
Notice two details. First, we import BootBuildImage so that Gradle knows which task type we are working with. Second, we set the tag :buildpacks so that we do not run into the image built through the Dockerfile (which, for example, may be :dockerfile). It is like labeling containers and jars in a kitchen: “salt” and “sugar” look the same until you taste the tea.
If you want to see the effect right away, after the build you can simply look at the image list:
# Check that the image appeared locally and has the expected tag
docker image ls docker-java-catalog-service
And you should see the buildpacks tag among the results.
A very common early mistake is trying to “mix” the two worlds. A person sees the Dockerfile and thinks: “Well, there is FROM and ENTRYPOINT there, so bootBuildImage probably uses this somehow.” It does not. These are completely two parallel packaging paths.
The correct mental model is this: if you configure image building through buildpacks, then the “control points” are in the Gradle configuration (that is, in build.gradle.kts), not in the Dockerfile. The Dockerfile remains your honest text scenario for the Dockerfile path, and bootBuildImage remains the honest Gradle task for the buildpacks path.
For completeness (and so that you understand where this task even comes from), make sure the Spring Boot plugin is connected. Minimally it looks like this:
plugins {
// Without the Spring Boot plugin, the bootBuildImage task will not appear in Gradle
id("org.springframework.boot") version "4.0.3"
id("io.spring.dependency-management") version "1.1.7"
java
}
If there is no Boot plugin, bootBuildImage simply will not appear — and Gradle will honestly say: “Task not found.” This is not “buildpacks are broken,” it just means the task does not exist.
5. builder image and run image: basic concepts
In the buildpacks world there are still two images: the builder image is needed for building, and the run image is for running. For practice, the important thing is not another round of analogies, but the practical takeaway: you can pin the builder from Gradle so the build does not drift between machines and CI. At the start, this is exactly the lever we need: the builder comes with a coordinated runtime base, so fine-tuning is not needed yet.
The builder image contains the tools and buildpacks logic, that is, the environment where the final image is prepared. The run image is the base on which the application actually starts later. The separation is very similar to a multi-stage Dockerfile, only in the buildpacks world it is expressed through a ready-made standardized mechanism.
In the minimal configuration, it is enough to understand that the builder can be pinned just as you pin a base image in Dockerfile, only this is done in Gradle:
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
tasks.named<BootBuildImage>("bootBuildImage") {
// Pin the builder image so the build is predictable across machines and CI
builder.set("paketobuildpacks/builder-noble-java-tiny")
}
This setting is useful not because “we want to customize everything,” but because it makes the build more predictable: the team knows which base is used for the build, instead of living in a state of “what did it choose today?”.
Another practical nuance that often interests a Java developer: which JVM version buildpacks will take. In a normal project, this usually follows automatically from the Java settings in Gradle (for example, through toolchain). Here is an example of how Java 25 is pinned in a project:
java {
toolchain {
// Specify the Java version as an input condition for the build (this also affects packaging)
languageVersion.set(JavaLanguageVersion.of(25))
}
}
This is not a “buildpacks setting,” but it is an important part of the input conditions: you are building a Boot application for a specific Java version, and the packaging mechanism must respect that choice.
6. Verifying the result: image and container launch
When the build is finished, the most dangerous thing is to think that “if Gradle wrote BUILD SUCCESSFUL, then everything is fine.” In the Docker world, good manners are different: the result is confirmed by launching and observing it.
Verification starts with the simplest thing — making sure the image appeared:
docker image ls docker-java-catalog-service
Next comes the launch. For a learning service, it usually looks like this:
# Start a container from the buildpacks image and map the port to the host
docker run --rm -p 8080:8080 docker-java-catalog-service:buildpacks
If everything is fine, you will see the familiar Spring Boot startup logs. And only now can you say that the “image exists” not philosophically, but practically: it starts as a container and behaves like a service.
If you want to stop the container quickly, press Ctrl+C in the terminal if you launched it in the foreground. If the container is running in the background, stop it with the usual command docker stop <containerId>. In other words, the runtime behavior here is completely ordinary: buildpacks change the build path, not the rules of container life.
7. Typical mistakes when working with bootBuildImage
Mistake #1: Docker is not running, but you are waiting for an image.
It usually looks like a message about being unable to connect to the Docker daemon. bootBuildImage may build the artifact, but it needs to “put” the result into Docker. So if Docker Desktop/Engine is turned off, the task does not turn into an image. The fix is simple and boring: start Docker and repeat the command.
Mistake #2: expecting bootBuildImage to read the Dockerfile.
This is almost a classic: “I carefully wrote a multi-stage build, why was it not used?” Because this is a different path. The Dockerfile path is controlled by your Dockerfile and docker build, while the buildpacks path is controlled by Gradle configuration and bootBuildImage. If you keep this fork in mind, there is almost no confusion.
Mistake #3: the image was built, but you cannot find it because the name is “random.”
If you do not set imageName, you may end up with a default name that does not match what you normally type in docker run. As a result, you run the old image, see the old behavior, and wonder why the changes “were not applied.” The lowest-pain fix is to set imageName and use a tag like :buildpacks.
Mistake #4: you have two images of the same service, and you run the wrong one.
This is a normal situation for a project that has both Dockerfile and buildpacks. What is not normal is running them interchangeably without tags and without naming discipline. If the Dockerfile image is called docker-java-catalog-service:latest, and the buildpacks image also ended up in latest by default, you have created a “guess who started now” quest for yourself. It is resolved the same way as in any engineering reality: through naming conventions (:dockerfile, :buildpacks) and a careful README.
Mistake #5: the first build takes forever, and it feels like something is broken.
The first time, buildpacks pull the builder/run images, and the needed components are downloaded and cached. This can be noticeable in terms of time, especially on not-so-fast internet. If the process is moving (something is happening in the console, Docker is pulling layers), this is usually normal. You should worry more when everything freezes with no output and no network activity.
GO TO FULL VERSION