34 min readMehdi Hadeli

Beyond kubectl: Managing .NET Workloads Across Kubernetes Clusters with Rancher

kubectl is the right tool for one cluster. The day your team runs a second cluster, or a second team joins the first one, the workflow that worked on a laptop stops scaling: credentials multiply, RBAC drifts, and "which cluster was that again?" becomes a meeting agenda item. This article is about what comes next: a management plane that sits above Kubernetes and drives every cluster's API from one place.

We use Rancher for that job and run it on a k3d cluster, continuing the local-first story from the article "Hosting a .NET 10 API on Kubernetes with k3d". That earlier article covered how traffic gets into a single cluster: k3d's load balancer, MetalLB for on-premises clusters, and the Ingress versus Gateway API decision. We will not repeat that material here. Instead, we answer the question that follows it: once you have clusters, how do you manage them as a team?

Introduction

Rancher is a Kubernetes management platform: a web UI, an RBAC engine, and a GitOps pipeline, all bundled into one Helm chart. It runs on a cluster, imports other clusters through their Kubernetes API, and gives you one pane of glass for workloads, projects, users, and delivery.

This article walks through a complete sample and uses it to demonstrate five ideas in order. The sample deploys the same .NET 10 todo API from the previous article, this time on a Rancher-managed setup with PostgreSQL and RabbitMQ as its backing services:

  1. Management plane vs data plane. Rancher runs on one cluster and manages many.
  2. The local cluster. Rancher imports its own cluster automatically and manages itself.
  3. Projects instead of namespaces. Rancher's grouping and RBAC boundary above plain namespaces.
  4. Multi-cluster import. A second k3d cluster joins the same management plane with one kubectl apply.
  5. Fleet GitOps. The app is delivered to every cluster from a Git repository, with no kubectl apply on any cluster.

The goal is to end with a reproducible two-cluster setup on your laptop: Rancher in the UI, the same .NET 10 todo API answering from both clusters backed by PostgreSQL and RabbitMQ, and the Git repository as the source of truth for both deployments. The sample also includes the same Ingress and Gateway API manifests from the previous article so you can see both traffic models inside Rancher.

The full sample lives in the rancher-multi-cluster-setup repository, and the step-by-step README is here. Every snippet in this article comes from that repository.

Objectives

  • Install Rancher Manager on a k3d cluster and log in for the first time.
  • Understand the management-plane model: one Rancher, many clusters.
  • Replace raw namespaces with Rancher projects for grouping and RBAC.
  • Deploy the same .NET 10 todo API from the previous article, with PostgreSQL and RabbitMQ, from the Rancher UI and prove it is the same YAML you already know.
  • Import a second k3d cluster and manage both from one UI.
  • Deliver the app to both clusters with Fleet GitOps from a Git repository.
  • Compare the Ingress and Gateway API resources side-by-side under Rancher service discovery.

The Problem with kubectl

Before Rancher makes sense, the pain has to be concrete. With one cluster, your mental model is a single kubeconfig context:

kubectl config get-contexts
kubectl get pods -A
kubectl apply -f deployment.yaml

Now add a second cluster. Each one needs its own context, its own credentials, and its own set of RBAC rules. kubectl handles that fine mechanically. Contexts are cheap. What is missing is the organization:

RBAC is per namespace and per cluster, and nothing in Kubernetes tells a new team member which namespaces they should even look at. Enforcing "this team gets 2 vCPU" means wiring ResourceQuota objects into every namespace the team owns. Deploying the same app to two clusters means remembering to run kubectl apply in two contexts. And "who changed the production cluster last night?" is not a question kubectl can answer.

Rancher answers all four with concepts that sit above the Kubernetes API: users, projects, roles, and GitRepo-driven delivery. The clusters themselves stay untouched. Rancher is an operator, not a replacement.

Recommended addition from the field. If your team is just starting with multi-cluster, capture these four failure modes early: wrong-context kubectl commands, drifted RBAC roles, per-cluster monitoring silos, and inconsistent network policies. They are not visible in resource quotas, but they show up in incident response time. Rancher's management plane fixes them by centralizing authentication, RBAC, and policy delivery.

Management Plane vs Data Plane

The mental model to internalize: Rancher is a management plane, not a cluster. It runs as a set of pods on one Kubernetes cluster (the local cluster), and it talks to every other cluster through that cluster's normal Kubernetes API. The workloads you manage, your .NET pods, are the data plane and never run inside Rancher.

Rendering diagram...

Read the diagram top to bottom. Rancher Manager lives in the cattle-system namespace of the rancher-mgmt cluster. It manages that same cluster through its own API (no special path), and it manages workload-dev through an agent pod that connects back to Rancher. The .NET workloads run in both clusters, inside the dotnet-k8s namespace, managed by Rancher but deployed by normal Kubernetes mechanics.

The Rancher Stack: k3s, k3d, RKE2, Rancher

Rancher is one product in a family, and the naming confuses people, so let us place each piece:

PieceWhat it isRole in this article
k3sLightweight, certified Kubernetes distribution (Rancher's)The Kubernetes inside k3d
k3dk3s wrapped in Docker containersOur laptop clusters
RKE2Rancher's hardened, production-grade Kubernetes distributionWhat you would manage on real servers
Rancher ManagerThe management platformInstalled on k3d, manages both clusters

The previous article in this series already established the bottom half of the stack: k3d for laptop-sized clusters, built from k3s, and the fastest local feedback loop for Kubernetes work. Rancher completes the top half: when your setup grows past one cluster, you add a management plane instead of more kubeconfig gymnastics.

For this sample we stay entirely on k3d: Rancher running on k3d, managing a second k3d cluster. Everything you see maps one-to-one to production. Swap k3d for RKE2 clusters and the management model does not change.

Architecture Overview

The sample creates two k3d clusters. rancher-mgmt is the management cluster: it runs Rancher and the demo workload (the .NET todo API with PostgreSQL and RabbitMQ). workload-dev is the managed cluster: it runs only the demo workload and a small agent that connects it to Rancher. Host ports 80 and 443 map into rancher-mgmt so the Rancher UI is reachable from the browser; workload-dev needs no inbound ports at all because Rancher reaches it through the agent's outbound connection.

Rendering diagram...

Two details in this diagram are worth pointing out. First, cert-manager sits next to Rancher because the chart uses it to issue the self-signed certificate for the UI. Rancher requires a cert, and for a local demo a self-signed one from cert-manager is exactly right. Second, the k3d cluster disables Traefik at creation time: k3s ships Traefik by default, and Traefik would grab ports 80 and 443 before Rancher's bundled nginx ingress can bind them.

Prerequisites

The same toolbox as the previous article, no additions:

ToolPurpose
DockerContainer runtime for k3d
kubectlKubernetes CLI, used by the scripts and the UI import
k3dCreates both k3d clusters
helmInstalls cert-manager and the Rancher chart

On Windows, run everything from Git Bash, and make sure cgroup v2 is enabled in WSL2 (the .wslconfig fix is documented in the previous article). Rancher Manager needs roughly 2 vCPU and 4 GB of RAM, which is why the sample's cluster script gives the k3d server node 4 GB instead of k3d's default.

Live Walkthrough: What We Validated

The rest of this article explains the concepts; this section records the exact steps that worked on a Windows 11 laptop with Docker Desktop 27.0.3, k3d v5.9.0, k3s v1.35.5+k3s1, and Helm v4.2.3. If you follow the sample, you should end up with the same screens.

1. Create the management cluster

Run the sample script (or the equivalent k3d cluster create command). The cluster is named rancher-mgmt, gets 4 GB of RAM, and exposes host ports 80/443 to the k3d load balancer. Traefik is disabled so ingress-nginx can bind those ports later.

./scripts/setup-cluster.sh
kubectl get nodes
# NAME                           STATUS   ROLES                  AGE   VERSION
# k3d-rancher-mgmt-server-0      Ready    control-plane,master   30s   v1.35.5+k3s1

2. Install Rancher and ingress-nginx

The sample's setup-rancher.sh installs cert-manager, the Rancher chart, and then ingress-nginx with hostNetwork=true. It also patches the Rancher Ingress to ingressClassName: nginx because the chart leaves it blank on some versions.

./scripts/setup-rancher.sh

At the end of the script curl -k https://rancher.local/ping must return pong. If it does not, check that:

  • 127.0.0.1 rancher.local is in your hosts file.
  • The ingress-nginx controller pod is running in namespace ingress-nginx.
  • kubectl -n cattle-system get ingress rancher shows ingressClassName: nginx.

3. First login

Open https://rancher.local, accept the self-signed certificate warning, and use admin / admin. Rancher forces a password change on the first screen. Set a password of your choice; on the next screen, set the Server URL to https://rancher.local, not https://localhost:8443, so downstream cluster agents can reach Rancher.

The login screen looks like this:

Rancher login page at https://rancher.local

After entering the bootstrap credentials, Rancher asks for a new password and the Server URL:

Rancher first-time setup asking for password and server URL

After login the dashboard shows the local cluster (the cluster Rancher itself runs on):

Rancher home dashboard showing the local cluster

4. Create the workload cluster

Create the second k3d cluster with no inbound port mappings. It does not need them because the agent inside it connects outbound to Rancher.

./scripts/setup-workload-cluster.sh
kubectl config use-context k3d-workload-dev
kubectl get nodes
# NAME                        STATUS   ROLES                  AGE   VERSION
# k3d-workload-dev-server-0   Ready    control-plane,master   30s   v1.35.5+k3s1

5. Import workload-dev from the UI

In Rancher: Cluster Management → Import Existing → Generic. Name the cluster workload-dev and click Create. The UI shows a kubectl apply -f https://rancher.local/v3/import/<token>.yaml command.

Run that command while your context is k3d-workload-dev:

kubectl config use-context k3d-workload-dev
kubectl apply -f https://rancher.local/v3/import/<token>.yaml

The cattle-cluster-agent pod starts in workload-dev's cattle-system namespace. It downloads the rancher/rancher-agent:v2.15.0 image; if your network is slow, pre-pull it in Docker Desktop and import it:

docker pull rancher/rancher-agent:v2.15.0
k3d image import rancher/rancher-agent:v2.15.0 -c workload-dev

After the agent connects, both clusters show Active in Cluster Management:

Rancher Cluster Management showing local and workload-dev Active

The list shows the provider type: local is a Local K3s cluster because Rancher itself runs there, while workload-dev is an Imported K3s cluster. That single table is the management plane in one picture: one URL, two clusters, one state.

6. Build and import the demo image

The sample app is the same .NET 10 Minimal API from the previous article, a full CRUD todo service backed by PostgreSQL and RabbitMQ. Build it locally and import the image into both clusters (there is no registry in this demo).

cd samples/rancher-multi-cluster-setup
docker build -t dotnet-k8s-setup:latest .
k3d image import dotnet-k8s-setup:latest -c rancher-mgmt
k3d image import dotnet-k8s-setup:latest -c workload-dev

If docker build fails with NETSDK1047 or a NuGet timeout, retry. The Dockerfile restores without an explicit architecture so it works with the .NET 10 preview Alpine runtime packs.

7. Deploy the app on both clusters

The Fleet GitRepo in k8s/fleet-gitrepo.yaml points at https://github.com/mehdihadeli/devops-samples path rancher-multi-cluster-setup/k8s/app. The manifests there include the todo API deployment, PostgreSQL, RabbitMQ, a ConfigMap, and a Secret: the full stack from the previous article, now delivered by Fleet. Once that repository contains the sample, Fleet will roll the manifests out to every registered cluster automatically:

kubectl config use-context k3d-rancher-mgmt
kubectl apply -f k8s/fleet-gitrepo.yaml

Until the path exists in the remote repository, Fleet reports no resource found at the following paths. For local testing before publication, apply the manifests directly:

kubectl --context k3d-rancher-mgmt apply -f k8s/app/
kubectl --context k3d-workload-dev apply -f k8s/app/

After applying, three todo-app pods run alongside Postgres and RabbitMQ pods on each cluster. To see them, click Explore next to the local cluster in Cluster Management, then open Workloads → Pods and select the dotnet-k8s namespace from the namespace filter at the top:

Rancher local cluster pods in dotnet-k8s namespace

The namespace filter is the small dropdown at the top of the page. Selecting dotnet-k8s hides every other namespace and shows only the project workloads. The same view on workload-dev looks identical because Rancher talks to both clusters through the same Kubernetes API:

Rancher workload-dev cluster pods in dotnet-k8s namespace

Both tables show the same five pods: three todo-app replicas, one postgres, and one rabbitmq. That is the multi-cluster management payoff: the same app, the same backing services, the same UI, on two different clusters.

The workload-dev cluster also shows the Rancher-managed deployment, service, PostgreSQL, RabbitMQ, and cluster storage classes. These are ordinary Kubernetes resources; Rancher just surfaces them from the imported cluster. Open Workloads → Deployments with the dotnet-k8s namespace selected:

Rancher workload-dev deployments

The deployments table shows the three controllers from our manifests: postgres (1 replica), rabbitmq (1 replica), and todo-app (3 replicas). Each row is the same object you would see with kubectl -n dotnet-k8s get deployments.

Next, Service Discovery → Services:

Rancher workload-dev services

The services expose the app on port 80 (todo-app-service), Postgres on port 5432 (postgres-service), and RabbitMQ on ports 5672 and 15672 (rabbitmq-service). ClusterIP services live inside the cluster, but Rancher displays them alongside the deployments so you do not have to switch tools.

The ConfigMap that overrides appsettings.json appears under Storage → ConfigMaps:

Rancher workload-dev configmaps

Because the deployment mounts this ConfigMap at /app/appsettings.json, the QueueName setting is picked up by the app at runtime without a code change.

Postgres persistence comes from a PersistentVolumeClaim in postgres.yaml. Open Storage → PersistentVolumeClaims:

Rancher workload-dev persistent volume claims

The postgres-pvc claim requests 1 Gi from the cluster's default storage class. That storage class is shown under Storage → StorageClasses:

Rancher workload-dev storage classes

local-path is k3s' built-in provisioner; it creates a hostPath-backed volume on the node. For a laptop demo it is perfect, and for production you would replace it with a CSI driver appropriate to your cloud or data center.

The Secret that holds the Postgres and RabbitMQ connection strings appears under Storage → Secrets:

Rancher workload-dev secrets

Rancher displays the keys but not the decoded values; the same stringData you wrote in secret.yaml is stored as base64 in etcd. The deployment injects these keys as environment variables, and .NET Configuration maps ConnectionStrings__DefaultConnection to ConnectionStrings:DefaultConnection automatically.

For external traffic, the sample also ships an Ingress and a Gateway API manifest. Keep in mind that the Kubernetes Gateway API is an official Kubernetes standard, independent of Envoy, nginx, or any specific product. The spec defines the resources, but a cluster still needs a conformant controller to actually implement them. Envoy Gateway is one such controller; nginx Gateway Fabric, Istio, Contour, and Cilium Gateway API are others. You do not remove Ingress to use Gateway API. The two APIs can live in the same cluster, and this sample uses both side by side.

Each object has a single, well-defined job:

  • Ingress tells an ingress controller how to route HTTP traffic from outside the cluster into a Service. It is the classic Kubernetes way to expose HTTP/HTTPS apps through one shared load balancer.
  • GatewayClass names the controller that will implement Gateway resources. It is the "which implementation" knob: Envoy Gateway, NGINX Gateway Fabric, or any other conformant controller.
  • Gateway owns the listener configuration: protocol, port, TLS. It is the data-plane entry point, while the routing rules live elsewhere.
  • HTTPRoute attaches to a Gateway and defines path-based routing rules. It replaces the rules and paths part of an Ingress and can do more, such as traffic splitting.

The Ingress is the classic networking.k8s.io/v1 resource:

# k8s/app/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: todo-app-ingress
  namespace: dotnet-k8s
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: todo-app.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: todo-app-service
                port:
                  number: 80

todo-app-ingress tells the ingress-nginx controller: when a request arrives with Host: todo-app.local, forward it to todo-app-service on port 80. The rewrite-target annotation keeps path handling predictable. On k3d, add 127.0.0.1 todo-app.local to your hosts file and the ingress controller routes traffic to the app. On bare metal with MetalLB, the controller Service becomes type: LoadBalancer, MetalLB assigns it a LAN IP, and you point DNS at that IP instead. The Ingress object itself does not change.

The Kubernetes Gateway API version splits the same job into the three resources listed above:

# k8s/app/gateway-api.yaml
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: nginx-gateway-class
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: todo-app-gateway
  namespace: dotnet-k8s
spec:
  gatewayClassName: nginx-gateway-class
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: todo-app-route
  namespace: dotnet-k8s
spec:
  parentRefs:
    - name: todo-app-gateway
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /todos
      backendRefs:
        - name: todo-app-service
          port: 80
  • nginx-gateway-class selects Envoy Gateway as the controller through controllerName.
  • todo-app-gateway opens an HTTP listener on port 80 and only allows routes from the same namespace (from: Same).
  • todo-app-route matches the /todos prefix and forwards to todo-app-service on port 80.

The live view under Service Discovery → Ingresses shows the Ingress object:

Rancher workload-dev ingresses

Recommended addition from the field. When you import a cluster that already has an ingress controller, Rancher does not force you to replace it. The Ingress tab simply lists whatever networking.k8s.io/v1 resources exist in the selected namespace. That means you can keep cluster-specific ingress controllers while still centralizing visibility. A cloud cluster with an ALB ingress class and an on-prem cluster with ingress-nginx both appear in the same UI.

The Gateway object is shown under Service Discovery → Gateways:

Rancher local cluster gateways

The table shows the todo-app-gateway is accepted and programmed by the GatewayClass controller. The address column tells you where the data plane is listening. On k3d this is an in-cluster address; on bare metal with a LoadBalancer-backed GatewayClass it would be the external IP.

The routing rule itself lives in the HTTPRoute:

Rancher local cluster httproutes

todo-app-route matches the /todos prefix and sends traffic to todo-app-service on port 80. The HTTPRoute is the resource that replaces the Ingress rules and paths; the Gateway only owns the listener configuration.

A third manifest, gateway-api-canary.yaml, shows a traffic split that Ingress cannot express natively:

# k8s/app/gateway-api-canary.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: todo-app-canary
  namespace: dotnet-k8s
spec:
  parentRefs:
    - name: todo-app-gateway
  rules:
    - backendRefs:
        - name: todo-app-service-v2
          port: 80
          weight: 10
        - name: todo-app-service
          port: 80
          weight: 90

The weight fields split traffic 10% to todo-app-service-v2 and 90% to the original service, expressed entirely in Gateway API with no annotations and no service mesh. Apply it only after you create the todo-app-service-v2 backend.

For a deeper look at the Ingress versus Gateway API decision, including MetalLB on bare metal, see the previous article. This article focuses on Rancher management; that article focuses on the traffic layer.

8. Verify the API per cluster

Port-forward the service on each cluster and call the /todos endpoint. The response confirms the API is backed by PostgreSQL and answering from both clusters:

# On rancher-mgmt
kubectl config use-context k3d-rancher-mgmt
kubectl port-forward -n dotnet-k8s svc/todo-app-service 8081:80 &
curl -s http://localhost:8081/todos
# []

# Create a todo to prove the database works
curl -s -X POST http://localhost:8081/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello from rancher-mgmt"}'

# On workload-dev
kubectl config use-context k3d-workload-dev
kubectl port-forward -n dotnet-k8s svc/todo-app-service 8082:80 &
curl -s http://localhost:8082/todos
# []

curl -s -X POST http://localhost:8082/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello from workload-dev"}'

That completes the live walkthrough. The sections below explain why each step works.

Install Rancher on k3d

Step 1: Create the cluster

The cluster script in the sample is deliberately short. k3d does the heavy lifting. The two flags that matter are the memory bump and the Traefik disable:

k3d cluster create rancher-mgmt \
  --servers 1 \
  --servers-memory 4g \
  -p "80:80@loadbalancer" \
  -p "443:443@loadbalancer" \
  --k3s-arg "--disable=traefik@server:0"

The port mappings make the Rancher UI reachable at http://localhost and https://localhost. The --k3s-arg flag disables Traefik, which k3s installs by default; leaving it enabled would start a fight over ports 80 and 443 the moment ingress-nginx comes up. We install ingress-nginx ourselves in setup-rancher.sh so we can run it with hostNetwork, exactly the same trick the previous article uses.

Step 2: cert-manager and the Rancher chart

Rancher is installed as a Helm chart from Rancher's own chart repository. It depends on cert-manager, so that chart goes in first:

helm repo add jetstack https://charts.jetstack.io --force-update
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.16.3 \
  --set crds.enabled=true \
  --wait \
  --timeout 4m

The crds.enabled=true setting is the modern way to install cert-manager's CRDs: since cert-manager 1.16 the CRDs ship with the Helm chart itself, so no separate kubectl apply of CRD manifests is needed.

Then Rancher itself:

helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
helm upgrade --install rancher rancher-latest/rancher \
  --namespace cattle-system \
  --create-namespace \
  --set hostname=rancher.local \
  --set bootstrapPassword=admin \
  --set replicas=1 \
  --wait \
  --timeout 15m

Three settings do the interesting work:

  • hostname=rancher.local. The UI must be reachable under a hostname Rancher knows. This sample uses a local-only hostname so the demo works without internet DNS. Add 127.0.0.1 rancher.local to your hosts file before opening the UI.
  • bootstrapPassword=admin. The temporary admin password. Rancher forces a real password change on first login.
  • replicas=1. A single-replica install. Defaults to three for HA; on a laptop one is enough.

The Rancher chart creates an Ingress, but it does not install an ingress controller. We add ingress-nginx ourselves, with hostNetwork so it can bind ports 80 and 443 directly on the k3d node (the k3d load balancer forwards host 80/443 to the node):

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.hostNetwork=true \
  --set controller.dnsPolicy=ClusterFirstWithHostNet \
  --set controller.service.type=ClusterIP \
  --set controller.publishService.enabled=false \
  --wait \
  --timeout 4m

# Some Rancher chart versions leave ingressClassName blank; patch it.
kubectl -n cattle-system patch ingress rancher --type='json' \
  -p='[{"op":"add","path":"/spec/ingressClassName","value":"nginx"}]'

Step 3: First login

Wait for the rollout, then open the URL and accept the self-signed certificate warning (expected, since the cert comes from cert-manager rather than a public CA). Add 127.0.0.1 rancher.local to your hosts file first:

kubectl -n cattle-system rollout status deploy/rancher
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller
# open https://rancher.local  (admin / admin, then change the password)

You land on a cluster dashboard with one cluster already listed: local.

Why rancher.local instead of localhost?

Rancher requires a hostname. The Helm chart uses it for the ingress Host header, cert-manager issues a TLS certificate for it, and Rancher stores it as the Server URL that every downstream cluster agent uses to phone home. The choice of hostname matters once you leave single-cluster mode.

localhost works for browsing the UI from the same machine, but it is the wrong Server URL for multi-cluster import. Inside the workload-dev cluster, localhost means the workload-dev node itself, not the laptop running Rancher. The cattle-cluster-agent installed there would try to connect to itself and fail. A real hostname that resolves back to the Rancher ingress fixes this.

This sample uses rancher.local because it is fully local and works without internet DNS. After adding 127.0.0.1 rancher.local to your hosts file, both your browser and the k3d clusters can resolve it. k3d containers run on the same Docker Desktop network as the host, so they reach rancher.local at the host IP where the ingress listens.

If you only want to explore the UI on one cluster, https://localhost:8443 through a port-forward is simpler and perfectly fine. For the multi-cluster and Fleet demo, use rancher.local.

The local Cluster: Rancher Manages Itself

The first cluster you see after login is local, and it is the cluster Rancher itself is running on. Rancher imports it automatically during installation. This is the purest statement of the management-plane model: Rancher is not a standalone appliance, it is a tenant of the very cluster it manages.

Rendering diagram...

The practical consequence: any workload you deploy into local shares the laptop's k3d cluster with Rancher itself. That is fine for a demo. Rancher's pods are modest once running, and it is the cheapest possible way to learn, because there is no second machine anywhere.

Projects vs Namespaces

Plain Kubernetes organizes with namespaces. Rancher adds one layer on top: the project, a group of namespaces that share RBAC and resource quotas. Think of it as the unit of team.

ConceptKubernetesRancher
ScopeNamespaceProject = group of namespaces
RBAC grantPer namespace, per role bindingOne project role covers all its namespaces
QuotaResourceQuota per namespaceProject-level quota
Who sees itWhoever has the role bindingMembers of the project

In the UI, the demo creates project team-a and, inside it, the namespace dotnet-k8s:

  1. Open the local cluster, then Projects/Namespaces.
  2. Create Project, name it team-a.
  3. Inside team-a, Create Namespace, name it dotnet-k8s.

A namespace always belongs to exactly one project. From Kubernetes' point of view nothing special happened: dotnet-k8s is a normal namespace. From Rancher's point of view it is now scoped to a team, with a natural place to attach roles and quotas.

Deploy the .NET App Without kubectl

The demo app is the same .NET 10 todo API from the previous article: full CRUD for todo items, backed by PostgreSQL for persistence and RabbitMQ for event publishing. Deploying it through Rancher instead of kubectl proves two things at once: the management plane works with real multi-service apps, not just hello-world endpoints, and the YAML Rancher sends to the cluster is exactly the same YAML you would kubectl apply yourself:

// DotnetK8sSetup — Program.cs (abridged)
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<TodoDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddSingleton(_ => new RabbitMqConnection(
    builder.Configuration.GetConnectionString("RabbitMq")!));

var app = builder.Build();

// Apply pending EF Core migrations on startup
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
    await db.Database.MigrateAsync();
}

app.MapGet("/health", () => Results.Ok(new { status = "Healthy" }));
app.MapTodoEndpoints();  // GET/POST/PUT/DELETE /todos
app.Run();

The /health endpoint serves the Kubernetes liveness and readiness probes. The MapTodoEndpoints() call registers the full CRUD surface: GET /todos, GET /todos/{id}, POST /todos, PUT /todos/{id}, and DELETE /todos/{id}. On POST, the handler publishes a TodoCreatedEvent to RabbitMQ, a small but real integration that proves the broker connection works end to end.

The deployment manifest connects the app to its dependencies through Kubernetes-native configuration. Connection strings come from a Secret injected as environment variables; non-sensitive settings come from a ConfigMap mounted as /app/appsettings.json. .NET's configuration pipeline picks both up automatically:

# k8s/app/app-deployment.yaml (abridged)
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: todo-app
          image: dotnet-k8s-setup:latest
          imagePullPolicy: IfNotPresent
          env:
            - name: ASPNETCORE_HTTP_PORTS
              value: '8080'
          envFrom:
            - secretRef:
                name: todo-app-secret
          volumeMounts:
            - name: config-volume
              mountPath: /app/appsettings.json
              subPath: appsettings.json
              readOnly: true
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
      volumes:
        - name: config-volume
          configMap:
            name: todo-app-config

The Secret contains two connection strings:

# k8s/app/secret.yaml
stringData:
  ConnectionStrings__DefaultConnection: 'Host=postgres-service;Port=5432;Database=todos;Username=app;Password=...'
  ConnectionStrings__RabbitMq: 'amqp://guest:guest@rabbitmq-service:5672'

The double-underscore convention (ConnectionStrings__DefaultConnection) is a .NET Configuration feature: when injected as an environment variable, it maps to ConnectionStrings:DefaultConnection in the configuration tree, overriding any file-based value. No code changes are needed; the naming convention does the work.

PostgreSQL and RabbitMQ are deployed as in-cluster services, suitable for a demo:

# k8s/app/postgres.yaml (abridged)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: dotnet-k8s
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: postgres
          image: postgres:17
          env:
            - name: POSTGRES_DB
              value: todos
            - name: POSTGRES_USER
              value: app
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: todo-app-secret
                  key: Postgres__Password

Build the image and import it into each cluster:

docker build -t dotnet-k8s-setup:latest .
k3d image import dotnet-k8s-setup:latest -c rancher-mgmt
k3d image import dotnet-k8s-setup:latest -c workload-dev   # multi-cluster demo

Now deploy from the Rancher UI. Click Import YAML in the top-right corner, paste all seven files from k8s/app/ (namespace.yaml, secret.yaml, configmap.yaml, postgres.yaml, rabbitmq.yaml, app-deployment.yaml, app-service.yaml), and click Import. The UI parses real Kubernetes YAML and hands it to the cluster unchanged: Secret, ConfigMap, Postgres, RabbitMQ, and the .NET deployment, all in one batch.

Verify from the terminal:

kubectl port-forward -n dotnet-k8s svc/todo-app-service 8080:80
curl http://localhost:8080/todos
# []

curl -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Deployed from Rancher UI"}'
# {"id":"...","title":"Deployed from Rancher UI","isComplete":false,...}

This is the "UI = YAML" proof: Rancher does not have its own deployment format. What you click in the UI is exactly what kubectl apply would do, whether for a single deployment or an entire multi-service stack.

RBAC: Cluster Roles vs Project Roles

With one team and one cluster, RBAC is a formality. The moment a second person appears, "who can see what" becomes the question, and Rancher's answer has two levels:

Role levelGrantsExample
Cluster roleOperate the cluster itselfCluster Member: sees all projects and namespaces
Project roleOperate workloads inside a projectProject Member: manages apps in team-a only

The demo creates a restricted user:

  1. Users & Authentication → Create → user alice, password of your choice, role Standard User.
  2. Open the local cluster → Members → Add → role Project Member for project team-a.
  3. Log out, log in as alice.

Alice sees exactly one project and its namespace. She does not see other projects, other clusters, or cluster-level resources. The same RBAC applies to every cluster Rancher manages, which is the point: one user model, many clusters.

Managing Multiple Clusters

Now the demo earns its name. Create the second cluster with no port mappings. Rancher will reach it through an agent connection, not through inbound ports:

k3d cluster create workload-dev --servers 1 --servers-memory 2g

Then in the Rancher UI: Cluster Management → Import Existing → Generic → Create. Rancher generates a command for you to run. It is a normal kubectl apply that installs an agent inside workload-dev:

kubectl apply -f https://rancher.local/v3/import/<generated-token>.yaml

The agent pod connects outbound to https://rancher.local and keeps the cluster registered. Nothing needs to reach into the cluster: no inbound firewall rules, no public IP, no VPN, and no internet DNS after the local hosts entry is in place. It is the same model Rancher uses to manage clusters on-premises or behind NAT.

If the agent cannot reach Rancher, check two things. First, rancher.local must resolve inside workload-dev to the host gateway (host.k3d.internal, typically 192.168.65.254 on Docker Desktop). Second, the Rancher ingress must actually route traffic: curl -k https://rancher.local/ping should return pong from anywhere that can reach the cluster.

Recommended addition from the field. The import command installs two agents: cattle-cluster-agent (cluster-level operations and state sync) and cattle-node-agent as a DaemonSet (kubectl exec, log streaming, node metrics). Both initiate outbound connections on port 443, so imported clusters need no inbound firewall rules. For clusters behind NAT or strict egress, enable agent tunnel mode so all API traffic flows through the agent's WebSocket connection.

Rendering diagram...

Within a minute or two, workload-dev shows Active in Cluster Management. You now have two clusters under one management plane. Projects, RBAC, and the workload list all work identically on both. That is the moment kubectl stops being the organizing principle of your operations.

Fleet: GitOps for Clusters

The last piece is delivery. Manually deploying to two clusters with kubectl is already tedious; a dozen clusters is impossible. Rancher's answer is Fleet, a GitOps engine bundled with the platform. Fleet watches a Git repository and rolls out the YAML it finds there to the clusters you select.

The sample ships a GitRepo resource that points at the devops-samples repository and targets every registered cluster:

# k8s/fleet-gitrepo.yaml
apiVersion: fleet.cattle.io/v1alpha1
kind: GitRepo
metadata:
  name: dotnet-k8s-setup
  namespace: fleet-default
spec:
  repo: https://github.com/mehdihadeli/devops-samples
  branch: main
  paths:
    - rancher-multi-cluster-setup/k8s/app
  targets:
    - clusterSelector: {}

Apply it once:

kubectl apply -f k8s/fleet-gitrepo.yaml

Fleet clones the repository, finds the rancher-multi-cluster-setup/k8s/app path, builds a Bundle per matched cluster, and hands each bundle to that cluster's Fleet agent. The agent applies it. The app now runs on rancher-mgmt and workload-dev, and nobody ran kubectl apply on either cluster to make it happen.

Rendering diagram...

The operational payoff: edit a manifest, push to main, and Fleet rolls the change out to every selected cluster. Drift is visible in the UI. A bundle stuck on "modified" is a cluster that is not converging.

One practical caveat: the GitRepo points at https://github.com/mehdihadeli/devops-samples and expects the path rancher-multi-cluster-setup/k8s/app to exist in that repository. Until the sample is published there, Fleet reports no resource found at the following paths. For local testing before publication, apply the manifests directly to each cluster:

kubectl --context k3d-rancher-mgmt apply -f k8s/app/
kubectl --context k3d-workload-dev apply -f k8s/app/

For the demo, also remember the image caveat: Fleet deploys manifests as-is, so dotnet-k8s-setup:latest must exist in each cluster (k3d image import dotnet-k8s-setup:latest -c workload-dev as well). In production the image lives in a registry, and both caveats disappear.

When to Skip Rancher

Every tool has an honest boundary, and Rancher's is easy to state: if you have one cluster and one person, Rancher is overhead. The UI is pleasant, but kubectl plus a good k9s session covers a solo developer's needs with zero moving parts. Rancher earns its 4 GB and its cattle-system namespace the moment any of these is true:

  • More than one cluster, especially clusters on different infrastructure.
  • More than one team, where namespace visibility and quotas need an owner.
  • Audited access, where "who changed what" needs an answer.
  • GitOps at scale, where the delivery pipeline should be uniform across clusters.

The sample's two clusters exist precisely so you can feel where that boundary is: a single k3d cluster from the previous article does not need Rancher; two clusters sharing one management plane, RBAC, and GitOps pipeline is the shape Rancher was built for.

Conclusion

This article took the story one step past the previous one. The first article in this series made a single cluster legible: k3d on the laptop, MetalLB for bare metal, and the Ingress versus Gateway API decision for traffic. This article made many clusters legible. Rancher Manager, installed on k3d as a Helm chart, imports its own cluster as local and organizes namespaces into projects with real RBAC boundaries. It deploys the same .NET 10 todo API, backed by PostgreSQL and RabbitMQ, through the UI using the exact same YAML a kubectl apply would use. It imports a second k3d cluster through an outbound agent connection, and Fleet delivers the entire stack to both clusters from a Git repository.

The reusable insight is the management-plane model: Rancher is not a Kubernetes replacement but a layer above it, talking to each cluster through its ordinary API. The projects, RBAC, import, and GitOps pieces of the demo all map unchanged to production clusters, whether they run k3s, RKE2, or a managed offering. The complete sample is in the rancher-multi-cluster-setup repository: two scripts create the clusters, one script installs Rancher, one manifest imports GitOps, and the same todo API from the previous article, now with PostgreSQL and RabbitMQ, proves the whole thing is alive on both clusters.

The natural next chapter is production: hardening a Rancher-managed fleet with GitOps-driven upgrades, observability through Rancher's monitoring stack, and policy enforcement across clusters. But the laptop demo in this article is the right place to start, because it makes the two hard ideas concrete in one sitting: Rancher manages itself, and it manages everything else the same way.

Recommended addition from the field. Before taking Rancher to production, enable the Rancher backup operator from day one. It protects the management plane, including cluster registrations, users, RBAC bindings, and settings, so a failed upgrade or database corruption does not force you to re-import every cluster. Also size the management cluster for growth: a dedicated 3-node cluster with 4 cores and 8 GB per node comfortably handles up to 50 downstream clusters, and testing upgrades in a non-production cluster for 24-48 hours before touching production is cheap insurance.