I like the simplicity of a bootable JAR. You package your application together with WildFly, copy one file into a container image, and let Kubernetes start as many instances as you need. At first, this looks like the ideal deployment model.
The problem starts when those instances need to behave as a cluster. Kubernetes treats pods as disposable. It generates a hostname, mounts a service account and secrets, and starts the container. When the pod disappears, its replacement gets a new identity. WildFly cannot treat that replacement as the same server and hope for the best. It still needs to join the correct cluster, distinguish itself from every other node, and recover unfinished transactions without another pod claiming the same transaction records.
This creates several runtime requirements. WildFly needs a unique node name for clustering and a unique transaction node identifier for recovery. JGroups needs a Kubernetes-aware discovery protocol so that each pod can find the other cluster members. The cluster password must come from a runtime secret. None of these values should be fixed inside the JAR because the same artifact runs in every pod.
The WildFly Cloud Galleon feature pack adds this cloud-specific setup while WildFly provisions the server. This worked differently with older deployment models. Launch scripts could modify an existing, provisioned server directory before starting it. A bootable JAR has no server directory to modify at that point. It creates the directory only during startup. The cloud configuration therefore has to become part of that bootable provisioning process.
WildFly 41 adds this support. The wildfly-maven-plugin can package the cloud feature pack into a bootable JAR. At startup, the cloud configurator applies the pod-specific settings to the temporary server installation. The JAR stays the same for every replica.
We will build TidalMesh, a small Jakarta EE order check-in API. Two replicas form a JGroups cluster through the Kubernetes API. We send the first request directly to pod A and the second request directly to pod B with the same session cookie. The second response must report two check-ins. That result shows that pod B loaded the session created on pod A.
The session counter exists only to make replication visible. Real order state belongs in durable storage. The production section covers the persistence and concurrency limits.
What we are building
TidalMesh has one endpoint:
POST /api/orders/{orderId}/check-insEach response includes the per-session check-in count, the WildFly node name, the transaction node identifier, and the HTTP session ID:
{
"orderId": "ORD-42",
"checkIns": 1,
"nodeName": "tidalmesh-55fcffbfb9-8gbxg",
"transactionNodeId": "almesh-55fcffbfb9-8gbxg",
"sessionId": "kpcuHrvWSh6FRDpsrOKu9cOJOYxkxoxN_PdV3ehp"
}The finished project contains:
A WAR containing the Jakarta REST application
A 126 MB bootable JAR containing that WAR and a provisioned WildFly 41 server
A Podman image based on the UBI 9 OpenJDK 21 runtime image
A two-replica Kubernetes deployment with
KUBE_PING, namespace-scoped role-based access control (RBAC), health probes, and an external cluster password
The project uses the following files:
tidalmesh-wildfly-bootable-jar/
├── .mvn/wrapper/maven-wrapper.properties
├── k8s/tidalmesh.yaml
├── scripts/verify-cluster.sh
├── src
│ ├── main
│ │ ├── java/com/mainthread/tidalmesh
│ │ │ ├── OrderResource.java
│ │ │ ├── OrderSession.java
│ │ │ └── TidalMeshApplication.java
│ │ └── webapp/WEB-INF/web.xml
│ └── test/java/com/mainthread/tidalmesh/OrderSessionTest.java
├── mvnw
└── pom.xmlWhat you need
This tutorial uses WildFly 41.0.0.Final, WildFly Cloud Galleon Pack 9.2.3.Final, and WildFly Maven Plugin 6.0.0.Final.
Java 21 or newer
Podman with a running machine on macOS or Windows
kubectlMinikube
curlandjqAbout ☕️☕️☕️ (clustering always is hard)
Maven compiles the source for Java 17. The generated container runs it on Java 21.
You can start directly from the example on my Github repository or follow along below:
git clone https://github.com/myfear/the-main-thread.git
cd the-main-thread/tidalmesh-wildfly-bootable-jarOn macOS or Windows, start the Podman machine if it is not already running:
podman machine startMost Linux setups run Podman directly, so you can skip that command.
Create the Jakarta EE application
First, set /api as the base path for the REST application:
package com.mainthread.tidalmesh;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api")
public class TidalMeshApplication extends Application {
}OrderSession keeps one counter per order in the current HTTP session:
package com.mainthread.tidalmesh;
import java.io.Serial;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import jakarta.enterprise.context.SessionScoped;
@SessionScoped
public class OrderSession implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Map<String, Integer> checkIns = new HashMap<>();
public int record(String orderId) {
return checkIns.merge(orderId, 1, Integer::sum);
}
}The bean implements Serializable because WildFly must marshal it into the distributed web-session cache. Keep clustered session objects small. WildFly needs this interface at runtime, and session replication fails when the stored object cannot be serialized.
Next, add the REST resource. It records a check-in and returns the runtime identity that WildFly calculated:
package com.mainthread.tidalmesh;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
@Path("/orders")
@RequestScoped
public class OrderResource {
private final OrderSession orderSession;
protected OrderResource() {
this.orderSession = null;
}
@Inject
public OrderResource(OrderSession orderSession) {
this.orderSession = orderSession;
}
@POST
@Path("/{orderId}/check-ins")
@Produces(APPLICATION_JSON)
public Response recordCheckIn(@PathParam("orderId") String orderId, @Context HttpServletRequest request) {
int checkIns = orderSession.record(orderId);
String nodeName = System.getProperty("jboss.node.name", "local");
String transactionNodeId = System.getProperty("jboss.tx.node.id", "local");
String body = Json.createObjectBuilder()
.add("orderId", orderId)
.add("checkIns", checkIns)
.add("nodeName", nodeName)
.add("transactionNodeId", transactionNodeId)
.add("sessionId", request.getSession().getId())
.build()
.toString();
return Response.ok(body, APPLICATION_JSON).build();
}
}The protected constructor is required for the normal-scoped CDI proxy. Weld uses the @Inject constructor to create the backing instance.
Now mark the web application as distributable in src/main/webapp/WEB-INF/web.xml:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
version="6.1">
<distributable/>
</web-app>Without <distributable/>, each pod keeps its own HTTP sessions. The application can be healthy on both pods and still lose the session when the next request reaches another replica.
Provision WildFly and the cloud runtime
The Maven build selects the server layers TidalMesh needs and asks the WildFly plugin to create a bootable JAR:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mainthread</groupId>
<artifactId>tidalmesh</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>TidalMesh</name>
<description>WildFly 41 cloud bootable JAR clustering demo</description>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<version.junit>5.13.4</version.junit>
<version.maven.compiler>3.14.0</version.maven.compiler>
<version.maven.surefire>3.5.3</version.maven.surefire>
<version.maven.war>3.4.0</version.maven.war>
<version.wildfly>41.0.0.Final</version.wildfly>
<version.wildfly.cloud>9.2.3.Final</version.wildfly.cloud>
<version.wildfly.maven.plugin>6.0.0.Final</version.wildfly.maven.plugin>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.wildfly.bom</groupId>
<artifactId>wildfly-ee-with-tools</artifactId>
<version>${version.wildfly}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-web-api</artifactId>
<version>11.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${version.junit}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${version.maven.compiler}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${version.maven.surefire}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>${version.maven.war}</version>
</plugin>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<version>${version.wildfly.maven.plugin}</version>
<configuration>
<feature-packs>
<feature-pack>
<location>org.wildfly:wildfly-galleon-pack:${version.wildfly}</location>
</feature-pack>
<feature-pack>
<location>org.wildfly.cloud:wildfly-cloud-galleon-pack:${version.wildfly.cloud}</location>
<excludedPackages>
<package>org.wildfly.cloud.launch.scripts</package>
</excludedPackages>
</feature-pack>
</feature-packs>
<layers>
<layer>jaxrs-server</layer>
<layer>jsonp</layer>
<layer>web-clustering</layer>
<layer>management</layer>
<layer>microprofile-health</layer>
</layers>
<name>ROOT.war</name>
<bootable-jar>true</bootable-jar>
<bootable-jar-name>tidalmesh-bootable.jar</bootable-jar-name>
<provisioning-dir>server</provisioning-dir>
<docker-binary>podman</docker-binary>
<jdk-version>21</jdk-version>
<image-name>tidalmesh</image-name>
<tag>latest</tag>
</configuration>
<executions>
<execution>
<id>package-bootable-jar</id>
<goals>
<goal>package</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>The configuration uses two feature packs. wildfly-galleon-pack supplies WildFly 41. wildfly-cloud-galleon-pack adds the cloud runtime and Kubernetes configuration. The feature-pack documentation lists 9.2.3.Final as the cloud pack for WildFly 41.
Each selected layer has a clear job:
jaxrs-serverandjsonpprovide the REST endpoint and JSON-P.web-clusteringadds distributable sessions, Infinispan, and JGroups.managementandmicroprofile-healthexpose health endpoints on port 9990.
The bootable JAR runs the cloud configurator, so the build excludes the old org.wildfly.cloud.launch.scripts package. Keep org.wildfly.cloud.bootable.runtime in the build because it provides the bootable JAR integration.
The unit test checks that each order keeps its own count:
package com.mainthread.tidalmesh;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class OrderSessionTest {
@Test
void keepsIndependentCountsPerOrder() {
OrderSession session = new OrderSession();
assertEquals(1, session.record("ORD-42"));
assertEquals(2, session.record("ORD-42"));
assertEquals(1, session.record("ORD-99"));
}
}Build and run the bootable JAR
Build the bootable JAR:
./mvnw clean verifyUse clean during development because the WildFly plugin reuses target/server when that directory already exists. A plain package can skip provisioning and leave an older deployment in the next bootable JAR.
The end of a successful build looks like this:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] Bootable JAR packaging DONE. To run the server:
java -jar .../target/tidalmesh-bootable.jar
[INFO] BUILD SUCCESSStart it locally. We clear HOSTNAME because this process does not run inside a pod:
env -u HOSTNAME JGROUPS_CLUSTER_PASSWORD=local-demo-secret \
java -jar target/tidalmesh-bootable.jarThe first log line shows that the cloud runtime is active:
Booting with the cloud configuratorYou will also see a KUBE_PING warning because the laptop has no Kubernetes API available to the process. After the discovery timeout, the application forms a one-member cluster. This is expected during the local test.
In another terminal, query readiness on the management port:
curl --fail --silent http://127.0.0.1:9990/health/ready | jqThe response includes the deployment check:
{
"status": "UP",
"checks": [
{
"name": "server-state",
"status": "UP",
"data": {
"value": "running"
}
},
{
"name": "deployments-status",
"status": "UP",
"data": {
"ROOT.war": "OK"
}
},
{
"name": "boot-errors",
"status": "UP"
},
{
"name": "suspend-state",
"status": "UP",
"data": {
"value": "RUNNING"
}
},
{
"name": "ready-deployment.ROOT.war",
"status": "UP"
}
]
}Next, keep one session cookie across two requests:
curl --fail --silent \
--cookie-jar /tmp/tidalmesh-cookies.txt \
--request POST \
http://127.0.0.1:8080/api/orders/ORD-42/check-ins | jq
curl --fail --silent \
--cookie /tmp/tidalmesh-cookies.txt \
--request POST \
http://127.0.0.1:8080/api/orders/ORD-42/check-ins | jq
The two responses report checkIns values of 1 and 2 with the same sessionId. Stop the local process with Ctrl+C.
Build the image with the plugin
The WildFly plugin can also generate the container recipe and run Podman:
./mvnw clean package wildfly:imageThe image goal reads <docker-binary>podman</docker-binary> from the POM. It writes this target/Dockerfile:
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:latest
COPY --chown=default:root tidalmesh-bootable.jar /deployments
CMD $JBOSS_CONTAINER_JAVA_RUN_MODULE/run-java.sh $JAVA_ARGSThe build output shows the exact command and tag:
[INFO] Executing the following command to build application image:
'podman build -t tidalmesh:latest .'
[INFO] Successfully tagged localhost/tidalmesh:latest
[INFO] Successfully built application image tidalmesh:latestPodman stores the local image as localhost/tidalmesh:latest. Use that exact name when you load the image into Minikube.
Start Kubernetes and load the image
Create an isolated Minikube profile with the Podman driver:
minikube start --profile tidalmesh --driver=podmanMinikube’s name-based loader may check a Docker daemon and miss Podman’s image store. Export the image as a Docker-compatible archive and load that file:
podman save \
--format docker-archive \
--output /tmp/tidalmesh-image.tar \
localhost/tidalmesh:latest
minikube image load \
--profile tidalmesh \
/tmp/tidalmesh-image.tarVerify the tag that Kubernetes will use:
minikube image ls --profile tidalmesh | grep tidalmeshExpected output:
localhost/tidalmesh:latestGive KUBE_PING the minimum Kubernetes access
KUBE_PING discovers cluster members by listing pods with a matching label. The JGroups Kubernetes discovery documentation requires get and list access to pods. A namespace-scoped Role gives TidalMesh enough access.
The complete k8s/tidalmesh.yaml is:
apiVersion: v1
kind: ServiceAccount
metadata:
name: tidalmesh
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: tidalmesh-pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tidalmesh-pod-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: tidalmesh-pod-reader
subjects:
- kind: ServiceAccount
name: tidalmesh
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tidalmesh
spec:
replicas: 2
selector:
matchLabels:
app: tidalmesh
template:
metadata:
labels:
app: tidalmesh
spec:
serviceAccountName: tidalmesh
containers:
- name: tidalmesh
image: localhost/tidalmesh:latest
imagePullPolicy: Never
env:
- name: KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: KUBERNETES_LABELS
value: app=tidalmesh
- name: JGROUPS_CLUSTER_PASSWORD
valueFrom:
secretKeyRef:
name: tidalmesh-cluster
key: password
ports:
- name: http
containerPort: 8080
- name: management
containerPort: 9990
- name: jgroups
containerPort: 7600
readinessProbe:
httpGet:
path: /health/ready
port: management
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /health/live
port: management
initialDelaySeconds: 15
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: tidalmesh
spec:
selector:
app: tidalmesh
ports:
- name: http
port: 8080
targetPort: httpThe Kubernetes downward API puts the current namespace into KUBERNETES_NAMESPACE, so the manifest also works outside the default namespace. KUBERNETES_LABELS limits discovery to TidalMesh pods. Kubernetes mounts the service account token, CA certificate, and API address into the pod.
WildFly exposes health on the management interface at port 9990. The Kubernetes Service exposes only the application port at 8080.
Create the cluster password separately from the manifest:
kubectl create secret generic tidalmesh-cluster \
--from-literal=password='tidalmesh-demo-only-change-me'The password activates the JGroups AUTH protocol with a SHA-512 digest token. AUTH checks the password when a node joins the cluster. JGroups traffic remains unencrypted, so use JGroups TLS, a service mesh, or another network encryption control when the cluster traffic must stay private.
Apply the workload and wait for both replicas:
kubectl apply --filename k8s/tidalmesh.yaml
kubectl rollout status deployment/tidalmesh --timeout=180s
kubectl get pods --selector app=tidalmeshExpected status:
NAME READY STATUS RESTARTS
tidalmesh-55fcffbfb9-8gbxg 1/1 Running 0
tidalmesh-55fcffbfb9-wjrlj 1/1 Running 0Check the cloud configuration
Both pod logs should show the same two-member cluster:
kubectl logs \
--selector app=tidalmesh \
--prefix \
--tail=-1 \
--max-log-requests=2 |
grep "joined cluster 'ee'"The second pod reports both node names:
Connected 'ee' channel. 'tidalmesh-55fcffbfb9-wjrlj'
joined cluster 'ee' with view:
[tidalmesh-55fcffbfb9-8gbxg, tidalmesh-55fcffbfb9-wjrlj]The startup log also shows the node identifiers:
-Djboss.node.name=tidalmesh-55fcffbfb9-wjrlj
-Djboss.tx.node.id=almesh-55fcffbfb9-wjrljThe node name comes from the pod’s HOSTNAME. A transaction node identifier may contain at most 23 bytes, so the cloud configurator keeps the last 23 characters. This keeps the random ReplicaSet and pod suffixes for the Kubernetes naming pattern used here.
The bootable JAR writes the temporary installation path to a fixed marker file. Read it from one pod:
pod="$(kubectl get pods \
--selector app=tidalmesh \
--output jsonpath='{.items[0].metadata.name}')"
kubectl exec "${pod}" -- \
cat /tmp/wildfly-bootable-jar/install-dirExpected shape:
/tmp/wildfly-bootable-server8279979314052469027The extracted directory disappears with the container, and the server configuration is read-only. Keep durable configuration in the build, environment, Kubernetes resources, or an external system. Any CLI change inside the pod disappears when the pod stops.
Prove session replication across two pods
The Kubernetes Service could send both requests to the same pod. The verification script chooses both targets directly. It starts one port-forward per pod, creates a session on the first pod, and sends the same cookie to the second:
#!/usr/bin/env bash
set -euo pipefail
namespace="${1:-default}"
work_dir="$(mktemp -d)"
first_forward_pid=""
second_forward_pid=""
cleanup() {
if [[ -n "${first_forward_pid}" ]]; then
kill "${first_forward_pid}" 2>/dev/null || true
wait "${first_forward_pid}" 2>/dev/null || true
fi
if [[ -n "${second_forward_pid}" ]]; then
kill "${second_forward_pid}" 2>/dev/null || true
wait "${second_forward_pid}" 2>/dev/null || true
fi
rm -rf "${work_dir}"
}
trap cleanup EXIT
pod_list="$(
kubectl get pods \
--namespace "${namespace}" \
--selector app=tidalmesh \
--field-selector status.phase=Running \
--output jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
)"
pod_count="$(printf '%s\n' "${pod_list}" | sed '/^$/d' | wc -l | tr -d ' ')"
if [[ "${pod_count}" -ne 2 ]]; then
echo "Expected two running TidalMesh pods, found ${pod_count}." >&2
exit 1
fi
first_pod="$(printf '%s\n' "${pod_list}" | sed -n '1p')"
second_pod="$(printf '%s\n' "${pod_list}" | sed -n '2p')"
kubectl port-forward --namespace "${namespace}" "pod/${first_pod}" \
18080:8080 19990:9990 >"${work_dir}/pod-0.log" 2>&1 &
first_forward_pid=$!
kubectl port-forward --namespace "${namespace}" "pod/${second_pod}" \
18081:8080 19991:9990 >"${work_dir}/pod-1.log" 2>&1 &
second_forward_pid=$!
for port in 19990 19991; do
for attempt in {1..30}; do
if curl --silent --fail "http://127.0.0.1:${port}/health/ready" >/dev/null; then
break
fi
if [[ "${attempt}" -eq 30 ]]; then
echo "Port-forward on ${port} did not become ready." >&2
exit 1
fi
sleep 1
done
done
first_response="$(
curl --silent --show-error --fail \
--cookie-jar "${work_dir}/cookies.txt" \
--request POST \
http://127.0.0.1:18080/api/orders/ORD-42/check-ins
)"
second_response="$(
curl --silent --show-error --fail \
--cookie "${work_dir}/cookies.txt" \
--request POST \
http://127.0.0.1:18081/api/orders/ORD-42/check-ins
)"
first_count="$(jq --raw-output '.checkIns' <<<"${first_response}")"
second_count="$(jq --raw-output '.checkIns' <<<"${second_response}")"
first_session="$(jq --raw-output '.sessionId' <<<"${first_response}")"
second_session="$(jq --raw-output '.sessionId' <<<"${second_response}")"
first_node="$(jq --raw-output '.nodeName' <<<"${first_response}")"
second_node="$(jq --raw-output '.nodeName' <<<"${second_response}")"
if [[ "${first_count}" != "1" || "${second_count}" != "2" ]]; then
echo "Expected replicated counts 1 and 2." >&2
echo "${first_response}" >&2
echo "${second_response}" >&2
exit 1
fi
if [[ "${first_session}" != "${second_session}" ]]; then
echo "The HTTP session ID changed between pods." >&2
exit 1
fi
if [[ "${first_node}" == "${second_node}" ]]; then
echo "Both responses came from ${first_node}; expected two nodes." >&2
exit 1
fi
jq --null-input \
--arg firstNode "${first_node}" \
--arg secondNode "${second_node}" \
--arg sessionId "${first_session}" \
'{
firstNode: $firstNode,
secondNode: $secondNode,
sessionId: $sessionId,
replicatedCounts: [1, 2]
}'Run it:
./scripts/verify-cluster.shThe output must contain two different pod names, one session ID, and counts of 1 and 2:
{
"firstNode": "tidalmesh-55fcffbfb9-8gbxg",
"secondNode": "tidalmesh-55fcffbfb9-wjrlj",
"sessionId": "kpcuHrvWSh6FRDpsrOKu9cOJOYxkxoxN_PdV3ehp",
"replicatedCounts": [
1,
2
]
}The same session ID appears on both nodes, and the count continues on the second pod. <distributable/> enables clustered sessions, web-clustering provides the distributed cache, and KUBE_PING gives JGroups the two-node view.
Before production
The counter exists to show cluster behavior. A real order check-in belongs in a database, an event log, or another durable system of record. A replicated HTTP session works for short-lived user state. It cannot provide a linearizable distributed counter that behaves as if all increments run one at a time in a single global order. It also cannot replace business persistence. Concurrent requests in one session need an explicit concurrency design.
Store the cluster password through your platform’s secret integration, plan its rotation, and add transport encryption where required. A rollout that changes the password while old and new pods overlap can split the cluster. Keep KUBERNETES_LABELS application-specific so KUBE_PING returns only TidalMesh pods, and keep the RBAC Role namespace-scoped. A shared label and the default JGroups cluster name can connect unrelated WildFly workloads.
The management listener binds to all interfaces so Kubernetes can reach the health endpoints. Other pods may still reach port 9990 even though the Service does not expose it. Use a NetworkPolicy to restrict that access. Outside Minikube, pull the image from an authenticated registry and pin a version tag or image digest. imagePullPolicy: Never and the latest tag belong only in this local setup.
Watch Java serialization compatibility during rolling deployments because old and new replicas may read the same session data. Small, version-tolerant session objects reduce deserialization failures during that overlap. Also verify transaction node uniqueness with your own pod naming rules. WildFly keeps the last 23 characters of HOSTNAME for jboss.tx.node.id. Kubernetes’ generated suffixes work in this deployment. A custom hostname scheme may remove the characters that make each identifier unique.
Clean up
Remove the workload and secret, then stop the Minikube profile:
kubectl delete --filename k8s/tidalmesh.yaml
kubectl delete secret tidalmesh-cluster
minikube stop --profile tidalmesh
rm /tmp/tidalmesh-image.tarOne JAR, different runtime identities
Maven decides which WildFly capabilities go into the bootable JAR. When a pod starts, Kubernetes supplies its hostname, namespace, and service account credentials. WildFly uses those values to set the node identity and discover peers. Kubernetes checks the management health endpoints before it sends traffic.
The result is one immutable JAR with two runtime identities. The pods discover each other with namespace-scoped Kubernetes permissions, authenticate cluster membership, and serve the same replicated session.
Tell me again that modern Jakarta EE is old fashioned and hard to manage. Not with WildFly.


