Appendix T: Deploying Camel to Kubernetes
Standard Maven builds, Containerfile images and Kubernetes deployments for Camel applications: Quarkus and Spring Boot on minikube (docker driver, containerd) with Strimzi Kafka and Redis.
Every chapter in this tutorial runs against a local compose stack. But production Camel applications run on Kubernetes. This appendix covers the full deployment lifecycle (building container images, generating manifests, deploying to a real cluster, scaling, and externalizing configuration) using standard Maven tooling and a Containerfile per runtime. The only operator is Strimzi, for Kafka.
The code is in examples/38-kubernetes-deploy/.
# Quarkus
cd examples/38-kubernetes-deploy/quarkus
mvn quarkus:dev
# Spring Boot
cd examples/38-kubernetes-deploy/spring-boot
mvn spring-boot:run
Why standard builds
Operator-based approaches like Camel K abstract away the build-and-deploy cycle. That convenience comes at a cost: the operator must be installed and maintained, your CI/CD pipeline can’t use standard Maven phases, and debugging requires understanding both the operator’s reconciliation loop and the underlying Kubernetes resources.
Standard builds give you:
| Advantage | Detail |
|---|---|
| Portability | The same image and manifests work on Minikube, EKS, GKE, AKS, and OpenShift |
| CI/CD integration | mvn package in any pipeline; no custom CLI or operator dependency |
| Team familiarity | Dockerfiles, Deployments, and ConfigMaps are universal Kubernetes knowledge |
| Debugging | kubectl logs, kubectl describe pod: nothing between your code and the cluster |
Prerequisites
The cluster runs on Docker Engine (docker-ce, docker context default) with
minikube’s docker driver and the containerd runtime inside the node. No
desktop application is involved. Fedora and RHEL hosts.
docker context show # default
minikube version # v1.39.0+
kubectl version # client v1.36.x
helm version # v3.18+ or v4
java -version # 25+
mvn -version # 3.9+
jq --version
One local cluster at a time: stop OpenShift Local (crc stop) before
creating the profile; the setup script refuses to run beside it.
The minikube profile
The setup script creates a dedicated profile, eip, and passes every setting
on the command line. It never runs minikube config set, which would change
minikube’s global defaults for every other project on the machine:
minikube start -p eip \
--driver=docker --container-runtime=containerd \
--kubernetes-version=v1.36.5 \
--cpus=4 --memory=8192 \
--ports=127.0.0.1:30088:30088
Two choices in that command matter later:
--container-runtime=containerd. The node runs containerd, not a Docker daemon, soeval $(minikube docker-env)has nothing to talk to. Images are built on the host withdocker buildand copied into the node withminikube -p eip image load.--ports=127.0.0.1:30088:30088. The application’s Service is a NodePort on 30088, and this publishes it on the host’s loopback when the profile is created.http://127.0.0.1:30088reaches the Service directly: nokubectl port-forward, nominikube tunnel, nominikube service, all of which drop mid-session. Ports are fixed at creation; adding one means recreating the profile (--replace).
Every later command names its target explicitly (minikube -p eip,
kubectl --context eip, helm --kube-context eip) so nothing depends on, or
changes, your current kubectl context.
The script does all of this, plus the infrastructure below:
./scripts/setup-minikube-stack.sh
Deploying infrastructure
The example needs Kafka and Redis running inside the cluster.
Strimzi Kafka
Strimzi deploys and manages Kafka on Kubernetes via custom resources. Install the operator from its OCI Helm chart, pinned:
helm --kube-context eip upgrade --install strimzi \
oci://quay.io/strimzi-helm/strimzi-kafka-operator --version 1.2.0 \
-n kafka --create-namespace --wait --timeout 300s
Strimzi 1.x serves only the kafka.strimzi.io/v1 API, and KRaft is the only
mode: there is no ZooKeeper block, and the broker and controller roles live on
a KafkaNodePool. The cluster from examples/38-kubernetes-deploy/k8s-infra/kafka-cluster.yaml:
apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
name: dual-role
namespace: kafka
labels:
strimzi.io/cluster: eip-kafka
spec:
replicas: 1
roles:
- controller
- broker
storage:
type: ephemeral
---
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
name: eip-kafka
namespace: kafka
spec:
kafka:
version: 4.3.1
listeners:
- name: plain
port: 9092
type: internal
tls: false
config:
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
default.replication.factor: 1
min.insync.replicas: 1
entityOperator:
topicOperator: {}
One dual-role node with ephemeral storage is appropriate for development. Production clusters use separate controller and broker pools, persistent storage and three or more replicas.
kubectl --context eip apply -f examples/38-kubernetes-deploy/k8s-infra/kafka-cluster.yaml
kubectl --context eip wait kafka/eip-kafka --for=condition=Ready -n kafka --timeout=600s
Create the topics:
kubectl --context eip apply -f examples/38-kubernetes-deploy/k8s-infra/kafka-topics.yaml
This creates eip.orders.incoming and eip.orders.enriched, each with 3 partitions.
Redis
Redis runs from the official image as a plain Deployment and Service in its
own namespace (k8s-infra/redis.yaml): one replica, no persistence, no
authentication: development only.
kubectl --context eip apply -f examples/38-kubernetes-deploy/k8s-infra/redis.yaml
kubectl --context eip rollout status deployment/redis -n redis
Verify both:
kubectl --context eip get pods -n kafka # eip-kafka-dual-role-0 Running
kubectl --context eip get pods -n redis # redis-... Running
The example application
The example is an order-processing pipeline with three Camel routes:
| Route | Role |
|---|---|
| OrderApiRoute | REST endpoint: POST /api/orders → publishes to Kafka eip.orders.incoming |
| OrderProcessorRoute | Kafka consumer → enriches each order with customer name from Redis → publishes to eip.orders.enriched |
| CacheLoaderRoute | Timer (runs once at startup) → pre-populates Redis with sample customer records |
The route logic is identical across runtimes; only the class annotations and Redis API differ:
@ApplicationScoped
public class OrderProcessorRoute extends RouteBuilder {
@Inject
RedisAPI redis;
@Override
public void configure() {
from("kafka:eip.orders.incoming?brokers={{kafka.brokers}}&groupId=k8s-order-processor&autoOffsetReset=earliest")
.routeId("order-processor")
.log("Processing order: ${body}")
.unmarshal().json(JsonLibrary.Jackson, Map.class)
.process(this::enrichFromCache)
.marshal().json(JsonLibrary.Jackson)
.to("kafka:eip.orders.enriched?brokers={{kafka.brokers}}")
.log("Enriched order published: ${body}");
}
}
@Component
public class OrderProcessorRoute extends RouteBuilder {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void configure() {
from("kafka:eip.orders.incoming?brokers={{kafka.brokers}}&groupId=k8s-order-processor&autoOffsetReset=earliest")
.routeId("order-processor")
.log("Processing order: ${body}")
.unmarshal().json(JsonLibrary.Jackson, Map.class)
.process(this::enrichFromCache)
.marshal().json(JsonLibrary.Jackson)
.to("kafka:eip.orders.enriched?brokers={{kafka.brokers}}")
.log("Enriched order published: ${body}");
}
}
The enrichFromCache method looks up customer:<customerId> in Redis and adds the customer name to the order. On a cache miss, it falls back to a default value; in production, you’d call a customer service here.
Building container images
Each runtime directory carries a Containerfile that copies the Maven build
output onto a pinned UBI 10 OpenJDK 25 runtime image
(registry.access.redhat.com/ubi10/openjdk-25-runtime:1.24-15). Build on the
host with Docker Engine, then load the image into the profile’s containerd:
cd examples/38-kubernetes-deploy/quarkus
mvn package -DskipTests
docker build -f Containerfile -t eip/eip-k8s-deploy:1.0 .
minikube -p eip image load eip/eip-k8s-deploy:1.0
cd examples/38-kubernetes-deploy/spring-boot
mvn package -DskipTests
docker build -f Containerfile -t eip/eip-k8s-deploy-springboot:1.0 .
minikube -p eip image load eip/eip-k8s-deploy-springboot:1.0
The Quarkus image copies the fast-jar layout (target/quarkus-app/) and starts
it with the image’s run-java.sh; the Spring Boot image runs the executable
jar. Both run as UID 185 and use an exact tag (never latest), so a rebuild
is an explicit image load followed by kubectl rollout restart.
Why not build inside the node? With the containerd runtime there is no Docker
daemon in the node to build against, and handing images between engines is
exactly what image load exists for.
Kubernetes manifests
With the image loaded, you need Kubernetes resources to deploy it.
# Quarkus — the quarkus-kubernetes extension generates manifests at build time
cd examples/38-kubernetes-deploy/quarkus
mvn package -DskipTests
cat target/kubernetes/kubernetes.yml
# Spring Boot — hand-written manifests in the k8s/ directory
ls examples/38-kubernetes-deploy/spring-boot/k8s/
# configmap.yaml deployment.yaml service.yaml
Quarkus: generated manifests
The quarkus-kubernetes extension reads properties from application.properties and generates a Deployment and Service at build time. Key configuration:
quarkus.container-image.group=eip
quarkus.container-image.name=eip-k8s-deploy
quarkus.container-image.tag=1.0
quarkus.kubernetes.namespace=default
quarkus.kubernetes.replicas=1
quarkus.kubernetes.image-pull-policy=never
quarkus.kubernetes.service-type=node-port
quarkus.kubernetes.node-port=30088
quarkus.kubernetes.env.vars.kafka-brokers=eip-kafka-kafka-bootstrap.kafka.svc.cluster.local:9092
quarkus.kubernetes.env.vars.quarkus-redis-hosts=redis://redis.redis.svc.cluster.local:6379
quarkus.kubernetes.resources.requests.cpu=250m
quarkus.kubernetes.resources.requests.memory=256Mi
quarkus.kubernetes.resources.limits.cpu=500m
quarkus.kubernetes.resources.limits.memory=512Mi
env.vars.kafka-brokers becomes the container variable KAFKA_BROKERS, which
overrides kafka.brokers from application.properties. (The older
env-vars.<NAME>.value form is silently ignored by current Quarkus: the
manifest is generated with no such variable, and the pod connects to
localhost:9092.) The container-image keys only name the image the manifest references; the
image itself comes from the Containerfile above. Health probes are
auto-configured because camel-quarkus-microprofile-health brings SmallRye
Health: the extension wires /q/health/live and /q/health/ready into the
generated Deployment.
Spring Boot: hand-written manifests
The Spring Boot variant includes manifests in k8s/ that you write and version-control. This is educational: you see exactly what Kubernetes resources your application needs.
configmap.yaml externalizes the connection settings:
apiVersion: v1
kind: ConfigMap
metadata:
name: eip-k8s-config
data:
KAFKA_BROKERS: "eip-kafka-kafka-bootstrap.kafka.svc.cluster.local:9092"
SPRING_DATA_REDIS_HOST: "redis.redis.svc.cluster.local"
SPRING_DATA_REDIS_PORT: "6379"
deployment.yaml defines the pod spec with health probes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: eip-k8s-deploy
spec:
replicas: 1
selector:
matchLabels:
app: eip-k8s-deploy
template:
metadata:
labels:
app: eip-k8s-deploy
spec:
containers:
- name: eip-k8s-deploy
image: eip/eip-k8s-deploy-springboot:1.0
imagePullPolicy: Never
ports:
- containerPort: 8088
envFrom:
- configMapRef:
name: eip-k8s-config
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8088
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8088
initialDelaySeconds: 15
periodSeconds: 5
service.yaml is a NodePort on the published port:
apiVersion: v1
kind: Service
metadata:
name: eip-k8s-deploy
spec:
selector:
app: eip-k8s-deploy
ports:
- port: 8088
targetPort: 8088
nodePort: 30088
type: NodePort
Note imagePullPolicy: Never: Kubernetes uses the image minikube image load put in the node instead of pulling from a registry. In production, you’d push to a registry and use IfNotPresent or Always.
Deploying to the cluster
kubectl --context eip apply -f examples/38-kubernetes-deploy/quarkus/target/kubernetes/kubernetes.yml
kubectl --context eip apply -f examples/38-kubernetes-deploy/spring-boot/k8s/
Both variants deploy as deployment/eip-k8s-deploy behind the same NodePort,
so deploying one replaces the other; delete the Deployment first when you
switch, because the two select their pods with different labels and a
Deployment’s selector is immutable. Watch the rollout:
kubectl --context eip rollout status deployment/eip-k8s-deploy
deploy-minikube.sh does the build, image load, apply and rollout in one
step, then runs the checks in the next section:
./examples/38-kubernetes-deploy/deploy-minikube.sh quarkus
./examples/38-kubernetes-deploy/deploy-minikube.sh spring-boot
Verifying the deployment
The Service is a NodePort published on the host’s loopback, so the API answers
on 127.0.0.1:30088 directly:
curl -X POST http://127.0.0.1:30088/api/orders \
-H "Content-Type: application/json" \
-d '{"orderId":"ORD-001","customerId":"C-101","item":"Shipping Container","quantity":2}'
# {"status":"accepted"}
Follow the application logs:
kubectl --context eip logs -f deployment/eip-k8s-deploy
You should see:
- OrderApiRoute receives the order and publishes to
eip.orders.incoming - OrderProcessorRoute consumes from Kafka, enriches with customer name “Global Freight Ltd” from Redis, and publishes to
eip.orders.enriched
Check health probes:
curl http://127.0.0.1:30088/q/health
# {"status":"UP","checks":[{"name":"camel-readiness-checks","status":"UP"}, ...]}
curl http://127.0.0.1:30088/actuator/health
# {"status":"UP","components":{"camelHealth":{"status":"UP"}, ...}}
To read the enriched topic from inside the cluster, exec into the broker pod instead of exposing Kafka to the host:
kubectl --context eip exec -n kafka eip-kafka-dual-role-0 -- \
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic eip.orders.enriched --from-beginning --max-messages 1
Scaling and consumer groups
One of the primary reasons to deploy to Kubernetes is horizontal scaling. Kafka consumer groups make this straightforward:
# Scale to 3 replicas
kubectl --context eip scale deployment/eip-k8s-deploy --replicas=3
# Watch the pods come up
kubectl --context eip get pods -w
When the new pods start, Kafka triggers a consumer group rebalance. The 3 partitions of eip.orders.incoming are distributed across the 3 pods: each pod processes a subset of the partitions. Watch the logs to see partition assignments:
kubectl --context eip logs -f deployment/eip-k8s-deploy --all-containers --prefix
Scaling back down reverses the process: Kafka redistributes partitions to the remaining consumers.
Partition count = max parallelism. Our topics have 3 partitions, so scaling beyond 3 replicas means some pods will be idle Kafka consumers. For higher throughput, increase the partition count when creating the topic.
Externalized configuration
The application.properties in the container image uses localhost for Kafka and Redis, which is fine for local development. Inside the cluster, the ConfigMap overrides these via environment variables.
Spring Boot automatically maps environment variables to properties: KAFKA_BROKERS overrides kafka.brokers, SPRING_DATA_REDIS_HOST overrides spring.data.redis.host.
Quarkus does the same: environment variables take precedence over application.properties values following the MicroProfile Config specification.
For sensitive data like Redis passwords, use a Kubernetes Secret instead of a ConfigMap:
apiVersion: v1
kind: Secret
metadata:
name: eip-k8s-secrets
type: Opaque
stringData:
REDIS_PASSWORD: "your-redis-password"
Reference it in the Deployment with secretRef alongside configMapRef.
Cleanup
Remove the application and infrastructure, then stop the profile:
./scripts/setup-minikube-stack.sh --clean # app, Kafka, Redis, Strimzi; then minikube stop -p eip
./scripts/setup-minikube-stack.sh --delete # or remove the profile entirely
By hand, the same order: custom resources while their operator still runs:
kubectl --context eip delete deployment/eip-k8s-deploy service/eip-k8s-deploy
kubectl --context eip delete -f examples/38-kubernetes-deploy/k8s-infra/kafka-topics.yaml
kubectl --context eip delete -f examples/38-kubernetes-deploy/k8s-infra/kafka-cluster.yaml
helm --kube-context eip uninstall strimzi -n kafka
kubectl --context eip delete -f examples/38-kubernetes-deploy/k8s-infra/redis.yaml
minikube stop -p eip
What you learned
- Container image builds: a
Containerfileper runtime on a pinned UBI 10 OpenJDK 25 image, built withdocker buildand loaded into minikube’s containerd withminikube image load - Host access without tunnels: a NodePort published on
127.0.0.1when the profile is created - Manifest generation: Quarkus auto-generates Kubernetes resources from
application.properties; Spring Boot uses hand-written YAMLs that are explicit and version-controlled - Health probes: MicroProfile Health and Spring Boot Actuator provide liveness and readiness checks that Kubernetes uses for pod lifecycle management
- Scaling:
kubectl scalecombined with Kafka consumer groups gives you horizontal scaling with automatic partition rebalancing - Externalized configuration: ConfigMaps and Secrets override application properties inside the cluster without rebuilding the image
Build, run, observe
./scripts/setup-minikube-stack.sh
./examples/38-kubernetes-deploy/deploy-minikube.sh quarkus
The script builds the image, loads it into the eip minikube profile, deploys the Helm chart, and sends one test order so you can watch it get enriched with Global Freight Ltd from Redis and the pods take partitions when scaled to three replicas.
References
To run the same example on OpenShift, with images built inside the cluster and Routes instead of NodePorts, see Appendix Z.