Deploy a Java Service to Kubernetes

Khimananda Oli 7 min read Programming and Languages
Deploy a Java Service to Kubernetes

By Khimananda Oli | Last reviewed: August 2026

Running JVM workloads in containers introduces specific challenges around memory management, startup latency, and graceful shutdown handling that generic tutorials often overlook. When you deploy a Java service to Kubernetes, success depends less on the basic YAML syntax and more on correctly configuring container resources, health probes, and JVM flags to match the orchestration layer's expectations. This guide walks through the exact configuration patterns I use in production environments to ensure Java applications start fast, scale reliably, and pass compliance audits without wasting cloud budget.

Java SourceMaven / GradleCI PipelineBuild + Test + ScanContainer RegistryDistroless ImageK8s ClusterDeployment + SvcEnd-to-End Flow: Deploy a Java Service to Kubernetes
High-level architecture for deploying Java services to Kubernetes with secure CI/CD integration

How do you containerize a Java application for Kubernetes?

The foundation of any reliable Kubernetes deployment is the container image itself. For Java applications, this means moving beyond simple FROM openjdk statements to multi-stage builds that minimize attack surface and image size. In my experience helping teams achieve SOC 2 compliance, using distroless or Alpine-based runtime images is no longer optional—it is a baseline security requirement that reduces CVE exposure by over 90% compared to full OS base images.

Multi-stage Dockerfile for Spring Boot

This pattern separates the build environment from the runtime environment. The builder stage compiles your code and extracts dependencies, while the final stage contains only the JRE and your application artifacts. This approach typically produces images under 250MB, which directly impacts pod startup time during autoscaling events.

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar --no-daemon && \
    mkdir -p build/dependency && \
    cd build/dependency && \
    jar -xf ../libs/*.jar

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/dependency/BOOT-INF/lib /app/lib
COPY --from=builder /app/build/dependency/META-INF /app/META-INF
COPY --from=builder /app/build/dependency/BOOT-INF/classes /app

ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["java", "-cp", "app:app/lib/*", "com.example.Application"]

Key details matter here. Setting USER nonroot prevents privilege escalation attacks. Using -XX:MaxRAMPercentage=75.0 instead of fixed heap sizes allows the JVM to respect container memory limits dynamically, preventing OOMKilled errors when Kubernetes adjusts resources. Always extract layers separately as shown; this enables better Docker layer caching and faster deployments. For teams managing multiple microservices, consider reading about reducing Docker image size with multi-stage builds to optimize storage costs across your registry.

What Kubernetes resources are required to deploy a Java service?

You need at minimum a Deployment, Service, and optionally an Ingress for external access. However, production-grade Java deployments require additional configuration that generic templates omit. Resource requests and limits must align with your JVM's actual memory footprint, and health probes must account for Spring Boot's startup characteristics to prevent premature restarts.

Production-ready Deployment manifest

This manifest includes the critical fields that separate demo deployments from production workloads. Note the explicit resource specifications, probe configurations, and termination grace period tuned for Java applications.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: java-api-service
  labels:
    app: java-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: java-api
  template:
    metadata:
      labels:
        app: java-api
    spec:
      terminationGracePeriodSeconds: 45
      containers:
      - name: java-api
        image: registry.example.com/java-api:v1.4.2
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "512Mi"
            cpu: "1000m"
        env:
        - name: JAVA_OPTS
          value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 10
          failureThreshold: 3
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 15
          failureThreshold: 3
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 10"]

The preStop hook with a sleep command is essential for Java services. Kubernetes sends SIGTERM and removes the pod from service endpoints simultaneously, but there is a race condition where in-flight requests may still route to the terminating pod. The 10-second sleep gives the load balancer time to update its endpoint list before your application begins shutting down. Without this, you will see intermittent 502 errors during rolling updates. Understanding these nuances helps avoid common pitfalls described in guides on debugging CrashLoopBackOff in Kubernetes.

KubeletJVM ContainerActuatorServiceStart ContainerInit Spring ContextReadiness Probe (15s delay)Add to EndpointsLiveness Probe (30s delay)Probe Timing Prevents Premature Restarts During Java Startup
Correct probe sequencing ensures Java services fully initialize before receiving traffic

How do you tune JVM memory settings for Kubernetes containers?

Memory misconfiguration is the single most common cause of instability when teams first deploy a Java service to Kubernetes. The JVM historically did not respect cgroup memory limits, leading to containers being killed despite having configured heap sizes. Modern JDK versions (17+) handle this correctly with -XX:+UseContainerSupport enabled by default, but you still need explicit tuning to balance heap, metaspace, and native memory overhead.

Memory allocation strategy

  • Set MaxRAMPercentage to 75%: This reserves 25% of container memory for non-heap JVM structures including metaspace, thread stacks, direct buffers, and GC overhead. Values above 80% risk OOMKilled events under load.
  • Match requests and limits: Java workloads should use Guaranteed QoS class by setting identical memory requests and limits. Burstable pods get evicted first during node pressure, causing unnecessary restarts of stateful services.
  • Account for thread count: Each Java thread consumes ~1MB of native memory by default. A service with 500 threads needs ~500MB beyond heap. Calculate total memory as: heap + metaspace + (threads × stack size) + native overhead.
  • Monitor actual usage: Use jcmd <pid> VM.native_memory summary inside running containers to validate assumptions. Prometheus exporters like micrometer provide continuous visibility into JVM memory pools for dashboarding.

I have audited dozens of Java deployments where teams set 2GB container limits with 2GB heap, then wondered why pods crashed during garbage collection cycles. The JVM needs breathing room. Start conservative at 75% and increase only after profiling confirms safe headroom. For deeper guidance on setting appropriate boundaries, review best practices for Kubernetes resource limits and requests.

How do you implement zero-downtime deployments for Java services?

Rolling updates are the default strategy, but Java's startup time requires careful tuning to avoid capacity degradation during deploys. The combination of proper probe configuration, pod disruption budgets, and anti-affinity rules ensures your service maintains availability throughout the update process.

Rolling update configuration

ParameterRecommended ValueRationale
maxSurge25%Allows new pods to start before old ones terminate, maintaining capacity
maxUnavailable0Prevents capacity reduction during Java's slower startup phase
minReadySeconds30Validates pod stability after readiness before proceeding
terminationGracePeriodSeconds45Allows in-flight requests to complete plus preStop hook duration

For business-critical Java APIs, pair rolling updates with PodDisruptionBudgets that maintain at least 50% replica count during voluntary disruptions like node drains. This protects against cluster maintenance coinciding with deployments. Teams operating in Nepal or regions with limited bandwidth should also consider image pull policies; using IfNotPresent with immutable tags avoids redundant pulls that slow rollout velocity. Advanced strategies like canary releases are covered in detail in the guide on blue-green and canary deploys on Kubernetes.

❌ MisconfiguredContainer Limit: 1024MiHeap: -Xmx1024m (100%)No room for metaspace, threads, GCResult: OOMKilled during peak load✓ Production-ReadyContainer Limit: 1024MiHeap: MaxRAMPercentage=75.0256Mi reserved for non-heap memoryResult: Stable under sustained loadAlways Reserve Headroom When You Deploy a Java Service to Kubernetes
Visual comparison of JVM memory allocation strategies preventing OOMKilled failures

Deploy a Java Service to Kubernetes with Confidence

Successfully running Java in Kubernetes requires treating the JVM as a first-class citizen of the container platform rather than an afterthought. From multi-stage distroless builds and precise memory tuning to probe sequencing and graceful shutdown hooks, each configuration choice compounds into either resilient production systems or chronic operational debt. Start with the patterns outlined here, instrument thoroughly with OpenTelemetry and Prometheus, and iterate based on actual workload behavior rather than theoretical defaults. If your team needs hands-on guidance implementing these practices or preparing Java infrastructure for SOC 2 audits, reach out to discuss your specific deployment requirements.

Frequently Asked Questions

Eclipse Temurin 21 JRE Alpine is currently the standard for 2026. It provides a minimal footprint under 80MB, reducing attack surface and pull times. Avoid using full JDK images in production deployments as they include unnecessary build tools that increase vulnerability risks and resource consumption significantly.

Set container memory requests and limits identically to prevent OOM kills. Use -XX:MaxRAMPercentage=75.0 instead of fixed heap sizes so the JVM respects cgroup v2 limits dynamically. This ensures garbage collection behaves predictably within pod boundaries without manual tuning when scaling resources up or down.

Spring Boot applications often exceed default probe timeouts due to bean initialization. Configure initialDelaySeconds to match actual startup time measured via logs. Alternatively, implement a dedicated /actuator/health/liveness endpoint that returns ready only after context refresh completes, preventing premature restarts during cold starts.

Yes. Google's distroless java21-debian12 image removes shells and package managers entirely. This hardens security by eliminating shell access and reducing CVE exposure. Debugging requires ephemeral debug containers since exec commands are unavailable inside these stripped-down runtime environments.

Enable spring.lifecycle.timeout-per-shutdown-phase=30s and set terminationGracePeriodSeconds to 45 seconds. The JVM must receive SIGTERM properly through PID 1; use tini or kubernetes-native signal handling if running without an init process to ensure active requests drain before pod deletion occurs.

CPU limits trigger CFS quota throttling even when average usage appears low. Java JIT compilation and GC pauses create micro-bursts exceeding limit thresholds. Monitor container_cpu_throttled_seconds_total metric and either remove CPU limits entirely or increase them substantially above observed peak burst requirements.

Use GraalVM native-image compilation to achieve sub-second startup times. For traditional JVMs, enable AppCDS shared archives and class data sharing. Pre-warm caches during container build phases and use readiness gates to delay traffic routing until JIT warmup completes fully.

Kustomize suits most Java microservices due to simpler overlay management without templating complexity. Helm benefits teams managing multiple environment-specific configurations or distributing charts publicly. In 2026, many teams adopt Flux CD with Kustomize for GitOps-driven Java service deployments across clusters.

Mount ConfigMaps as files at /config/application.yml for Spring Cloud Kubernetes integration. Use sealed-secrets or external-secrets operator for sensitive values. Avoid environment variables for complex nested configs since Java property binding handles file-based sources more reliably than flattened env var formats.

Standard Kubernetes Services use round-robin load balancing which breaks gRPC long-lived connections. Implement client-side load balancing with grpc-java-kubernetes-nameresolver or deploy Envoy sidecars with xDS support. Configure headless services to expose individual pod endpoints for proper connection distribution across replicas.

Attach async-profiler via ephemeral debug containers without restarting pods. Expose JMX through port-forwarding for VisualVM connections. For continuous profiling in 2026, deploy Pyroscope eBPF agents which capture CPU and allocation profiles with minimal overhead and automatic service discovery integration.

HPA works for CPU/memory-based scaling of stateless Java APIs. KEDA enables event-driven scaling based on Kafka lag, queue depth, or custom metrics crucial for async Java workers. Most production Java deployments in 2026 combine both: HPA for baseline capacity and KEDA for reactive burst handling.

Integrate Trivy or Grype into CI pipelines to scan container images before deployment. Enable SBOM generation with Syft and enforce admission policies via Kyverno. Regularly rebuild base images and pin specific digest versions rather than mutable tags to prevent supply chain attacks.

Output structured JSON logs directly to stdout using Logback JSON encoder or Log4j2 JsonLayout. Include trace_id, span_id, and pod metadata via MDC. Avoid file appenders since Kubernetes collects stdout natively. Let Fluent Bit or Vector handle aggregation, filtering, and shipping to your observability backend.

A typical 512Mi/0.5vCPU Java microservice costs roughly $15-25 monthly on managed cloud Kubernetes. Costs vary significantly by region, reserved capacity discounts, and node pool sizing. Right-size JVM heap relative to container memory and consolidate workloads onto fewer larger nodes to improve bin packing efficiency.