Container Images

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Quarkus does not hand you a single "build a container" command — it hands you four interchangeable builder extensions behind one abstraction (quarkus.container-image.*), a set of hand-editable Dockerfiles it generates into every project, and a family of small runtime base images tuned for either the JVM or a native executable. This page covers all three: which builder extension to add and what each one needs and produces, what the generated src/main/docker/ Dockerfiles are for, and how to choose a base image, push to a registry, and size container resources once the image exists. For the Spring Boot counterpart see Java and Spring Boot Images, and for the Dockerfile techniques underneath both, Build Cache and Multi-Stage Builds.

The four container-image extensions

Quarkus’s container-image support is split into a common configuration namespace (quarkus.container-image.*) plus one extension per build mechanism. Only one builder extension is normally active at a time, and quarkus.container-image.builder picks which one runs when more than one is on the classpath:

Extension Add with Needs Produces

Jib (quarkus-container-image-jib)

./mvnw quarkus:add-extension -Dextensions=container-image-jib

Nothing extra — no Docker daemon, no docker CLI. Jib assembles the image directly as a set of layers pushed (or written) via the registry API.

A layered image built entirely in-process, which is also why it is the Quarkus default whenever a container-image extension is present without an explicit builder choice.

Docker (quarkus-container-image-docker)

./mvnw quarkus:add-extension -Dextensions=container-image-docker

A running Docker daemon (or a daemon-compatible CLI, e.g. Podman configured as a Docker CLI drop-in) reachable from the build machine, plus one of the generated Dockerfiles below.

An image built by literally invoking docker build against the chosen Dockerfile — the most transparent option when the team already owns and edits Dockerfiles directly.

Buildpacks (quarkus-container-image-buildpack)

./mvnw quarkus:add-extension -Dextensions=container-image-buildpack

A buildpacks builder image (Cloud Native Buildpacks' equivalent of a Dockerfile base plus build logic, e.g. Paketo’s Java builder) and a container runtime capable of running it (Docker daemon by default).

An OCI image assembled by the builder image’s buildpacks, without the project ever owning a Dockerfile —  useful when a platform team standardizes on buildpacks for every language, not just Quarkus.

S2I (quarkus-container-image-s2i)

./mvnw quarkus:add-extension -Dextensions=container-image-s2i

An OpenShift cluster: S2I ("Source-to-Image") is an OpenShift-native build strategy, so this extension only makes sense when the target platform already is OpenShift.

An ImageStream-backed image built inside the cluster’s own build pipeline, skipping the need for local Docker access entirely — the build happens as an OpenShift BuildConfig run, not on the developer’s machine.

Select the active builder explicitly with:

quarkus.container-image.builder=jib
# or: docker | buildpack | s2i

The generated Dockerfiles under src/main/docker/

Regardless of which builder is selected, Quarkus scaffolds four Dockerfiles into src/main/docker/ the first time a project is created (via the Quarkus Maven/Gradle plugin or the code-generator UI), each targeting a different packaging output. Only the Docker extension actually consumes one of them to build an image — Jib, Buildpacks and S2I use their own build mechanisms and ignore these files — but all four remain useful as reference or as a starting point for a hand-rolled docker build:

File Targets

Dockerfile.jvm

The default fast-jar layout (see Packaging and JVM Modes) run on a plain JDK base image — the most common JVM-mode image, laid out to take advantage of the fast-jar directory structure for Docker layer caching (dependencies, then the application itself, in separate COPY instructions).

Dockerfile.legacy-jar

The legacy-jar packaging output (see Packaging and JVM Modes) — a single uber-style runner JAR plus a lib/ directory, for teams that still build that layout for compatibility with tooling that expects one JAR file.

Dockerfile.native

A native executable (see Native Executables with GraalVM/Mandrel) built on a general-purpose UBI base image — glibc-linked, so it tolerates a native executable built without the -micro or -Dquarkus.native.container-build musl toolchain constraints.

Dockerfile.native-micro

The same native executable copied onto ubi-quarkus-micro, a distroless-style minimal base (see below) —  smaller and with a reduced attack surface, at the cost of requiring the native executable to have been built against that base image’s runtime library set.

When the Docker extension is active, it defaults to Dockerfile.jvm for a JVM build and Dockerfile.native for a native build, but either path is overridable:

quarkus.docker.dockerfile-jvm-path=src/main/docker/Dockerfile.jvm
quarkus.docker.dockerfile-native-path=src/main/docker/Dockerfile.native

Runtime base images and layer caching

UBI and distroless-style minimal images

Quarkus’s own default base images are Red Hat’s Universal Base Image (UBI) family, chosen because UBI images are freely redistributable while still being built and patched the way Red Hat’s own supported images are:

  • ubi9-minimal (and its JVM equivalents) — a small general-purpose UBI variant with a package manager (microdnf) still present, used as the default JVM-mode base and as the Dockerfile.native base for a glibc-linked native executable.

  • ubi-quarkus-micro — a distroless-style minimal image: stripped down to the C library and few packages a native executable actually needs at runtime, no shell or package manager retained. It is what Dockerfile.native-micro builds on, and it is the base Quarkus recommends whenever the goal is the smallest possible attack surface for a native image (see Native Executables with GraalVM/Mandrel for how a native executable is produced in the first place).

Either base is overridable independently for JVM and native builds through the same container-image.* namespace used to select the builder:

quarkus.container-image.base-jvm-image=registry.access.redhat.com/ubi9/openjdk-21-runtime
quarkus.container-image.base-native-image=quay.io/quarkus/ubi-quarkus-micro-image:2.0

Layer caching

Jib in particular is built around fine-grained layering: rather than one COPY instruction that invalidates the whole image cache whenever a single class file changes, Jib splits the application into separate layers by how often each part actually changes — typically dependencies (rarely change), resources (change occasionally), and application classes (change on every build). Because each layer is cached and pushed independently, a rebuild after touching only application code re-pushes just the classes layer, while the dependency and resource layers are reused unchanged from the registry. Dockerfile.jvm’s own multi-`COPY structure over the fast-jar layout (see Packaging and JVM Modes) achieves the same effect for the Docker builder, for the same reason: fast-jar was designed to put rarely-changing dependency JARs in a separate directory from the frequently-changing application classes precisely so a layered COPY (or Jib’s layer split) can cache the former.

Pushing to a registry

Any of the four builders can push the image it produces straight to a registry as part of the build, rather than only building it locally:

quarkus.container-image.push=true
quarkus.container-image.registry=quay.io
quarkus.container-image.group=my-org
quarkus.container-image.name=my-app
quarkus.container-image.tag=1.0.0
  • quarkus.container-image.registry — the registry host (omit it, or set it to docker.io, for Docker Hub).

  • quarkus.container-image.group — the namespace/organization/repository path segment under the registry (commonly a Quay or Docker Hub organization, or a project namespace).

  • quarkus.container-image.name — the image name; defaults to the Maven/Gradle artifact id when unset.

  • quarkus.container-image.tag — the image tag; defaults to the application version when unset. Multiple tags can be produced from one build via quarkus.container-image.additional-tags.

The fully-qualified image reference assembled from these is <registry>/<group>/<name>:<tag>, and it is this reference that a Kubernetes/OpenShift deployment manifest later pulls — the manifest-generation side of that (via quarkus-kubernetes/quarkus-openshift) belongs on Kubernetes and OpenShift, not here.

Registry authentication is supplied one of two ways:

quarkus.container-image.username=my-registry-user
quarkus.container-image.password=my-registry-password

or, when neither is set, each builder falls back to whatever credentials are already configured for it outside Quarkus — Jib and Buildpacks read the local Docker config file (~/.docker/config.json, the same credential store docker login populates), the Docker builder relies on the Docker daemon already being logged in, and S2I relies on the OpenShift cluster’s own configured pull/push secrets. Credentials should be supplied through environment variables, a secrets manager, or CI-native secret injection rather than committed as plain application.properties values, exactly as with any other credential Quarkus config accepts (see Secrets, TLS and Hardening).

Sizing container resources for JVM vs. native

A JVM-mode image and a native-executable image warrant very different resource requests, because they carry different memory profiles:

  • JVM mode needs headroom beyond the application’s own working set for heap, metaspace, and the JIT compiler’s own bookkeeping — a JVM given only the memory its heap needs will still be killed under memory pressure once metaspace and JIT overhead are counted. A JVM-mode container typically wants a noticeably higher memory ceiling than the equivalent native executable and benefits from an explicit heap cap (e.g. -XX:MaxRAMPercentage) sized comfortably under the container’s memory limit rather than left to the JVM’s own container-awareness heuristics alone.

  • Native mode starts with a much smaller footprint — no JIT, no bytecode interpreter, no class-loading machinery resident at runtime (see Native Executables with GraalVM/Mandrel and Build-Time Augmentation and the Closed World for why) — so it tolerates a substantially lower memory ceiling. It should still declare explicit requests/limits rather than none at all: an unbounded native process under a real workload can still grow beyond a careless guess, and Kubernetes needs a declared request to schedule the pod sensibly in the first place.

As a concrete starting point (to be tuned against the application’s actual profiling data, not used as a fixed rule):

# JVM-mode container
resources:
  requests:
    cpu: "0.5"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "768Mi"
# Native-executable container
resources:
  requests:
    cpu: "0.2"
    memory: "64Mi"
  limits:
    cpu: "0.5"
    memory: "128Mi"

These resources values live in a Kubernetes/OpenShift manifest, not in application.properties — how Quarkus generates that manifest, and the rest of the Kubernetes-specific configuration around it, is covered on Kubernetes and OpenShift.