How to Run a Java Monolith on Kubernetes Without NodePort Hacks
A developer running a Java monolith on Kubernetes described this setup:
- Ubuntu host
- Apache2 handling SSL
kindcluster- 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:
kindis 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.