← Back to list

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…

Rajesh Kumar · 2026-06-14 15:58 · 14 claps · 15.3 min read paywalled
#k3k #virtual-cluster #kubernetes #gitops #gitops-with-argocd
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🏃 · Running & Endurance

How I Run Virtual Kubernetes Clusters with k3k, ArgoCD and Cloudflare Tunnels

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.in managed 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:

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

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

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

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

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