Quarkus Container Image Strategy: When Buildpacks Beat Jib
A practical trade-off for teams choosing between a simple app-owned image pipeline and a platform-governed build with rebase and metadata.
Both Jib and Buildpacks let you skip a handwritten Dockerfile. That is the least interesting thing about them.
The real choice is operational. Do you want the app team to build a predictable image with as little ceremony as possible, or do you want the image build to carry stronger platform policy, richer metadata, and the option to patch the runtime base without rebuilding the app? Those are not the same job, and Quarkus gives you a path for both.
I think this is where a lot of container-image advice gets mushy. We flatten the decision into “what command builds the image?” when the better question is “what contract am I taking on?” Jib is mostly an application-builder story. Buildpacks are closer to a platform-builder story, even when a developer triggers the command on a laptop.
This walkthrough keeps the application intentionally small so the comparison stays honest. We build the same Quarkus service twice, first with Jib and then with Buildpacks, and then we look at the part that actually changes the decision: metadata, builder control, and rebase behavior.
Prerequisites
You need a normal Quarkus setup and a working local container runtime. We are not doing anything exotic here, but I do want the commands to be reproducible without filling in missing pieces from memory.
JDK 21 installed
Quarkus CLI on your
PATHDocker available locally
The
packCLI for image inspection and rebase checksAbout 2 ☕️
I am using Docker here because the current Quarkus Buildpack integration in the Quarkus container image guide expects a Docker-backed flow. Buildpacks as an ecosystem are broader than that. This article is about what Quarkus gives you today, not every possible CNB setup.
Create the sample once
Create a tiny REST application:
quarkus create app dev.themainthread:container-choice-demo \
--extension=rest-jackson \
--java=21 \
--no-codeI am using --no-code because the application itself is not the story. We only need one endpoint that proves the image runs.
Create src/main/java/dev/themainthread/container/HelloResource.java:
package dev.themainthread.container;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/hello")
public class HelloResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "hello from quarkus";
}
}Start the app once and verify the endpoint:
cd container-choice-demo
quarkus devIn another terminal:
curl http://localhost:8080/helloYou should get:
hello from quarkusStop dev mode after that. The application is now boring in exactly the way I want. If one build path behaves differently from the other, the image tooling is the variable.
I am also using the Quarkus CLI image commands on purpose. They let us switch between supported image builders without turning the project setup into the story.
First path: Jib
Build the image with Jib:
quarkus image build jib --group mainthread --name container-choice-demoFor a local build, Quarkus wires the result into the local container daemon so you can run it immediately:
docker run --rm -p 8080:8080 mainthread/container-choice-demo:1.0.0-SNAPSHOTThen check the endpoint again:
curl http://localhost:8080/helloIf the container answers, Jib did its job. That sounds obvious, but it is worth saying because this is what a lot of teams actually need: take a normal Quarkus service, build an image, move on with life.
What Jib gets right is the lack of drama. It understands Java application layering well, it fits naturally into Quarkus image tooling, and in push-oriented CI pipelines it can avoid some of the local-daemon assumptions that other paths drag in. If your requirement is “build a container image for this service and keep the pipeline boring,” Jib is a very strong default.
That does not mean it is universally better. It means the default burden is low. You do not have to decide on a builder stack up front, and you do not have to explain to the rest of the team why image creation suddenly has opinions about lifecycle binaries and run images.
Second path: Buildpacks
Before running the Buildpacks path, switch the Maven Wrapper away from the generated only-script mode:
mvn -N org.apache.maven.plugins:maven-wrapper-plugin:3.3.4:wrapper -Dtype=sourceI am doing this up front because current generated wrappers can be awkward inside builder containers. In only-script mode, mvnw can fall back from the Maven ZIP distribution to the TAR.GZ distribution when unzip is missing in the build environment. If your checksum in maven-wrapper.properties matches the ZIP, that fallback can make the Buildpacks build fail even though the wrapper file looks correct. type=source avoids that path and keeps the wrapper self-contained without requiring a checked-in wrapper JAR.
Also, Quarkus defaults to fast-jar, which writes the runnable application under target/quarkus-app/. Paketo’s Maven buildpack, by default, looks for built artifacts with the pattern target/*.[ejw]ar. For this comparison piece, the least awkward fix is to have the build inside the builder produce an uber-jar instead.
Now build the same application with Buildpacks:
quarkus image build buildpack \
--group mainthread \
--name container-choice-demo \
--build-env 'BP_MAVEN_ADDITIONAL_BUILD_ARGUMENTS=-Dquarkus.package.jar.type=uber-jar' \
--builder-image paketobuildpacks/builder-jammy-baseA builder image is the build-side toolkit for a Buildpacks build. It is an OCI image that bundles the lifecycle binaries, an ordered set of buildpacks, a build-time base image, and a reference to the run image that will sit under the final application image.
That means paketobuildpacks/builder-jammy-base is not just a helper image name. It affects which buildpacks get a chance to detect your project, what environment they run in while producing layers, and which runtime base line the exported image starts from. Later, when we get to rebase, that run-image relationship becomes the interesting part.
That is why I am pinning the builder explicitly instead of hand-waving it away as a default. In a Buildpacks flow, the builder is part of the image contract.
Run the image:
docker run --rm -p 8080:8080 mainthread/container-choice-demo:1.0.0-SNAPSHOTAnd verify it:
curl http://localhost:8080/helloAt this point both tools look similar from the outside. We built a Quarkus service, we started a container, and the endpoint answered. If that is where you stop, the article collapses into “two ways to avoid a Dockerfile.” That is not the interesting part.
The interesting part is what the Buildpack image knows about itself.
Inspect the Buildpack image
Use pack to inspect the image metadata:
pack inspect-image mainthread/container-choice-demo:1.0.0-SNAPSHOTYou should see sections for the run image, the buildpacks that participated in the build, and the process types baked into the image. That matters because the resulting artifact is not just “some filesystem layers Jib assembled.” It is an image with a buildpack lineage. The inspect-image command is where that becomes visible.
This is one of the first places where platform concerns show up. A platform team can standardize on a sanctioned builder and know that every service image carries the same kind of metadata and the same base-image policy. An app team can still trigger the build, but the artifact says more about how it came to exist.
You also get a better answer when somebody asks, “What exactly built this image?” With Buildpacks, that question has first-class metadata behind it. With Jib, the answer is usually simpler, but it is also mostly “our build assembled these layers.” That can be enough. Sometimes it is not.
Rebase is the whole point
If you want one reason Buildpacks still matter in 2026, this is the one I would use.
Try:
pack rebase mainthread/container-choice-demo:1.0.0-SNAPSHOTRebase swaps the run-image layers under a buildpack-produced application image without rebuilding the application itself. When there is an updated run image available, you can patch the base and keep the application layers intact. The Buildpacks rebase docs are worth reading here because this is the feature that changes the operational conversation.
That is a very different operational story from “run the Java build again.” If your platform team owns runtime base images and your application teams own code, Buildpacks let those responsibilities meet in a cleaner place. A CVE in the base image does not automatically mean every service has to rerun the whole application build just to move onto a patched runtime layer.
This is also where I stop thinking of Buildpacks as a developer convenience feature. Convenience is nice. Rebase is strategy. It only matters if your organization actually separates application rebuilds from base-image maintenance, but when that split is real, Jib and Buildpacks are not interchangeable anymore.
Where Jib still wins
I would still pick Jib first for a lot of Quarkus services.
If the team mostly wants a reliable image build inside the application pipeline, Jib is easier to explain and easier to own. There is less builder policy to reason about. There are fewer moving pieces to standardize. The mental model is close to the application build itself: compile the app, assemble a sensible layered image, push it where it needs to go.
That simplicity matters. Teams rarely regret the simpler pipeline on day one. They regret it when the complicated alternative did not buy them anything real.
The mistake is treating Buildpacks as automatically more modern because they are more opinionated. More opinionated is only better when the opinion matches a problem you actually have.
Where Buildpacks earn their keep
I would pick Buildpacks when one or more of these are true:
The platform team wants to standardize the builder and the runtime base across many services
You care about buildpack metadata and image inspection as part of your delivery story
Rebase is operationally useful in your environment
You want image construction to express platform policy, not just application packaging
That is a narrower case than “all Java services should use Buildpacks.” It is also a much more honest one.
A few traps worth calling out
Do not turn quarkus.container-image.build=true into a permanent property just because a tutorial wanted shorter commands. The current Quarkus docs explicitly warn against doing that for the Buildpack path because it can lead to nested builds in places you did not intend.
If you pass Buildpack-specific environment, use the current Quarkus configuration shape:
quarkus.buildpack.builder-env."BP_JVM_VERSION"=21That is the kind of detail stale articles get wrong. Older config examples using quarkus.buildpack.env.* are easy to find and easy to cargo-cult into a project that no longer matches the docs.
Also, pin the builder you actually mean. latest is a lousy platform contract. If builder choice is part of the image policy, treat it like policy.
Finally, do not force Buildpacks into a team that only needs an ordinary image build. If nobody will inspect the metadata, nobody will use rebase, and nobody cares about standardizing on a sanctioned builder line, Jib is probably the better answer because it asks less from everyone.
My rule of thumb
If I am building a normal Quarkus service and I want the image step to stay boring, I pick Jib.
If I want the image to carry platform policy, builder identity, and rebase-friendly runtime separation, I pick Buildpacks.
That is the whole decision for me. Not “which one avoids a Dockerfile?” Both do. The better question is who owns the image contract after the Java build is over.
Quarkus makes it easy to try both, which is exactly what it should do. The mistake is pretending they solve the same problem just because they can both produce a container image from the same source tree.


