How I Run Virtual Kubernetes Clusters with k3k, ArgoCD and Cloudflare Tunnels
Running multiple isolated Kubernetes environments in a homelab usually means either spinning up separate VMs which are expensive on…
How I Run Virtual Kubernetes Clusters with k3k, ArgoCD and Cloudflare Tunnels

How I Run Virtual Kubernetes Clusters with k3k, ArgoCD and Cloudflare Tunnels
Running multiple isolated Kubernetes environments in a homelab usually means either spinning up separate VMs which are expensive on resources or using namespace-level separation on a shared cluster, which gives you no real isolation. k3k offers a third path: virtual K3s clusters running inside your existing cluster, each with its own API server, CIDRs, and kubeconfig, without the VM overhead.
k3k is conceptually similar to vcluster but is K3s-native and maintained by the Rancher team at SUSE. When I finally sat down with it, I ran a shared-mode virtual cluster on my RKE2 homelab node, deployed NeuVector into it via ArgoCD, and wired up external access using a Cloudflare tunnel operator I had built earlier. This article covers the full setup: k3k installation, how service and storage sync actually work in shared mode, the GitOps workflow, and how external access flows end to end.
On a free medium plan? Read here for free.
If you want to follow along, the full GitOps repository including the ApplicationSet, values files, and k3k cluster manifests referenced in this article is at https://github.com/rajeshkio/pulumiInfraProxmox/tree/main/argocd.
Prerequisites
Before diving in, here is what you need to follow along:
- A running RKE2 or K3s cluster, this article uses a single-node Proxmox VM
- ArgoCD deployed on the host cluster
- Longhorn or another storage provisioner on the host cluster
- A Cloudflare account with a tunnel already created and a domain managed via Cloudflare
- The cf-tunnel-operator Helm chart, covered in a separate article linked in the references section
My Setup
- RKE2 single node on Proxmox (
proxmox-1,192.168.90.100) - ArgoCD managing the host cluster (registered as
suse-ai) - Domain
*.rajesh-kumar.inmanaged via Cloudflare - A custom Cloudflare tunnel operator I had built earlier
I have automated my lab infrastructure using Pulumi and ArgoCD. The full GitOps repository including the ApplicationSet, values files, and k3k cluster manifests referenced in this article is available at the repo if you want to follow along with the exact structure.
What is k3k?
k3k runs K3s-based virtual clusters inside an existing Kubernetes cluster. Each virtual cluster gets its own API server, its own control plane, and its own service and pod CIDRs. From the outside it looks and feels like a real cluster. You get a kubeconfig, you run kubectl apply, pods come up.
The project is maintained by the Rancher team at SUSE and is available at https://github.com/rancher/k3k. You install it via Helm and it adds a Cluster CRD to your host cluster. Each Cluster resource you create spins up a new virtual K3s cluster inside its own namespace.
k3k supports two modes, which have meaningfully different networking and isolation characteristics:
Shared mode means the virtual cluster has its own API server but pods physically run on the host cluster nodes. They share the host network namespace. Services created inside the virtual cluster get synced to the host cluster automatically.
Virtual mode is fully isolated. The virtual cluster runs its own K3s server as a pod inside the host cluster. Pods get their own network namespace and their own CIDRs. Nothing is shared with the host network.
This article covers shared mode. Virtual mode has different networking characteristics and will be covered separately.
The Cloudflare Tunnel Operator
Before getting into the k3k setup I want to give some context on how external access works here because it is central to this article.
I had previously built a Kubernetes operator that automates Cloudflare tunnel management. If you want to understand how Cloudflare tunnels work on Kubernetes or how I built the operator from scratch, I have written about both:
- How Cloudflare tunnels work on Kubernetes: https://blogs.learningdevops.com/how-to-set-up-cloudflare-tunnel-on-kubernetes-and-how-it-actually-works-inside-91e11a35c84c
- Building the operator from scratch: https://blogs.learningdevops.com/building-a-kubernetes-operator-from-scratch-automate-cloudflare-tunnel-with-httproute-421d6642dc11
- Operator source code: https://github.com/rajeshkio/cf-tunnel-operator
The short version: you create an HTTPRoute resource pointing at your service. The operator watches all HTTPRoute resources in the cluster. When it sees one it reads the hostname from spec.hostnames[0], builds the backend service URL from the backendRef name, namespace, and port, calls the Cloudflare API to update the tunnel ingress rules, creates the DNS CNAME record pointing to your tunnel, and writes a TunnelStatus custom resource with the sync state. Delete the HTTPRoute and it removes the tunnel rule and DNS record automatically. No manual Cloudflare dashboard interaction needed after the initial tunnel is created.
Installing k3k
k3k installs via Helm. I manage this through ArgoCD so I added it to my ApplicationSet.yaml, but you can also install it directly.
helm repo add k3k https://rancher.github.io/k3k
helm repo update
helm install k3k k3k/k3k -n k3k-system --create-namespace
After installation you get a k3k controller pod in k3k-system and the Cluster CRD registered on your host cluster.
kubectl get pods -n k3k-system
NAME READY STATUS RESTARTS AGE
k3k-564cc5f6fc-8w8jj 1/1 Running 0 27d
Creating the Shared Mode Virtual Cluster
I created the k3k-dev cluster in shared mode with the following manifest.
apiVersion: k3k.io/v1beta1
kind: Cluster
metadata:
name: dev
namespace: k3k-dev
spec:
agents: 0
expose:
nodePort: {}
mirrorHostNodes: true
mode: shared
serverArgs:
- --tls-san=192.168.90.100
serverResources:
limits:
cpu: "4"
memory: 8Gi
requests:
cpu: "1"
memory: 2Gi
servers: 1
tlsSANs:
- 192.168.90.100
version: v1.34.3-k3s1
A few things worth noting here.
expose: nodePort: {} means the cluster API server is exposed via a NodePort on the host node. In my case the API server ended up on port 31274 on 192.168.90.100.
mirrorHostNodes: true means the host node proxmox-1 is visible inside the virtual cluster as a node. When you run kubectl get nodes inside k3k-dev you see the actual host node.
sync.services.enabled is true by default and this is what makes service syncing work. Any service you create inside k3k-dev gets mirrored into the k3k-dev namespace on the host cluster with the same NodePort.
After applying this manifest the cluster comes up quickly.
kubectl get clusters.k3k.io dev -n k3k-dev
NAME MODE STATUS POLICY
dev shared Ready
Getting the Kubeconfig
k3k generates a kubeconfig secret in the cluster namespace. Find and extract it like this.
kubectl get secret -n k3k-dev -o name | grep kubeconfig
kubectl get secret k3k-dev-kubeconfig -n k3k-dev \
-o jsonpath='{.data.value}' | base64 -d > backup/k3k-dev-kubeconfig.yaml
The generated kubeconfig points to the internal ClusterIP of the k3k-dev API server service at https://10.43.204.3. This address is only reachable from inside the cluster network.
kubectl get svc k3k-dev-service -n k3k-dev
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
k3k-dev-service NodePort 10.43.204.3 <none> 443:31274/TCP,2379:32483/TCP 3h46m
If you need to access the virtual cluster API from outside, use the NodePort instead. In my case that is port 31274 on 192.168.90.100, so from an external machine you would set the server in the kubeconfig to [https://192.168.90.100:31274.](https://192.168.90.100:31274.)
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml get nodes
NAME STATUS ROLES AGE VERSION
proxmox-1 Ready control-plane,etcd 34m v1.34.3+rke2r3
Registering k3k-dev in ArgoCD
To deploy applications to k3k-dev via ArgoCD you need to register it as a remote cluster. The key detail here: the kubeconfig points to ClusterIP 10.43.204.3, which is only routable from within the cluster network, not from your laptop or any machine outside the cluster. So the argocd cluster add command has to run from a machine that can reach that address.
One option was to update the kubeconfig server to the NodePort address, but that would mean ArgoCD talks to k3k-dev through the host node’s network stack rather than directly to the cluster’s service IP.
I wanted ArgoCD to reach the k3k-dev cluster directly via its service IP, without routing through the node. Since proxmox-1 is the node where everything is deployed, I SSH'd into it and ran the registration from there.
argocd cluster add k3k-dev \
--kubeconfig backup/k3k-dev-kubeconfig.yaml \
--name k3k-dev \
--server https://10.43.204.3
After registration:
argocd cluster list
SERVER NAME VERSION STATUS MESSAGE
https://10.43.204.3 k3k-dev Unknown Cluster has no applications and is not being monitored.
https://kubernetes.default.svc suse-ai v1.34.3 Successful
The Unknown status with the message about no applications is normal. It goes healthy once ArgoCD deploys something to it.
Understanding Service Sync in Shared Mode
Before deploying anything serious I wanted to see exactly how traffic flows in shared mode. I deployed a simple nginx app directly with kubectl.
export KUBECONFIG=backup/k3k-dev-kubeconfig.yaml
kubectl create namespace nginx
kubectl create deployment nginx --image=nginx:stable -n nginx
kubectl expose deployment nginx --port=80 --target-port=80 --type=NodePort -n nginx
Inside k3k-dev the service looks like this.
kubectl get svc nginx -n nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx NodePort 10.43.52.140 <none> 80:32674/TCP 13s
Now check the host cluster.
export KUBECONFIG=suseai-kubeconfig
kubectl get svc -n k3k-dev
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
k3k-dev-service NodePort 10.43.204.3 <none> 443:31274/TCP,2379:32483/TCP 46m
nginx-nginx-dev-6e67696e782b6e67696e782b646576 NodePort 10.43.52.140 <none> 80:32674/TCP 23s
k3k automatically synced the nginx service to the host cluster’s k3k-dev namespace. Same ClusterIP, same NodePort. The host kube-proxy now knows about port 32674 and forwards traffic to the nginx pod.
curl http://192.168.90.100:32674
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
The traffic path is:
curl 192.168.90.100:32674
--> host RKE2 kube-proxy
--> synced NodePort service in k3k-dev namespace on host
--> nginx pod running on host network via shared mode
Storage in Shared Mode
One thing the k3k architecture docs mention is that shared mode uses the host cluster’s storage classes. Storage class sync is disabled by default. The sync.storageClasses.enabled field in the Cluster spec is false out of the box, which means k3k-dev starts with no storage classes visible inside it.
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml get storageclass
No resources found
The fix is straightforward. Enable storage class sync in the cluster spec.
kubectl patch clusters.k3k.io dev -n k3k-dev \
--type=merge \
-p '{"spec":{"sync":{"storageClasses":{"enabled":true}}}}'
All host storage classes synced into k3k-dev immediately.
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path rancher.io/local-path Delete WaitForFirstConsumer false 14s
longhorn (default) driver.longhorn.io Delete Immediate true 14s
longhorn-retain driver.longhorn.io Retain Immediate true 14s
longhorn-static driver.longhorn.io Delete Immediate true 14s
longhorn-xfs driver.longhorn.io Retain Immediate true 14s
I tested a PVC to confirm end-to-end provisioning works.
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml get pvc -n default
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
test-pvc Bound test-pvc 1Gi RWO longhorn 5s
On the host cluster k3k synced the PVC into the k3k-dev namespace and Longhorn provisioned a real PV.
kubectl get pvc -n k3k-dev | grep test-pvc
test-pvc-default-dev-746573742d7076632b64656661756c742b646576 Bound pvc-b28f4194 1Gi RWO longhorn 8s
kubectl get pv | grep k3k-dev
pvc-b28f4194-b856-430a-8f0d-5602d01a40bf 1Gi RWO Delete Bound k3k-dev/test-pvc-default-dev-... longhorn 10s
The storage path is:
PVC created in k3k-dev
--> k3k PVC sync --> host cluster k3k-dev namespace
--> Longhorn provisioner on host --> PV created and bound
--> synced back to k3k-dev as Bound
Make sure to add sync.storageClasses.enabled: true to your cluster manifest so it persists across cluster recreations.
spec:
...
sync:
storageClasses:
enabled: true
Installing Gateway API CRDs
The cf-tunnel-operator watches HTTPRoute resources which are part of the Gateway API. k3k-dev does not come with Gateway API CRDs so I installed them manually.
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml apply -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml
After installation:
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml api-resources | grep -i gateway
backendtlspolicies gateway.networking.k8s.io/v1 true BackendTLSPolicy
gatewayclasses gateway.networking.k8s.io/v1 false GatewayClass
gateways gateway.networking.k8s.io/v1 true Gateway
httproutes gateway.networking.k8s.io/v1 true HTTPRoute
grpcroutes gateway.networking.k8s.io/v1 true GRPCRoute
tcproutes gateway.networking.k8s.io/v1alpha2 true TCPRoute
tlsroutes gateway.networking.k8s.io/v1 true TLSRoute
Setting Up the ApplicationSet for k3k-dev
My existing ApplicationSet in ArgoCD uses a list generator. Every application is a hardcoded element with cluster, chart, values path, and other fields. The template uses {{cluster}} and {{app-name}} to build the Application name, destination, and values file path.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: platform
namespace: argocd
spec:
generators:
- list:
elements:
- cluster: suse-ai
app-name: cert-manager
chart: cert-manager
targetRevision: v1.20.2
repoURL: https://charts.jetstack.io
namespace: cert-manager
releaseName: cert-manager
# ... other suse-ai apps
template:
metadata:
name: '{{cluster}}-{{app-name}}'
namespace: argocd
spec:
destination:
name: '{{cluster}}'
namespace: '{{namespace}}'
sources:
- repoURL: '{{repoURL}}'
targetRevision: '{{targetRevision}}'
chart: '{{chart}}'
helm:
releaseName: '{{releaseName}}'
valueFiles:
- $values/argocd/values/{{cluster}}/{{app-name}}/values.yaml
- repoURL: https://github.com/rajeshkio/pulumiInfraProxmox.git
targetRevision: HEAD
ref: values
The values file path convention is argocd/values/{cluster}/{app-name}/values.yaml. For any app I deploy to k3k-dev the values file lives at argocd/values/k3k-dev/{app-name}/values.yaml. Adding a new application to k3k-dev means adding one element to the list and committing a values file at the right path.
Where Should cloudflared Run?
Before setting up the tunnel I had to decide where to run cloudflared and the operator. There were a few options.
One option was to keep cloudflared and the operator on the host cluster and use k3k’s service sync to mirror LoadBalancer services from the virtual cluster to the host. MetalLB on the host would assign a real IP and the operator on the host would generate a tunnel rule pointing at that IP. The problem is that the operator always builds svc.cluster.local URLs from the HTTPRoute backendRef. It does not use IPs. So even with a synced service the operator would still generate a DNS name that only resolves inside the virtual cluster, not on the host where cloudflared runs.
Another option was to run cloudflared and the operator inside each k3k cluster. The operator watches HTTPRoutes inside that cluster, builds svc.cluster.local URLs that are valid within that cluster's network, and cloudflared running in the same cluster forwards traffic to those URLs directly. No IP management, no code changes to the operator, and the setup is fully self-contained per cluster. The cost is one Cloudflare tunnel per k3k cluster.
I went with the second option. This works cleanly because cloudflared inside k3k-dev can reach services in the same virtual cluster without any additional routing.
Manual Cloudflare Tunnel Validation
Before using the operator I wanted to test the tunnel setup manually first to understand the flow step by step.
I created a new Cloudflare tunnel in the dashboard under Networking > Tunnels, got the tunnel token, and deployed cloudflared directly into k3k-dev. You can learn more about the setup in How To-Set Up Cloudflare Tunnel On Kubernetes blog.
export KUBECONFIG=backup/k3k-dev-kubeconfig.yaml
kubectl create namespace cloudflare-system
kubectl create secret generic cloudflared-token \
--from-literal=token=cloudflare-api-key \
-n cloudflare-system
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflare-system
spec:
replicas: 1
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token
key: token
The pod came up and the logs showed all four connections registering to Cloudflare’s Mumbai PoPs via QUIC.
2026-06-10T11:50:11Z INF Starting tunnel tunnelID=40aff82c-0af3-4f38-8153-bdb88d6761e8
2026-06-10T11:50:11Z INF Registered tunnel connection connIndex=0 connection=287eb54b ip=198.41.200.23 location=bom09 protocol=quic
2026-06-10T11:50:12Z INF Registered tunnel connection connIndex=1 connection=63dd0ee7 ip=198.41.192.77 location=bom03 protocol=quic
2026-06-10T11:50:13Z INF Registered tunnel connection connIndex=2 connection=ce860b0a ip=198.41.192.227 location=bom03 protocol=quic
2026-06-10T11:50:14Z INF Registered tunnel connection connIndex=3 connection=6d6049c6 ip=198.41.200.33 location=bom12 protocol=quic
I then added a public hostname in the Cloudflare dashboard pointing at [http://nginx.nginx.svc.cluster.local:80.](http://nginx.nginx.svc.cluster.local:80.)
2026-06-10T11:52:50Z INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"k3k-dev-nginx.rajesh-kumar.in\",\"service\":\"http://nginx.nginx.svc.cluster.local:80\"},{\"service\":\"http_status:404\"}]}"
curl http://k3k-dev-nginx.rajesh-kumar.in
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
Working. With manual validation done I moved on to deploying the operator so this process is automated going forward.
Deploying the Cloudflare Tunnel Operator
The operator watches HTTPRoute resources across the cluster. When it sees an HTTPRoute it reads the hostname, builds the backend service URL, calls the Cloudflare API to update the tunnel configuration, creates the DNS CNAME record, and writes a TunnelStatus CR with the result. Delete the HTTPRoute and it cleans up the tunnel rule and DNS record automatically.
Create the credentials secret with your Cloudflare API token, account ID, zone ID, and tunnel ID. The operator uses these to call the Cloudflare API. If you need a walkthrough of how to obtain these values and structure the secret, I covered that in detail in Building a Kubernetes Operator from Scratch.
Then install via Helm.
helm --kubeconfig=backup/k3k-dev-kubeconfig.yaml upgrade --install cf-tunnel-operator \
cf-tunnel-operator/cf-tunnel-operator \
-n cf-tunnel-operator-system \
-f cf-tunnel-operator/cf-tunnel-operator/values.yaml
The operator started up and began watching for HTTPRoute resources.
{"level":"info","ts":"2026-06-10T12:11:31Z","msg":"Starting Controller","controller":"httproute","controllerGroup":"gateway.networking.k8s.io","controllerKind":"HTTPRoute"}
{"level":"info","ts":"2026-06-10T12:11:31Z","msg":"Starting workers","controller":"httproute","controllerGroup":"gateway.networking.k8s.io","controllerKind":"HTTPRoute","worker count":1}
Testing the Operator with nginx
I created a simple HTTPRoute for the nginx service to verify the operator works end to end before moving to NeuVector.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: nginx
namespace: nginx
spec:
hostnames:
- "k3k-dev-nginx.rajesh-kumar.in"
rules:
- backendRefs:
- name: nginx
port: 80
The operator picked it up immediately.
{"level":"info","ts":"2026-06-10T12:25:19Z","msg":"Building tunnel rule","hostname":"k3k-dev-nginx.rajesh-kumar.in","service":"http://nginx.nginx.svc.cluster.local:80"}
{"level":"info","ts":"2026-06-10T12:25:19Z","msg":"Pushing rules to Cloudflare","count":2}
{"level":"info","ts":"2026-06-10T12:25:21Z","msg":"DNS record ensured","hostname":"k3k-dev-nginx.rajesh-kumar.in"}
The TunnelStatus CR was created in the operator namespace.
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml \
get tunnelstatuses nginx-nginx -n cf-tunnel-operator-system -o yaml
apiVersion: cf-tunnel-operator.rajesh-kumar.in/v1alpha1
kind: TunnelStatus
metadata:
name: nginx-nginx
namespace: cf-tunnel-operator-system
spec:
httpRouteName: nginx
httpRouteNamespace: nginx
status:
backendService: http://nginx.nginx.svc.cluster.local:80
hostname: k3k-dev-nginx.rajesh-kumar.in
lastSyncTime: "2026-06-10T12:25:21Z"
message: ""
notlsverify: false
scheme: http
syncStatus: Success
curl http://k3k-dev-nginx.rajesh-kumar.in
Working. The operator created the tunnel rule and DNS record automatically.
Importing k3k-dev into Rancher
I wanted to manage k3k-dev from Rancher alongside my other clusters. Having everything in one place means I can see workloads, events, resource usage, and logs for all clusters from a single UI. I imported k3k-dev into Rancher using the cluster import flow which generates a kubectl apply command with the Rancher agent manifests. After import Rancher assigned it the cluster ID c-tbhkb.

k3k imported into Rancher
Deploying NeuVector via ArgoCD
With the networking validated and the cluster registered in Rancher, I deployed NeuVector. NeuVector is a container security platform that integrates with Rancher for SSO and centralized management. With the Rancher integration enabled you log into NeuVector using your Rancher credentials and NeuVector shows up as a managed security tool inside the Rancher UI.
The values file needed a few adjustments compared to a standard NeuVector deployment. Rancher SSO enabled with the cluster ID from the import step. Persistent storage enabled using Longhorn.
Here is the values file.
tag: 5.5.1
rbac: true
controller:
apisvc:
type: ClusterIP
ranchersso:
enabled: true
federation:
managedsvc:
type: ClusterIP
mastersvc:
type: ClusterIP
replicas: 1
pvc:
enabled: true
storageClass: longhorn
capacity: 2G
cve:
scanner:
replicas: 1
global:
cattle:
clusterId: c-tbhkb
url: https://rancher.rajesh-kumar.in
systemDefaultRegistry: registry.rancher.com
manager:
svc:
type: ClusterIP
I saved this at argocd/values/k3k-dev/neuvector/values.yaml in my GitOps repo and added the following element to the ApplicationSet.
- cluster: k3k-dev
app-name: neuvector
chart: core
targetRevision: 2.10.2
repoURL: https://neuvector.github.io/neuvector-helm
namespace: cattle-neuvector-system
releaseName: neuvector
After committing and pushing I applied the updated ApplicationSet.
kubectl apply -f argocd/bootstrap/applicationset.yaml
ArgoCD created the k3k-dev-neuvector Application and started deploying.

NeuVector application synced in argocd
A few minutes later, all the Pods were running and the PVC was attached
kubectl --kubeconfig backup/k3k-dev-kubeconfig.yaml get pods,pvc -n cattle-neuvector-system
NAME READY STATUS RESTARTS AGE
pod/neuvector-cert-upgrader-job-htp5f 1/1 Running 0 14s
pod/neuvector-controller-pod-8699c45c96-jph26 1/1 Running 0 26s
pod/neuvector-enforcer-pod-twm7b 1/1 Running 0 159m
pod/neuvector-manager-pod-7d56bc5b9b-lxq6f 1/1 Running 0 26s
pod/neuvector-scanner-pod-7c99565f48-5x6x4 1/1 Running 0 159m
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
persistentvolumeclaim/neuvector-data Bound neuvector-data 2G RWX longhorn 26s
The GitOps Structure for Raw Manifests
The ApplicationSet handles Helm chart deployments cleanly. But the HTTPRoute for NeuVector is a raw manifest, not a Helm chart. I did not want to create a separate ArgoCD Application for every HTTPRoute I would ever create across k3k clusters.
The pattern I settled on is one ArgoCD Application per cluster that watches a manifests/ directory for that cluster. Any raw manifest I drop into that directory gets automatically deployed to the cluster.
The directory structure looks like this.
argocd/k3k-clusters/dev/
cluster.yaml # k3k Cluster resource, applied to host cluster
k3k-dev-manifests-app.yaml # ArgoCD Application, applied once manually
manifests/
neuvector-httproute.yaml # deployed to k3k-dev automatically
# any future raw manifests go here
The Application definition.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: k3k-dev-manifests
namespace: argocd
spec:
project: default
destination:
name: k3k-dev
namespace: default
source:
repoURL: https://github.com/rajeshkio/pulumiInfraProxmox.git
targetRevision: HEAD
path: argocd/k3k-clusters/dev/manifests
syncPolicy:
automated:
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=true
One thing to watch here: the cluster.yaml in the parent directory is a k3k.io/v1beta1 Cluster resource that targets the host cluster, not k3k-dev. If you accidentally point this Application at the parent directory instead of manifests/, ArgoCD will try to deploy cluster.yaml to k3k-dev and fail because k3k-dev does not have the k3k CRDs installed. Keep the path pointing at manifests/ specifically.
I applied the Application once manually.
kubectl apply -f argocd/k3k-clusters/dev/k3k-dev-manifests-app.yaml
From this point forward any file I commit to argocd/k3k-clusters/dev/manifests/ gets deployed to k3k-dev automatically on the next ArgoCD sync.
Exposing NeuVector via Cloudflare Tunnel
With NeuVector running and the operator in place I created the HTTPRoute. NeuVector’s manager serves HTTPS with a self-signed certificate, so cloudflared needs to skip TLS verification when connecting to the backend. The operator supports this via annotations on the HTTPRoute.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: neuvector-httproute
namespace: cattle-neuvector-system
annotations:
cf-tunnel-operator/backend-scheme: "https"
cf-tunnel-operator/no-tls-verify: "true"
spec:
hostnames:
- "k3k-dev-neuvector.rajesh-kumar.in"
rules:
- backendRefs:
- name: neuvector-service-webui
port: 8443
I committed this to argocd/k3k-clusters/dev/manifests/neuvector-httproute.yaml and pushed. ArgoCD synced it to k3k-dev within seconds and the operator reconciled it immediately.
{"level":"info","ts":"2026-06-10T13:35:18Z","msg":"Building tunnel rule","hostname":"k3k-dev-neuvector.rajesh-kumar.in","service":"https://neuvector-service-webui.cattle-neuvector-system.svc.cluster.local:8443"}
{"level":"info","ts":"2026-06-10T13:35:18Z","msg":"Pushing rules to Cloudflare","count":3}
{"level":"info","ts":"2026-06-10T13:35:20Z","msg":"DNS record ensured","hostname":"k3k-dev-neuvector.rajesh-kumar.in"}
The NeuVector UI came up at [https://k3k-dev-neuvector.rajesh-kumar.in.](https://k3k-dev-neuvector.rajesh-kumar.in.)

NeuVector UI
The Complete Traffic Path
Here is the full picture of how a request reaches NeuVector from the internet.
browser --> k3k-dev-neuvector.rajesh-kumar.in
--> Cloudflare edge (DNS resolves to Cloudflare proxy IPs)
--> cloudflared pod running inside k3k-dev in cf-tunnel-operator-system namespace
--> neuvector-service-webui.cattle-neuvector-system.svc.cluster.local:8443
--> NeuVector manager pod running on proxmox-1
References
- k3k Helm chart: https://rancher.github.io/k3k
- cf-tunnel-operator source code: https://github.com/rajeshkio/cf-tunnel-operator
- How Cloudflare tunnels work on Kubernetes: https://blogs.learningdevops.com/how-to-set-up-cloudflare-tunnel-on-kubernetes-and-how-it-actually-works-inside-91e11a35c84c
- Building a Kubernetes operator from scratch: https://blogs.learningdevops.com/building-a-kubernetes-operator-from-scratch-automate-cloudflare-tunnel-with-httproute-421d6642dc11
- GitOps repository: https://github.com/rajeshkio/pulumiInfraProxmox/tree/main/argocd
- NeuVector Helm chart values: https://github.com/neuvector/neuvector-helm/blob/master/charts/core/values.yaml
- Gateway API experimental install: https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml
- ArgoCD ApplicationSet documentation: https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/
If you found this useful, let us connect on LinkedIn. I write about infrastructure engineering, AI systems, and building things from scratch.
메타데이터
- post_id
- e9dd6b7fd414
- slug
- how-i-run-virtual-kubernetes-clusters-with-k3k-argocd-and-cloudflare-tunnels-e9dd6b7fd414
- url
- https://medium.com/@rk90229/how-i-run-virtual-kubernetes-clusters-with-k3k-argocd-and-cloudflare-tunnels-e9dd6b7fd414
- canonical_url
- https://medium.com/@rk90229/how-i-run-virtual-kubernetes-clusters-with-k3k-argocd-and-cloudflare-tunnels-e9dd6b7fd414
- author_url
- https://medium.com/@rk90229
- status
- ok
- fetched_at
- 2026-06-22 12:55:45