DEV Community

Cover image for Paketo Buildpacks for Java: From mvn package to a Production Container Without a Dockerfile"
Nijo George Payyappilly
Nijo George Payyappilly

Posted on

Paketo Buildpacks for Java: From mvn package to a Production Container Without a Dockerfile"

There's a moment every platform team hits eventually. You've got fifty Spring Boot services, each with its own Dockerfile, each one a slightly different snowflake. One pins eclipse-temurin:17-jre, another is still on openjdk:11-slim, a third copied a base image from a 2021 Stack Overflow answer that nobody dares touch. When a JDK CVE drops, somebody has to open fifty pull requests, rebuild fifty images, and pray the build args still work.

Cloud Native Buildpacks — and Paketo, the most mature open-source implementation — exist to make that whole category of toil disappear. Instead of describing how to build an image, you hand the buildpack your source or your JAR, and it produces a well-structured, reproducible, secure OCI image with no Dockerfile in sight.

This post is about how that actually works for Java, where the interesting Java-specific behavior lives, and what you need to know to run the result reliably in production. I'll assume you know your way around containers and the JVM, and I'll spend most of the time on the parts that bite people in production.

The pitch: why buildpacks instead of a Dockerfile

A Dockerfile is imperative. It's a script that says run these commands in this order. That flexibility is exactly the problem at scale: every team encodes its own opinions, and those opinions drift, rot, and quietly accumulate vulnerabilities.

Buildpacks invert the model. They are declarative and composable. You provide an app; an ordered group of buildpacks inspects it (the detect phase), decides which ones apply, and contributes layers (the build phase). For a Spring Boot app, the JVM buildpack detects a JAR, the executable-JAR buildpack figures out how to launch it, the memory-calculator buildpack contributes runtime sizing logic, and so on. You didn't write any of that. You ran one command:

pack build my-service \
  --builder paketobuildpacks/builder-jammy-base \
  --path .
Enter fullscreen mode Exit fullscreen mode

Or, if you're already in the Spring ecosystem, you don't even need the pack CLI:

./mvnw spring-boot:build-image
# or
./gradlew bootBuildImage
Enter fullscreen mode Exit fullscreen mode

What you get back is worth understanding, because each property maps to an operational benefit:

  • Reproducibility. Same source plus same builder yields a byte-identical image. No "works on my laptop" drift.
  • Layering that respects change frequency. Dependencies, JVM, and application code land in separate layers. Your 200 MB of dependency JARs aren't re-pushed every time you change one line of application code — a real bandwidth and registry-storage win across hundreds of daily builds.
  • A real SBOM. Paketo emits a Software Bill of Materials (CycloneDX / SPDX) describing every component. Your supply-chain scanning gets this for free.
  • Rebase. This is the one that changes your life — more on it below.
  • Non-root by default, minimal surface. The Jammy and the newer Ubuntu base images ship with a small footprint and run as an unprivileged user without you configuring anything.

What actually happens when Paketo builds a Java app

It helps to picture the phases, because when something goes wrong you'll be debugging one of them specifically.

Detect. Each candidate buildpack votes on whether it applies. The Java buildpacks look for a JAR, a pom.xml, a Gradle build, or compiled classes. If you pass source, a JDK buildpack contributes a full JDK and runs your build tool; if you pass a pre-built JAR, it skips compilation and contributes only a JRE. Passing a pre-built artifact is usually the right call in CI — your pipeline already ran the tests and produced the JAR, so don't pay to compile twice.

Build. The winning buildpacks run in order, each contributing one or more layers. For a typical Spring Boot service you'll see layers for the JRE, for class-data sharing archives, for the exploded application, and for the runtime helpers. Spring Boot's layered-JAR support (on by default in modern versions) lets Paketo split your fat JAR into dependencies, spring-boot-loader, snapshot-dependencies, and application layers — ordered least-to-most volatile, which is exactly what you want for cache efficiency.

Export. The layers are assembled into an OCI image with the launch metadata, the entrypoint, and the SBOM attached.

The result is an image whose entrypoint isn't a naked java -jar. It's a launcher that, at container start, runs a set of exec.d helpers and profile scripts that compute JVM flags from the environment the container is actually running in. That runtime computation is the heart of the Java story, and it's where the memory calculator lives.

The memory calculator: the most important thing to understand

Here is the single most important behavior to internalize, because misunderstanding it is the root cause of most "my Paketo Java app got OOMKilled" tickets.

At container startup, Paketo runs a memory calculator that partitions the container's memory limit into JVM regions. It doesn't just set -Xmx to the limit — it carves out everything the JVM needs natively first, and gives the remainder to the heap. The formula is essentially:

Heap = Total Container Memory
       − Metaspace                  (sized from a class count it computes)
       − Reserved Code Cache        (default 240 MB)
       − Direct Memory              (default 10 MB)
       − (Thread Count × Stack Size)
       − Headroom
Enter fullscreen mode Exit fullscreen mode

The default thread count is 250, and the default stack size is 1 MB, so threads alone reserve ~250 MB before you've allocated a single object on the heap. On a 1 GiB container with a typical Spring Boot + Hibernate class footprint, you can easily end up with only 350–450 MB of actual heap. People see "1 GiB limit" and assume "1 GiB heap," and then watch GC thrash and wonder why.

The tuning levers, all set as environment variables (no Dockerfile, no flags):

Variable What it controls Why you'd change it
BPL_JVM_THREAD_COUNT Threads assumed for stack reservation Default 250 is wasteful for most services; 80–100 is realistic and frees ~150 MB for heap
BPL_JVM_HEAD_ROOM Percentage held back for native growth Bump above 0 to leave room for JIT code cache, jemalloc/Netty direct buffers, Metaspace growth
BP_JVM_VERSION JDK/JRE major version Pin it; don't let it drift
BP_JVM_CDS_ENABLED Application Class Data Sharing Faster, more memory-efficient startup
JAVA_TOOL_OPTIONS Arbitrary JVM flags The escape hatch for anything the calculator doesn't model

A practical baseline for a mid-sized REST service:

env:
  - { name: BP_JVM_VERSION,        value: "21" }
  - { name: BPL_JVM_THREAD_COUNT,  value: "80" }
  - { name: BPL_JVM_HEAD_ROOM,     value: "10" }
  - { name: BP_JVM_CDS_ENABLED,    value: "true" }
Enter fullscreen mode Exit fullscreen mode

The mental model to keep: the memory calculator is your friend, but it only knows what you tell it. Give it a wrong thread count or zero headroom on a service that uses lots of native memory, and it will confidently size a heap that leaves no room for the native allocations the JVM makes outside the heap — and the kernel, not the JVM, will reclaim that with a SIGKILL.

The CPU trap that Paketo can't save you from

Paketo handles memory beautifully. CPU is where you're still on your own, and it's where the worst production surprises hide.

Since JDK 10, -XX:+UseContainerSupport is on by default, so the JVM reads cgroup CPU limits to size its internal thread pools. The number it derives — ActiveProcessorCount — drives GC parallel threads, JIT compiler threads, and ForkJoinPool.commonPool parallelism. If your container's CPU limit rounds down to 1, the JVM behaves like a single-core machine: one GC thread, one compiler thread, and any parallel stream or reactive scheduler silently running serial.

It gets worse when your CPU request is tiny relative to the limit. A request: 20m / limit: 1000m profile (a 50× ratio I see constantly) tells the scheduler the pod needs almost nothing, so nodes get packed densely. At runtime the Completely Fair Scheduler enforces the limit over 100 ms windows, and under contention your pod gets throttled — stalled waiting for its next slice. For a JVM this is uniquely painful: GC threads get paused mid-collection (long tail pauses), JIT compilation gets throttled (your app stays interpreted longer and never reaches steady-state throughput), and safepoint synchronization drags.

The fixes live in your Kubernetes manifest, not your image:

  • Set a CPU request close to your steady-state p95, not a token value. Burst ratios of 2–4× are reasonable; 50× is a latency landmine.
  • Set -XX:ActiveProcessorCount explicitly (via JAVA_TOOL_OPTIONS) to match the cores you actually expect to use, so GC and compiler threads aren't sized for a ceiling you rarely reach.
  • Make memory request equal memory limit. Guaranteed QoS for memory eliminates surprise OOMKills from node overcommit and gives the calculator a stable ceiling to plan against.

Watch container_cpu_cfs_throttled_seconds_total. If it's non-zero, no amount of buildpack tuning will fix what is fundamentally a scheduling problem.

Rebase: patching the JDK without rebuilding

This is the feature that justifies the whole migration on its own.

Because buildpack layers are content-addressable and the application layers are cleanly separated from the OS and JRE layers, you can swap the base image underneath an existing app image without rebuilding the app:

pack rebase my-service:latest \
  --run-image paketobuildpacks/run-jammy-base:latest
Enter fullscreen mode Exit fullscreen mode

When a JDK or OS CVE drops, you don't reopen fifty PRs and rerun fifty builds. You rebase fifty images in minutes, and the application layers — your actual code, already tested — are untouched. From a security-operations standpoint this collapses mean-time-to-patch from days to minutes, and it does it without reintroducing build-time risk. This is the kind of leverage that turns a platform team's CVE response from a fire drill into a cron job.

Running it well: an SRE lens

Buildpacks give you a good image. Reliability comes from how you operate it. A few principles, framed the way Google's SRE practice frames them:

Observability first, and before any change. You can't tune what you can't see. Wire up Micrometer → your metrics backend and watch the JVM golden-signal proxies: jvm_memory_used_bytes{area="heap"} after GC, jvm_gc_pause_seconds, jvm_threads_live_threads, and process_cpu_usage. From cAdvisor, container_memory_working_set_bytes, container_oom_events_total, and the CFS throttling counter above. Paketo makes it easy to add the OpenTelemetry or Spring Boot Actuator wiring as buildpack-contributed layers, so you get this consistently across every service without per-team effort.

Define SLOs and spend an error budget. Pick latency (p99), error rate (including OOM events), and saturation (heap-after-GC, throttling %) as your service-level indicators. Set targets, and use the burn rate to gate change: if the budget is healthy, run your tuning experiments; if it's burning, freeze and stabilize. This keeps buildpack and JVM experimentation from quietly eroding reliability.

Reduce toil, but don't trade it for new failure modes. Buildpacks are a textbook toil reduction — they delete the repetitive, automatable work of Dockerfile maintenance. Keep that spirit when you add automation around them. Resist the urge to auto-resize JVM pods aggressively; the JVM's reluctance to return committed heap to the OS confuses naive autoscalers into a restart loop, and each restart pays the JIT warmup tax. Horizontal scaling on request rate or queue depth is almost always the better lever for stateless Java services.

Change one thing at a time. When you roll out a new resource profile and new JVM flags in the same deploy and latency moves, you've learned nothing about which change did it. Stage your rollouts as canaries, vary one dimension per deployment, and keep the previous configuration one helm rollback away. This is just the scientific method applied to production, and it's the difference between a platform team that knows why its services behave the way they do and one that's perpetually guessing.

When to reach for the advanced options

Two Paketo capabilities are worth knowing about even if you don't need them on day one:

GraalVM native images (BP_NATIVE_IMAGE=true) compile your Spring Boot app ahead-of-time into a native executable. Startup drops from seconds to tens of milliseconds and the memory footprint shrinks dramatically — transformative for scale-to-zero, serverless-style, or high-replica-count workloads. The trade-offs are real: longer build times, no JIT peak-throughput optimization, and a reflection-configuration tax for libraries that do dynamic class loading. Reach for it when fast startup and small footprint matter more than peak throughput.

Class Data Sharing and CRaC. CDS (BP_JVM_CDS_ENABLED=true) pre-computes a class archive so startup is faster and metaspace is shared — low-risk, turn it on. CRaC (Coordinated Restore at Checkpoint) goes further, snapshotting a warmed-up JVM and restoring it near-instantly, which is compelling for services with long warmup periods, though it carries operational complexity around the checkpoint lifecycle.

Neither is a silver bullet. Both change the operational characteristics enough that you should decide deliberately, per workload, with the golden signals in front of you.

The takeaway

Paketo Buildpacks remove an entire class of platform toil: no Dockerfiles to maintain, reproducible and well-layered images, an SBOM for free, and rebase to collapse CVE patching from a multi-day fire drill into a minutes-long routine. For Java specifically, the runtime memory calculator does sophisticated work to size the JVM to the container — but it only knows what you tell it, so set the thread count and headroom deliberately rather than trusting the defaults.

And remember the one thing the buildpack can't do for you: it builds the image, but it doesn't write your Kubernetes manifests. The CPU request/limit ratios, the Guaranteed-QoS memory configuration, the observability, and the rollout discipline are yours to own. Get the image and the operational posture right, and you've got a Java platform that's reproducible, secure, and reliable — with a fraction of the per-service effort you're spending today.


If you're running Paketo-built Java workloads at scale and want to compare notes on memory-calculator tuning or rebase automation, I'd love to hear how you've approached it.

Top comments (0)