Mr.PlanB Logo

    Newsletter

    Subscribe our newsletter

    Get new infrastructure guides, comparison reports, and migration notes in your inbox.

    Infrastructure notes, guides, and new tools. Unsubscribe anytime.

    Back to Blog
    Kubernetes
    DevOps
    Infrastructure

    How to Run a Java Monolith on Kubernetes Without NodePort Hacks

    February 18, 2026
    5 min read

    A developer running a Java monolith on Kubernetes described this setup:

    • Ubuntu host
    • Apache2 handling SSL
    • kind cluster
    • Moqui + OpenSearch in pods
    • MySQL on the host
    • Service type: NodePort
    • Apache reverse proxying to 172.x.x.x:30083

    It works, but it feels duct-taped together, and that instinct is correct. Here is a cleaner, production-style layout that stays self-managed and uses no cloud load balancers.

    1. Is NodePort behind an Apache reverse proxy bad?

    For local development it's fine. For production it's fragile. Apache is proxying to a kind internal IP, which is brittle because:

    • kind is not meant for production
    • Control-plane IPs are not stable design boundaries
    • You're bypassing Kubernetes' intended ingress model

    Avoiding cloud-managed load balancers and running everything on your own machine is a perfectly reasonable choice. On bare metal, a clean production-style setup looks like this:

    Recommended architecture (self-managed)

    Internet
       ↓
    Apache (or Nginx) — TLS termination
       ↓
    Kubernetes Ingress Controller
       ↓
    Service (ClusterIP)
       ↓
    Pods
    

    Instead of NodePort, install an Ingress controller such as:

    • NGINX (nginx-ingress)
    • Traefik

    Then either point Apache at the ingress controller instead of a NodePort or, better, remove Apache entirely and let the ingress controller handle TLS with cert-manager.

    If you stay fully self-managed on a single machine, the simplest clean version is to install nginx-ingress, terminate TLS at the ingress, and use hostNetwork, or MetalLB if you later scale to multiple nodes.

    NodePort isn't wrong. It is low-level plumbing, and Ingress exists so you don't have to wire ports by hand forever.

    2. Autoscaling a Java monolith

    The pods were using 400 to 500MB of RAM each, so scaling from 1 to 3 replicas took about 1.5GB of memory. That is simply how horizontal scaling works. Kubernetes doesn't share JVM heap between replicas, so every replica is a full JVM, and the memory cost comes from the Java architecture. There are still places to optimize.

    JVM tuning

    If you don't explicitly set these flags, the JVM may over-allocate:

    -XX:MaxRAMPercentage
    -Xms
    -Xmx
    

    Set memory limits in Kubernetes:

    resources:
      requests:
        memory: "512Mi"
      limits:
        memory: "512Mi"
    

    Then tune the JVM to respect the container's memory limit. Otherwise the pod will use more memory than you expect.

    HPA strategy

    The Horizontal Pod Autoscaler works better with CPU than with memory, because memory usage is sticky and CPU-based scaling reacts faster. A 90% CPU utilization target is reasonable. The more useful number to find is how many concurrent requests one Moqui pod can handle. Measure that and use it as your scaling unit.

    The bigger question

    Running 3 replicas for availability or for traffic is fine. If you are scaling because memory grows with session storage, that points to an architecture problem, and it leads to the real pain point in this setup.

    3. Sessions, logs, locks and PVC bottlenecks

    The monolith keeps these directories on a shared RWX PVC across multiple replicas, and it runs into LOCK issues:

    • logs/
    • txlogs/
    • sessions/

    This is the real blocker, and the cause is shared filesystem semantics. Multiple Java pods writing to the same RWX volume will cause locking contention, especially for sessions.

    Sessions should not be on disk

    Disk-based sessions don't scale horizontally. The standard production approach for scaling a Java monolith is Redis, whether that is an external Redis, Redis in Kubernetes or a Redis cluster. The session store becomes a centralized in-memory key-value store, so there is no file locking, no RWX problem and no user logout when a request lands on a different pod.

    Logs should not be on a shared PVC

    Logs belong on stdout, where Fluent Bit, Loki or an ELK stack can collect them, because the Kubernetes logging model expects stdout instead of shared files. If you need transaction tracing, ship the logs to centralized logging instead of a shared disk.

    Static files and uploads

    That leaves a clean separation of concerns:

    • Sessions → Redis
    • Logs → centralized logging
    • Uploads → object storage

    Object storage suits uploads because it is safe to use from every replica, needs no locking, avoids file corruption and scales independently. If you are fully self-hosted, run MinIO (S3-compatible), or NFS, although NFS is the older approach compared with object storage. An RWX PVC is the least scalable option here.

    4. Should MySQL run inside Kubernetes?

    MySQL currently runs on the host, and that is fine. Running databases inside Kubernetes as a StatefulSet is possible, but it adds volume management, backup orchestration and HA complexity. On a single bare-metal machine, keeping MySQL outside Kubernetes is completely reasonable. If you move to multiple nodes, reconsider.

    5. Managing Kubernetes through a UI

    You can manage the cluster through a UI with:

    • Lens
    • Kubernetes Dashboard
    • Portainer

    Lens gives by far the cleanest experience for remote cluster management. You download the kubeconfig and connect, without relying on fragile NodePort exposure.

    The production shape for this case

    For self-hosting on local hardware with full control, a clean architecture looks like this:

    • Bare metal server
    • Proper Kubernetes distro (not kind), such as RKE2, Talos or k3s
    • nginx-ingress installed
    • TLS handled at ingress
    • Moqui pods (no shared filesystem for sessions)
    • Redis for sessions
    • Object storage (MinIO) for uploads
    • MySQL either outside k8s or as StatefulSet
    • Centralized logging

    That removes the NodePort, the control-plane IP dependencies and the RWX locks.

    The honest take

    The setup is stuck because monoliths expect a shared disk while Kubernetes expects stateless pods. Once you move:

    • Sessions → Redis
    • Logs → stdout
    • Uploads → object storage

    scaling becomes boring, and boring is good. The current architecture works, but it is tightly coupled to a single-machine mental model, and Kubernetes works best once that filesystem coupling is gone.