Build Your First Hardened Quarkus Image with Jib and Java 25
Create a small REST service, package it without a Dockerfile, and verify what really runs inside the container.
The final Quarkus service in this article has no Dockerfile. Maven packages the application as a Quarkus fast-jar, Jib turns that distribution into OCI layers, and the Red Hat Hardened OpenJDK 25 image supplies the runtime.
That short build path still leaves a few decisions with us. The base image decides which operating-system files ship, which user starts the process, whether a shell exists, and how quickly security fixes arrive. The image builder decides how application files are layered and which entrypoint becomes part of the image metadata. A clean vulnerability report means little if the container starts as root or points at a launch script that is missing from the filesystem.
Red Hat Hardened Images is the generally available catalog produced by Project Hummingbird. Its runtime images keep a small software footprint, default to a non-root user where possible, and remove tools that the workload does not need. The Hummingbird image documentation describes the default variants as distroless: no package manager and no shell.
Jib builds Docker and OCI images directly from Java build tools. It separates dependencies from application classes so a code change does not invalidate every layer. Quarkus adds its own integration through quarkus-container-image-jib, which understands the fast-jar layout and constructs the runtime entrypoint for us.
What We Build
We build a small status service on Quarkus and Java 25. It exposes /status and the SmallRye Health readiness endpoint. Jib places the Quarkus fast-jar on top of registry.access.redhat.com/hi/openjdk:25-runtime, then loads the result into Podman.
The final checks shows:
The HTTP application and readiness endpoint respond
The packaged application runs on the Java 25 Hummingbird runtime
The image starts as UID
65532with/workas its working directoryStarting
/bin/shfails because the runtime image does not contain a shell
What You Need
The application has no external service dependency. Podman is only needed when we build and run the local image.
JDK 25 installed
Quarkus CLI
Podman 5 or later
curlandjqAbout ☕️☕️
Optional: Cosign for signature verification
On macOS and Windows, start the Podman virtual machine before the image build:
podman machine startCreate the Project
Create the application:
quarkus create app com.themainthread:hardened-quarkus-jib \
--extension=quarkus-rest-jackson,quarkus-smallrye-health,quarkus-container-image-jib \
--java=25 \
--no-code \
--no-dockerfilesUse these extensions:
quarkus-rest-jacksonprovides the JSON REST endpointquarkus-smallrye-healthexposes/q/health/readyquarkus-container-image-jibturns the Quarkus package into an OCI image without reading a Dockerfile
The Quarkus extension catalog marks quarkus-container-image-jib as preview.
--no-dockerfiles keeps the generated project aligned with the build we use. Quarkus usually scaffolds Dockerfiles with your application. But Jib does not read a Dockerfile, so we don’t even need it.
Add a Runtime Probe
The application code stays small because the container is the subject here. Create src/main/java/com/themainthread/hardened/StatusResource.java:
package com.themainthread.hardened;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/status")
@Produces(MediaType.APPLICATION_JSON)
public class StatusResource {
@GET
public StatusResponse status() {
return new StatusResponse(
"hardened-quarkus-jib",
"ready",
Runtime.version().feature());
}
public record StatusResponse(String service, String status, int javaFeatureVersion) {
}
}Runtime.version().feature() gives us a visible check on the JVM. The compiler release and the container runtime are separate settings, so we verify Java 25 twice: @QuarkusTest checks the Maven JVM, and @QuarkusIntegrationTest checks the Hummingbird JVM inside the packaged image.
Configure Jib and the Hardened Runtime
Replace src/main/resources/application.properties with the following configuration:
quarkus.application.name=hardened-quarkus-jib
quarkus.container-image.group=themainthread
quarkus.container-image.name=hardened-quarkus-jib
quarkus.container-image.registry=localhost
quarkus.container-image.labels."org.opencontainers.image.source"=https://github.com/myfear/the-main-thread/hardened-quarkus-jib
quarkus.container-image.labels."org.opencontainers.image.title"=Hardened Quarkus with Jib
quarkus.jib.base-jvm-image=registry.access.redhat.com/hi/openjdk:25-runtime
quarkus.jib.docker-executable-name=podman
quarkus.jib.jvm-additional-arguments=-XX:MaxRAMPercentage=70.0,-XX:+ExitOnOutOfMemoryError
quarkus.jib.user=65532
quarkus.jib.working-directory=/work
quarkus.jib.use-current-timestamp=false
quarkus.jib.use-current-timestamp-file-modification=falseThe container-image properties produce localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT and add OCI source and title labels. The application version supplies the image tag unless you override quarkus.container-image.tag.
quarkus.jib.base-jvm-image selects the Hummingbird OpenJDK 25 runtime variant. It contains the headless runtime. The builder variant adds the extra build tools. The published image is available for Linux on AMD64 and ARM64.
quarkus.jib.docker-executable-name=podman tells Jib where to load a local build. Jib still assembles the image. Podman receives the completed image and then runs it. A registry push uses the registry API directly, so it does not need a local container daemon.
The Hummingbird OpenJDK runtime declares UID 65532. We set the same value on the final image so the runtime contract stays visible in our own metadata. Kubernetes can still assign another arbitrary non-root UID, provided the application files remain readable.
The Hummingbird Java image uses /home/build, while the Quarkus Jib configuration defaults to /home/jboss for its standard UBI Java image. /work gives the fast-jar one predictable location. Our application only reads from it, so UID 65532 does not need write access to the application layers.
We leave quarkus.jib.jvm-entrypoint unset. For this custom base image, Quarkus constructs a direct Java command that ends with -jar quarkus-run.jar. This matters because the Hummingbird runtime has no shell and does not ship the UBI run-java.sh helper.
-XX:MaxRAMPercentage=70.0 caps the maximum heap near 70% of the container memory limit. That leaves headroom for metaspace, thread stacks, direct buffers, native allocations, and the operating-system runtime. -XX:+ExitOnOutOfMemoryError terminates the process after an out-of-memory error so the orchestrator can replace it. Seventy percent is a starting point for this small service. Measure heap and native memory under your own load before copying it into a large application.
Finally, the timestamp settings make unchanged application layers stable between builds. Podman reports the image creation time as January 1, 1970 with this configuration. Jib uses the Unix epoch here so time alone does not change the digest. The complete image digest can still change when the Hummingbird tag points to a patched base. That is exactly what we want during a security rebuild.
Test the Two Runtime Boundaries
The generated project already contains the Quarkus JUnit dependency. Add RestAssured to the <dependencies> section in pom.xml:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>The Quarkus platform manages the RestAssured version used by the tests below.
Add the REST and health test at src/test/java/com/themainthread/hardened/StatusResourceTest.java:
package com.themainthread.hardened;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class StatusResourceTest {
@Test
void returnsRuntimeStatus() {
given()
.when().get("/status")
.then()
.statusCode(200)
.body("service", equalTo("hardened-quarkus-jib"))
.body("status", equalTo("ready"))
.body("javaFeatureVersion", equalTo(25));
}
@Test
void exposesReadiness() {
given()
.when().get("/q/health/ready")
.then()
.statusCode(200)
.body("status", equalTo("UP"));
}
}This test checks application behavior on the Maven JVM. Run it now:
./mvnw testThis assertion proves Maven launched the Quarkus test application on Java 25. It will fail early if the project is compiled with one JDK and tested with another.
Now add src/test/java/com/themainthread/hardened/StatusResourceIT.java:
package com.themainthread.hardened;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class StatusResourceIT extends StatusResourceTest {
@Test
void runsOnHummingbirdJava25Runtime() {
given()
.when().get("/status")
.then()
.statusCode(200)
.body("javaFeatureVersion", equalTo(25));
}
}@QuarkusIntegrationTest runs against the packaged artifact. When the container-image build is enabled, Quarkus starts the resulting image for the test. The Java 25 assertion here proves the base-image choice survived packaging.
The generated POM sets skipITs to true, so the image build below overrides it.
Build the Image and Run the Container Test
Build the fast-jar, assemble the image, load it into Podman, and run the integration test:
./mvnw verify \
-DskipITs=false \
-Dquarkus.container-image.build=trueThe final test summary should contain:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSJib also warns that the base uses a tag:
[WARNING] Base image 'registry.access.redhat.com/hi/openjdk:25-runtime' does not use a specific image digest - build may not be reproducibleThat warning is accurate. The timestamp settings stabilize our application layers, while 25-runtime is allowed to move to a patched Hummingbird image. The production section below separates the update tag from the immutable digest we deploy.
Jib creates separate layers for the Quarkus libraries, quarkus-run.jar, framework-generated files, and application files. A normal code edit usually changes the application layer while the dependency layers stay cached. This is why Jib can remain fast without a handwritten multi-stage Dockerfile.
Jib targets linux/amd64 when no platform is configured. Podman can emulate it on many ARM64 machines, but it prints a platform mismatch warning and startup is slower. Build the native platform explicitly:
./mvnw verify \
-DskipITs=false \
-Dquarkus.container-image.build=true \
-Dquarkus.jib.platforms=linux/arm64The Hummingbird 25-runtime tag publishes both architectures, so either build resolves from the same image name.
Run the Service
Start the image in one terminal:
podman run --rm \
--name hardened-quarkus \
--memory=512m \
-p 8080:8080 \
localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOTCall the application from another terminal:
curl -s http://localhost:8080/status | jqExpected response:
{
"javaFeatureVersion": 25,
"service": "hardened-quarkus-jib",
"status": "ready"
}Check readiness too:
curl -s http://localhost:8080/q/health/ready | jqExpected response:
{
"status": "UP",
"checks": []
}The empty checks array is normal for this application. SmallRye Health has no external database or broker to inspect, but the endpoint proves that the management route is present and the process can answer requests.
Inspect What Jib Built
The HTTP response proves the application works. Image inspection proves the runtime contract.
Check the user and working directory:
podman image inspect \
localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
--format 'user={{.Config.User}} workdir={{.Config.WorkingDir}}'Expected output:
user=65532 workdir=/workNow inspect the entrypoint:
podman image inspect \
localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
--format '{{json .Config.Entrypoint}}' | jqExpected output:
[
"java",
"-Djava.util.logging.manager=org.jboss.logmanager.LogManager",
"-XX:MaxRAMPercentage=70.0",
"-XX:+ExitOnOutOfMemoryError",
"-jar",
"quarkus-run.jar"
]Before the next command, predict the result. The entrypoint uses java directly, and we chose the distroless runtime variant. There should be no /bin/sh available to run id:
podman run --rm \
--entrypoint=/bin/sh \
localhost/themainthread/hardened-quarkus-jib:1.0.0-SNAPSHOT \
-c 'id'The command must exit with a non-zero status and report that /bin/sh cannot be found. If it prints a user ID, you built from a different image variant or replaced the entrypoint with something that pulled a shell into the final filesystem.
Push Directly to a Registry
The local build needs Podman because Jib must load the image somewhere and the integration test must run it. A separate CI packaging job can push straight to a registry:
./mvnw package \
-Dquarkus.container-image.push=true \
-Dquarkus.container-image.image=quay.io/acme/hardened-quarkus-jib:1.0.0Provide registry credentials through the CI secret store with QUARKUS_CONTAINER_IMAGE_USERNAME and QUARKUS_CONTAINER_IMAGE_PASSWORD. Jib talks to the registry API, so this packaging path needs neither a Dockerfile nor a privileged Docker-in-Docker service. It runs @QuarkusTest, but it does not launch the pushed image. Keep the packaged-container test in a job that has a container runtime, or run it after deployment against the promoted image.
For a multi-architecture release, push both supported platforms:
./mvnw package \
-Dquarkus.container-image.push=true \
-Dquarkus.container-image.image=quay.io/acme/hardened-quarkus-jib:1.0.0 \
-Dquarkus.jib.platforms=linux/amd64,linux/arm64A local image store can only run one platform at a time. The registry is the right destination for the multi-architecture index.
Verify the published index:
podman manifest inspect \
quay.io/acme/hardened-quarkus-jib:1.0.0 \
| jq '.manifests[].platform'Expected platforms:
{
"architecture": "amd64",
"os": "linux"
}
{
"architecture": "arm64",
"os": "linux"
}Make the Image Survive Production
The hardened base removes a large amount of operating-system content. It does not remove our responsibility for updates, provenance, application dependencies, or runtime diagnosis.
Rebuild tags and deploy digests
25-runtime tracks the Java 25 line and receives patched image revisions. Rebuilding against that tag is convenient because Jib picks up the new base layers. A released workload should deploy an immutable digest so a rollback always returns to the same bytes.
Use both behaviors in the pipeline: resolve and verify the current approved Hummingbird tag, build the application, push the result, and deploy the final application image by digest. Schedule rebuilds even when the application source has not changed. Hardened images are immutable, so a base-image fix reaches production only after another application image is built and deployed.
Verify the base signature
The Hummingbird documentation provides Red Hat’s public signing key. Verify the base before the build:
cosign verify \
--key https://security.access.redhat.com/data/63405576.txt \
--insecure-ignore-tlog \
registry.access.redhat.com/hi/openjdk:25-runtimeThis checks the signature against the supplied key. --insecure-ignore-tlog skips transparency-log verification, so record the command as signature-only verification without a Rekor inclusion check.
Debug through the process boundary
A missing shell changes the debugging routine. Use podman logs hardened-quarkus, HTTP health endpoints, metrics, traces, heap dumps written to an approved volume, and an external debug container. Installing a shell into the production image restores the tools the hardened base deliberately removed.
The same rule applies to startup scripts. An entrypoint such as /bin/sh run-java.sh works with many general-purpose base images and fails immediately here. The direct Java entrypoint we inspected earlier is part of the compatibility proof.
Keep trust stores and SBOMs separate
Enterprise services often call internal TLS endpoints. The Hummingbird Java image reads its truststore from /etc/pki/ca-trust/extracted/java/cacerts. Mount a prepared truststore read-only, or create a derived image through the documented builder-to-runtime flow. On SELinux systems, remember the :Z or :z relabel option for Podman volumes.
The base-image SBOM describes the Hummingbird content. It does not include the Quarkus libraries we add in later layers. Scan the final application image and generate an application SBOM as a separate release artifact. A quiet base scan can still sit underneath a vulnerable Maven dependency. Containers have a sense of humor about organizational boundaries.
The FIPS variants have another hard boundary: the Hummingbird documentation states that validation applies when they run on RHEL systems installed in FIPS mode. Changing an image tag cannot make an arbitrary host compliant.
Conclusion
We built a Quarkus JVM image with a direct Java entrypoint, a non-root Hummingbird runtime, stable application layers, and tests that cross the real container boundary. Jib removes the Dockerfile maintenance, while the inspection and failure checks keep the runtime assumptions explicit.
The complete code is available in the hardened Quarkus Jib example.


