Deploy a Service Mesh with Linkerd

Deploy Linkerd on Kubernetes, verify mTLS, and run a canary deployment.

Introduction

30 Second Summary

When dozens of microservices talk to each other, things get complicated fast. Every connection needs encryption, every deployment needs monitoring, and one bad release can take down the whole system.

In this project, you will deploy a service mesh using Linkerd on a local Kubernetes cluster, inject sidecar proxies into a 3-tier application, verify automatic encryption between services, and perform a canary deployment with traffic splitting.

What You'll Build

A fully operational service mesh environment running on your local machine with a 3-tier microservices application, automatic mTLS encryption, live observability metrics, and HTTPRoute-based canary traffic splitting.

By the end of this project, you'll have:

  • A local kind cluster running the Linkerd control plane with the Viz observability extension.
  • A 3-tier application (frontend, API, backend) with automatic sidecar proxy injection and verified mutual TLS encryption on all service-to-service traffic.
  • An HTTPRoute resource performing weighted canary traffic splitting between two API service versions with real-time metric monitoring.
  • Secret Mission: Lock down your mesh with zero-trust authorization policy using Server and AuthorizationPolicy resources.

Are there any prerequisites?

This project assumes you are familiar with Kubernetes basics (deployments, services, namespaces, and kubectl). You should already have kind and kubectl installed on a Linux machine.

Before We Start

Before We Start...

Set Up kind Cluster and Install Linkerd

Every microservice in your application needs to talk to other services over the network. Without a service mesh, securing and observing that traffic means changing your application code for every service individually.

A service mesh handles all of this at the infrastructure layer. In this step, you'll create a local Kubernetes cluster and install Linkerd on it, giving your cluster automatic encryption, traffic metrics, and traffic control with zero application code changes.

In this step, get ready to:
  • Create a kind cluster for the mesh environment.
  • Install the Linkerd CLI tool.
  • Deploy the Linkerd control plane and Viz extension.
Create a kind cluster

Linkerd runs on top of Kubernetes. You need a cluster before you can install anything mesh-related.

  • Create a new kind cluster named linkerd-mesh by running this command:
kind create cluster --name linkerd-mesh

What does this command do?

kind creates a single-node Kubernetes cluster running inside a Docker container on your machine. The --name linkerd-mesh flag gives the cluster a descriptive name so you can identify it later.

kind also automatically sets your kubectl context to point at this new cluster. Every kubectl command you run from now on targets this cluster.

  • Verify the cluster is running by checking the cluster info:
  • Run this command:
kubectl cluster-info
  • You should see output showing the Kubernetes control plane is running at a local address.
  • Confirm the node is ready by running:
kubectl get nodes
  • You should see one node with a STATUS of Ready.

Cluster creation failed?

  • Make sure Docker is running on your machine. kind needs Docker to create the cluster node.
  • If you see a timeout error, Docker may need more memory allocated. Check Docker's resource settings.
  • help me debug my kind cluster creation error
Install the Linkerd CLI

The Linkerd CLI is your primary tool for installing, managing, and debugging the mesh. It validates your cluster, installs the control plane, and provides traffic inspection commands.

  • Download and install the Linkerd edge CLI by running this command:
curl --proto '=https' --tlsv1.2 -sSfL https://run.linkerd.io/install-edge | sh

What does this command do?

This downloads the official Linkerd install script over a secure HTTPS connection and runs it. The script places the linkerd binary at $HOME/.linkerd2/bin/linkerd.

Linkerd uses edge releases as their official open-source artifacts. The script installs the latest edge release, which is edge-26.6.1 at the time of writing.

  • Add the Linkerd CLI to your PATH so your terminal can find it:
export PATH=$HOME/.linkerd2/bin:$PATH

Make the PATH change permanent

The export command only lasts for your current terminal session. To keep it permanent, add the same line to your ~/.bashrc or ~/.zshrc file.

  • Verify the installation by checking the Linkerd CLI version:
linkerd version

Check your output against the tabs below.

✔️ I see an edge version like edge-26.6.1

You should see output similar to:

Client version: edge-26.6.1
Server version: unavailable

The Client version confirms your CLI is installed. Server version shows unavailable because you haven't installed the control plane yet. That's expected at this stage.

ⓧ I see a stable version like stable-2.x

You have an older Linkerd stable release installed from a previous setup. This project requires an edge release.

  • Remove the old binary by deleting the $HOME/.linkerd2 directory:
rm -rf $HOME/.linkerd2
  • Reinstall using the edge install script:
curl --proto '=https' --tlsv1.2 -sSfL https://run.linkerd.io/install-edge | sh
export PATH=$HOME/.linkerd2/bin:$PATH
  • Run linkerd version again to confirm you now see an edge version.

ⓧ Command not found

Your terminal cannot find the linkerd binary. This means the PATH was not updated correctly.

  • First, confirm the binary exists by running:
ls $HOME/.linkerd2/bin/linkerd
  • If the file exists, re-run the PATH export:
export PATH=$HOME/.linkerd2/bin:$PATH
  • If the file does not exist, the install script may have failed. Re-run the curl install command from above.
  • After fixing, run linkerd version again to confirm.
Install Linkerd control plane and Viz extension

Linkerd's architecture has two layers: a control plane that manages proxy configuration and certificates, and a data plane made of sidecar proxies that handle the actual traffic. You'll install the control plane now, which sets up the foundation for meshing your services later.

What is a control plane?

The control plane is a set of pods that run in the linkerd namespace. It handles certificate issuance for mTLS, proxy configuration, and service discovery.

The Viz extension adds an observability layer on top: a dashboard, metrics collection, and live traffic inspection tools.

  • Linkerd requires the Gateway API CRDs to be present on the cluster. Install them by running:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml

What are Gateway API CRDs?

Gateway API is a Kubernetes standard for managing traffic routing. Linkerd uses these Custom Resource Definitions (CRDs) for features like traffic splitting with HTTPRoute resources, which you'll use later for canary deployments.

  • Run the Linkerd pre-installation check to confirm your cluster is ready:
linkerd check --pre
  • You should see all checks pass with green checkmarks. This validates that your cluster meets Linkerd's requirements.
  • Install the Linkerd CRDs on the cluster:
linkerd install --crds | kubectl apply -f -
  • Install the Linkerd control plane:
linkerd install | kubectl apply -f -

What do these commands do?

linkerd install --crds generates the YAML for Linkerd's Custom Resource Definitions and pipes it to kubectl apply to install them on your cluster.

linkerd install generates the YAML for the control plane components (identity, destination, proxy-injector) and deploys them into the linkerd namespace.

  • Verify the control plane installed correctly by running:
linkerd check
  • Wait for all checks to pass. This may take a minute while pods start up.
  • You should see a series of green checkmarks across multiple categories.

Seeing failed checks?

  • If you see pods not ready, wait 30 seconds and run linkerd check again. The control plane pods may still be starting.
  • If the pre-check failed earlier but you continued anyway, run linkerd check --pre again to identify the cluster requirement that's missing.
  • help me debug my linkerd check failures
  • Now install the Viz extension for observability:
linkerd viz install | kubectl apply -f -
  • Run the full check one more time to verify everything, including Viz:
linkerd check
  • All checks should pass, including new sections for the Viz extension.
  • Confirm the control plane pods are healthy:
kubectl get pods -n linkerd
  • You should see pods like linkerd-destination, linkerd-identity, and linkerd-proxy-injector all showing Running.
  • Check the Viz extension pods too:
kubectl get pods -n linkerd-viz
  • You should see Viz pods in Running status as well.

Viz pods not starting?

  • Run kubectl describe pod -n linkerd-viz to check for image pull errors or resource issues.
  • Make sure your machine has enough memory. Linkerd is lightweight, but Docker may need 4GB+ allocated.
  • help me debug my Linkerd Viz pods

Your cluster is now running a fully operational Linkerd service mesh. Next up, you'll deploy a 3-tier application and watch Linkerd automatically inject sidecar proxies into every pod.

Deploy the 3-Tier Application and Mesh It

Your Linkerd control plane is running and verified. Now you need an application to mesh.

In this step, you will deploy a 3-tier microservices application into a dedicated namespace. The namespace will be annotated so that Linkerd automatically injects a sidecar proxy into every pod. This gives you a working service chain to observe and manage in the steps ahead.

In this step, get ready to:
  • Create an application namespace with automatic proxy injection enabled.
  • Deploy a 3-tier application (frontend, API, backend) with a traffic generator.
  • Verify that every pod has a Linkerd sidecar proxy injected.
Create application namespace with auto-injection enabled

Linkerd's proxy injector watches for a specific annotation on namespaces. When it sees the annotation, it automatically attaches a sidecar proxy container to every pod deployed in that namespace.

  • Create the mesh-demo namespace by running this command:
kubectl create namespace mesh-demo
  • Enable automatic sidecar injection by annotating the namespace:
kubectl annotate namespace mesh-demo linkerd.io/inject=enabled

What does this annotation do?

The linkerd.io/inject=enabled annotation tells Linkerd's admission controller to inject a proxy sidecar into every pod created in this namespace. You do not need to modify your application code or Dockerfiles.

Every pod will automatically get a second container (the linkerd-proxy) that handles mTLS, telemetry, and traffic management on behalf of your application.

  • Confirm the namespace exists and has the correct annotation by running:
kubectl get namespace mesh-demo -o jsonpath='{.metadata.annotations}'

You should see output containing linkerd.io/inject:enabled in the annotations map.

Namespace already exists?

If you see an error saying the namespace already exists, you can skip the create command and just run the annotate command. If the annotation already exists, add --overwrite to the annotate command.

help me debug a namespace annotation issue with Linkerd injection

Deploy the 3-tier application

Your application has three services chained together. The frontend receives requests and calls the API. The API processes requests and calls the backend. The backend is a terminal endpoint that returns a response.

A traffic generator pod continuously sends requests to the frontend so you always have live metrics to observe.

  • Create a file called mesh-demo.yaml in your current directory by pasting the following manifest:
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: frontend
  namespace: mesh-demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api
  namespace: mesh-demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: backend
  namespace: mesh-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: mesh-demo
  labels:
    app: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      serviceAccountName: backend
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - terminus
            - "--h1-server-port=8080"
            - "--response-text=backend-v1"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: backend
  namespace: mesh-demo
spec:
  selector:
    app: backend
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: mesh-demo
  labels:
    app: api
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
      version: v1
  template:
    metadata:
      labels:
        app: api
        version: v1
    spec:
      serviceAccountName: api
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://backend:8080"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: mesh-demo
spec:
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: mesh-demo
  labels:
    app: frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      serviceAccountName: frontend
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://api:8080"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: frontend
  namespace: mesh-demo
spec:
  selector:
    app: frontend
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: traffic-gen
  namespace: mesh-demo
  labels:
    app: traffic-gen
spec:
  replicas: 1
  selector:
    matchLabels:
      app: traffic-gen
  template:
    metadata:
      labels:
        app: traffic-gen
    spec:
      serviceAccountName: frontend
      containers:
        - name: slow-cooker
          image: buoyantio/slow_cooker:1.3.0
          args:
            - "-qps"
            - "2"
            - "-concurrency"
            - "1"
            - "http://frontend:8080"

What does this manifest define?

This YAML creates a complete 3-tier service chain:

  • backend runs in terminus mode. It is the end of the chain and responds with backend-v1.
  • api runs in point-to-point-channel mode. It receives a request and forwards it downstream to backend:8080.
  • frontend also runs in point-to-point-channel mode. It receives a request and forwards it to api:8080.
  • traffic-gen uses slow_cooker to send 2 requests per second to the frontend, generating continuous traffic for observability.

Each service has its own ServiceAccount. Linkerd uses these identities to issue mTLS certificates for each proxy.

  • Save mesh-demo.yaml.
  • Deploy the application by running:
kubectl apply -f mesh-demo.yaml -n mesh-demo

You should see output confirming that serviceaccounts, deployments, and services were created.

Seeing ImagePullBackOff errors?

If pods show ImagePullBackOff or ErrImagePull, your kind cluster cannot reach the container registry. Check your internet connection and ensure Docker is running.

help me debug ImagePullBackOff errors in my kind cluster

Verify sidecar injection and mesh status

Every pod in the mesh-demo namespace should now have two containers: the application container and the linkerd-proxy sidecar.

  • Check that all pods show 2/2 containers ready by running:
kubectl get pods -n mesh-demo

You should see four pods (backend, api, frontend, traffic-gen) each showing 2/2 in the READY column. The 2/2 confirms the app container plus the Linkerd proxy sidecar are both running.

What if pods show 1/1 instead of 2/2?

If you see 1/1, the sidecar was not injected. Confirm the namespace annotation is correct by running kubectl get namespace mesh-demo -o yaml and checking for linkerd.io/inject: enabled under annotations. If the annotation was added after the pods were created, delete the pods so they are recreated with injection.

  • Run the Linkerd data plane check to verify all proxies are healthy:
linkerd -n mesh-demo check --proxy

All checks should pass, confirming the proxies are connected to the control plane and functioning correctly.

  • Confirm live traffic is flowing through the mesh by running:
linkerd viz stat deploy -n mesh-demo

You should see all four deployments listed with success rate, requests per second (RPS), and latency columns populated. The traffic-gen pod is already sending 2 requests per second through the service chain.

Stats showing zero traffic?

If the stat output shows dashes instead of numbers, wait 30 seconds and run the command again. The traffic generator needs a moment to start sending requests after the pod becomes ready.

If traffic still does not appear, check the traffic-gen pod logs:

kubectl logs deploy/traffic-gen -n mesh-demo -c slow-cooker

help me debug why my Linkerd mesh is not showing traffic stats

✔️ Awesome, I've got everything!

Great. Double check you have saved mesh-demo.yaml and that all pods show 2/2 ready.

ⓧ I'd like to double check the full code

Here is the complete mesh-demo.yaml for comparison:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: frontend
  namespace: mesh-demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api
  namespace: mesh-demo
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: backend
  namespace: mesh-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: mesh-demo
  labels:
    app: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      serviceAccountName: backend
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - terminus
            - "--h1-server-port=8080"
            - "--response-text=backend-v1"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: backend
  namespace: mesh-demo
spec:
  selector:
    app: backend
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: mesh-demo
  labels:
    app: api
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
      version: v1
  template:
    metadata:
      labels:
        app: api
        version: v1
    spec:
      serviceAccountName: api
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://backend:8080"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: mesh-demo
spec:
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: mesh-demo
  labels:
    app: frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      serviceAccountName: frontend
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://api:8080"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: frontend
  namespace: mesh-demo
spec:
  selector:
    app: frontend
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: traffic-gen
  namespace: mesh-demo
  labels:
    app: traffic-gen
spec:
  replicas: 1
  selector:
    matchLabels:
      app: traffic-gen
  template:
    metadata:
      labels:
        app: traffic-gen
    spec:
      serviceAccountName: frontend
      containers:
        - name: slow-cooker
          image: buoyantio/slow_cooker:1.3.0
          args:
            - "-qps"
            - "2"
            - "-concurrency"
            - "1"
            - "http://frontend:8080"

Your 3-tier application is fully meshed. Every request between services now flows through Linkerd's proxy layer. Next up, you will verify that mTLS is encrypting all that traffic and explore the observability features built into your mesh.

Verify mTLS and Explore Observability

In the previous step, you deployed a 3-tier application and confirmed every pod is running with a Linkerd sidecar proxy. Traffic is flowing continuously through the service chain.

Now comes the payoff. A service mesh does two critical things without touching your application code: it encrypts all traffic between services with mTLS, and it generates detailed telemetry about every request. In this step, you will prove both are happening live in your cluster.

In this step, get ready to:
  • Verify that all service-to-service connections are encrypted with mutual TLS.
  • Explore golden metrics (success rate, RPS, latency) in the Viz dashboard and CLI.
  • Inspect live traffic at L7 using tap for real-time request streams.
Verify automatic mTLS encryption

Every connection between meshed pods is automatically encrypted with mutual TLS. You do not need to configure certificates or modify your application. The sidecar proxies handle all of this transparently.

  • Confirm that all connections in the mesh are secured by running this command:
linkerd viz edges -n mesh-demo

What does this command do?

The edges subcommand shows every connection between resources in the namespace. Each row displays the source, destination, and whether the connection is secured with mTLS.

You should see all connections listed with a secured status. This means the Linkerd proxies are performing a full TLS handshake using their unique identity certificates for every connection.

  • Now see mTLS in action at the individual request level by running:
linkerd viz tap deploy/api -n mesh-demo

You should see a live stream of requests. Each line shows the tls=true flag along with the identity of the calling service.

What is the tls=true flag telling me?

Each request in the tap output shows whether TLS was active. The identity field reveals which ServiceAccount made the call. This proves that Linkerd is not just encrypting traffic, but also cryptographically verifying the identity of each service on every request.

  • Press Ctrl+C to stop the tap stream once you have observed several requests with tls=true.

Not seeing any output from tap?

Make sure the traffic generator is still running. Check with kubectl get pods -n mesh-demo and verify that the traffic-gen pod shows 2/2 READY. If it restarted, wait a few seconds for requests to flow again.

help me debug why linkerd viz tap is not showing output

Explore golden metrics in the Viz dashboard

Golden metrics are the three signals that tell you whether a service is healthy: success rate, requests per second (RPS), and latency percentiles. Linkerd calculates these automatically from the proxy telemetry.

  • Launch the Linkerd Viz dashboard by running:
linkerd viz dashboard &

A browser window opens showing the Linkerd dashboard. Navigate to the mesh-demo namespace to see per-deployment metrics for frontend, api, and backend.

What are golden metrics?

Golden metrics are the standard signals for service health in production systems. Success rate shows the percentage of non-error responses. RPS shows how much demand a service handles. Latency percentiles (P50, P95, P99) show how long requests take at the median, the 95th percentile, and the worst case.

These three metrics together give you a complete picture of whether a service is performing well or degrading.

  • View the same golden metrics in tabular form from the CLI by running:
linkerd viz stat deploy -n mesh-demo

You should see a table with columns for each deployment showing success rate, RPS, and latency percentiles (P50, P95, P99). All three services should show 100% success rate with the traffic generator running.

  • See a real-time ranking of which paths are being called by running:
linkerd viz top deploy/api -n mesh-demo

This displays sorted live traffic statistics for the api deployment. You can see which source is calling the api and how frequently. Press Ctrl+C to exit.

Dashboard not opening in a browser?

If you are running in a headless environment (like a remote server), the browser will not open automatically. Look for the URL printed in your terminal output. It will be a localhost address you can visit manually or forward through SSH.

help me access the Linkerd Viz dashboard

Use tap for live traffic inspection

The tap command is the equivalent of tcpdump for your service mesh, but operating at Layer 7 (HTTP). It lets you see every request flowing through a service in real time with full metadata.

  • Stream live requests through the api deployment by running:
linkerd viz tap deploy/api -n mesh-demo

Each line in the output shows the request method, path, response status code, latency, and the source/destination identity. This gives you full visibility into what is happening between services without adding any logging to your application code.

What metadata does tap show?

  • Source and destination addresses with proxy identity (which ServiceAccount is calling which).
  • HTTP method and path for each request.
  • Response status code and latency for each response.
  • TLS status confirming encryption is active.
  • Press Ctrl+C to stop the tap stream.

Why is tap useful in production debugging?

Traditional debugging requires adding log statements, redeploying, and waiting for the issue to reappear. With tap, you can observe traffic on any service immediately without modifying code or restarting pods. This makes it invaluable for diagnosing intermittent failures or latency spikes.

You have now confirmed that your mesh encrypts all traffic with mTLS and generates full observability data. Next up, you will use these same proxies to perform a canary deployment with live traffic splitting.

Perform a Canary Deployment with Traffic Splitting

You have a fully meshed application with mTLS encryption and observable golden metrics. Now it is time to tackle the real challenge of microservices: deploying a new version of a service without breaking anything.

In production, teams never swap 100% of traffic to a new version all at once. Instead, they use a canary deployment pattern to gradually shift a small percentage of traffic to the new version while monitoring its health. In this step, you will deploy a second version of the API service and use an HTTPRoute resource to split traffic between the two versions.

In this step, get ready to:
  • Deploy a v2 of the API service into your mesh.
  • Create an HTTPRoute that splits traffic 90/10 between v1 and v2.
  • Gradually shift traffic to 50/50 then 100% to v2 while monitoring golden metrics.
Deploy a v2 of the API service

To perform a canary deployment, you need two versions of the same service running side by side. The new version (api-v2) will use the same api ServiceAccount so it gets the same mTLS identity, but it responds with a different message so you can distinguish its traffic.

  • Create a file called api-v2.yaml by running:
cat <<EOF > api-v2.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-v2
  namespace: mesh-demo
  labels:
    app: api-v2
    version: v2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api-v2
      version: v2
  template:
    metadata:
      labels:
        app: api-v2
        version: v2
    spec:
      serviceAccountName: api
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://backend:8080"
            - "--response-text=api-v2"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: api-v2
  namespace: mesh-demo
spec:
  selector:
    app: api-v2
  ports:
    - port: 8080
      targetPort: 8080
EOF

What does this manifest do?

This creates a second Deployment called api-v2 that behaves almost identically to the original api Deployment. It runs the same buoyantio/bb image in point-to-point-channel mode, forwarding requests to backend:8080.

The key difference is the --response-text=api-v2 argument, which makes v2 responses identifiable. It uses the same api ServiceAccount so it receives the same mTLS identity as the original.

  • Deploy api-v2 into the mesh by running:
kubectl apply -f api-v2.yaml -n mesh-demo
  • Verify the new pod is running with a sidecar by running:
kubectl get pods -n mesh-demo -l app=api-v2
  • You should see 1/1 transition to 2/2 in the READY column, confirming both the app container and the Linkerd proxy sidecar are running.
  • Wait about 30 seconds if it still shows 0/2 or 1/2, then re-run the command.

Pod stuck at 1/2 or 0/2?

Check the pod events for errors by running kubectl describe pod -n mesh-demo -l app=api-v2. Common causes: the buoyantio/bb:v0.0.6 image failed to pull (check your internet connection), or the namespace annotation for injection is missing.

help me debug my api-v2 pod that is stuck in a non-ready state

✔️ Awesome, I've got everything!

Great. Double check your pod shows 2/2 READY before continuing.

ⓧ I'd like to double check the full code

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-v2
  namespace: mesh-demo
  labels:
    app: api-v2
    version: v2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api-v2
      version: v2
  template:
    metadata:
      labels:
        app: api-v2
        version: v2
    spec:
      serviceAccountName: api
      containers:
        - name: bb
          image: buoyantio/bb:v0.0.6
          args:
            - point-to-point-channel
            - "--h1-server-port=8080"
            - "--h1-downstream-server=http://backend:8080"
            - "--response-text=api-v2"
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: api-v2
  namespace: mesh-demo
spec:
  selector:
    app: api-v2
  ports:
    - port: 8080
      targetPort: 8080
Create an HTTPRoute for weighted traffic splitting

Right now, api-v2 is running but receiving no traffic. To start sending requests to it, you will create an HTTPRoute resource that tells Linkerd to split traffic destined for the api Service between both backends using configurable weights.

Why HTTPRoute instead of TrafficSplit?

Linkerd previously used the SMI TrafficSplit CRD for traffic splitting, but that API is now deprecated. The recommended approach uses HTTPRoute resources from the Gateway API specification.

HTTPRoute is more flexible because it can match on headers, paths, and methods in addition to weights. It integrates directly with Linkerd's policy system without needing an extra extension.

  • Create the initial traffic split manifest by running:
cat <<EOF > traffic-split.yaml
---
apiVersion: policy.linkerd.io/v1beta2
kind: HTTPRoute
metadata:
  name: api-canary-route
  namespace: mesh-demo
spec:
  parentRefs:
    - name: api
      kind: Service
      group: core
      port: 8080
  rules:
    - backendRefs:
        - name: api
          port: 8080
          weight: 90
        - name: api-v2
          port: 8080
          weight: 10
EOF

What does this HTTPRoute do?

The parentRefs field targets the existing api Service on port 8080. Any traffic originally destined for that Service now gets intercepted by this route.

The backendRefs define two destinations with weights: 90% of requests go to the original api Service and 10% go to api-v2. This is your initial canary percentage.

  • Apply the HTTPRoute by running:
kubectl apply -f traffic-split.yaml -n mesh-demo
  • Wait about 30 seconds for traffic to start distributing, then check the stats by running:
linkerd viz stat deploy -n mesh-demo
  • You should see both api and api-v2 in the output table with RPS values. The api-v2 row should show roughly 10% of the traffic that api receives.

Not seeing api-v2 in the stat output?

Give it 60 seconds for metrics to accumulate. The Viz metrics stack needs time to collect data from the new proxy. Run the linkerd viz stat command again after waiting.

If api-v2 still shows no RPS after 60 seconds, confirm the HTTPRoute was created by running kubectl get httproutes -n mesh-demo. You should see api-canary-route listed.

help me debug why my HTTPRoute traffic split is not working in Linkerd

✔️ Awesome, I've got everything!

Great. You should see traffic flowing to both api and api-v2 in the stat output.

ⓧ I'd like to double check the full code

---
apiVersion: policy.linkerd.io/v1beta2
kind: HTTPRoute
metadata:
  name: api-canary-route
  namespace: mesh-demo
spec:
  parentRefs:
    - name: api
      kind: Service
      group: core
      port: 8080
  rules:
    - backendRefs:
        - name: api
          port: 8080
          weight: 90
        - name: api-v2
          port: 8080
          weight: 10
Gradually shift traffic and monitor

A real canary deployment works by gradually increasing the new version's traffic share while watching for errors. If the success rate, latency, or error rate degrades, you roll back. If metrics stay healthy, you promote the new version.

  • Create a 50/50 split manifest by running:
cat <<EOF > traffic-split-50-50.yaml
---
apiVersion: policy.linkerd.io/v1beta2
kind: HTTPRoute
metadata:
  name: api-canary-route
  namespace: mesh-demo
spec:
  parentRefs:
    - name: api
      kind: Service
      group: core
      port: 8080
  rules:
    - backendRefs:
        - name: api
          port: 8080
          weight: 50
        - name: api-v2
          port: 8080
          weight: 50
EOF

What changed?

The HTTPRoute name (api-canary-route) is the same, so applying this manifest updates the existing route in place. The only change is the weights: both backends now receive equal traffic.

  • Apply the 50/50 split by running:
kubectl apply -f traffic-split-50-50.yaml -n mesh-demo
  • Wait about 30 seconds, then check the metrics by running:
linkerd viz stat deploy -n mesh-demo
  • You should see api and api-v2 with roughly equal RPS values now. Both should maintain a healthy SUCCESS rate near 100%.

The metrics confirm that api-v2 is handling half the load without degrading. Time to promote it fully.

  • Create the full promotion manifest that sends 100% of traffic to api-v2 by running:
cat <<EOF > traffic-split-full.yaml
---
apiVersion: policy.linkerd.io/v1beta2
kind: HTTPRoute
metadata:
  name: api-canary-route
  namespace: mesh-demo
spec:
  parentRefs:
    - name: api
      kind: Service
      group: core
      port: 8080
  rules:
    - backendRefs:
        - name: api-v2
          port: 8080
          weight: 100
EOF
  • Apply the full promotion by running:
kubectl apply -f traffic-split-full.yaml -n mesh-demo
  • Wait about 30 seconds, then verify all traffic is routed to api-v2 by running:
linkerd viz stat deploy -n mesh-demo
  • You should see api-v2 receiving all the RPS while api shows 0 or near-zero traffic. The canary promotion is complete.

How does this map to real canary deployments?

In production, teams automate this process with progressive delivery controllers like Flagger. The controller watches golden metrics (success rate, latency, throughput) at each stage and automatically rolls back if thresholds are breached.

What you just did manually is the exact same loop: deploy, split a small percentage, observe, increase, observe, promote. The HTTPRoute weights give you fine-grained control over exactly how much risk you take at each step.

Traffic not shifting after applying a new manifest?

Make sure the HTTPRoute name field is exactly api-canary-route in every manifest. If the name differs, Kubernetes creates a new route instead of updating the existing one.

Also verify that parentRefs targets the api Service with group: core and port: 8080. A mismatch here means the route is not attached to the correct Service.

help me troubleshoot why my Linkerd HTTPRoute weight changes are not taking effect

You have just completed a full canary deployment lifecycle. Next up, you will lock down your mesh with zero-trust authorization policies in the Secret Mission.

Secret mission

Lock Down Your Mesh with Zero-Trust Authorization Policy

Right now, every service in your mesh can talk to every other service. The mTLS encryption proves identity, but nothing enforces who is allowed to connect where. In production, that is a security gap. Your mission: use Linkerd's Server and AuthorizationPolicy resources to restrict backend access so only the API service can reach it.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on a local kind cluster, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • kind cluster linkerd-mesh (contains Linkerd control plane, Viz extension, and all mesh-demo resources).
  • Linkerd CLI installed at ~/.linkerd2/.
  • Local YAML files: mesh-demo.yaml, api-v2.yaml, traffic-split.yaml, traffic-split-50-50.yaml, traffic-split-full.yaml, authorization-policy.yaml.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with Linkerd, adding more services, or practicing traffic management.

  • Your kind cluster continues running as a Docker container in the background.
  • The Linkerd control plane, Viz extension, and all mesh-demo resources remain active.
  • The Linkerd CLI stays available at ~/.linkerd2/bin/linkerd.

Pause - I'll come back to this later

Stop the Docker container running your kind cluster to free up memory. Your cluster state is preserved and you can restart it later.

  • Stop the kind cluster container by running:
docker stop linkerd-mesh-control-plane
  • When you want to resume, start it again by running:
docker start linkerd-mesh-control-plane
  • After restarting, wait a minute for all pods to become ready, then verify with kubectl get pods -A.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the cluster, the Linkerd CLI, and all local YAML files.

  • Delete the kind cluster by running:
kind delete cluster --name linkerd-mesh
  • This removes the Linkerd control plane, Viz extension, all namespaces, and every Kubernetes resource in one command.
  • Remove the Linkerd CLI by running:
rm -rf ~/.linkerd2
  • Remove the local YAML files by running:
rm mesh-demo.yaml api-v2.yaml traffic-split.yaml traffic-split-50-50.yaml traffic-split-full.yaml authorization-policy.yaml
  • Confirm everything is gone by running:
kind get clusters
  • You should see no clusters listed in the output.

Nice Work!

Nice Work!

You just deployed and operated a fully functional service mesh on your local machine. You now have hands-on experience with the exact infrastructure patterns that platform engineering and DevOps teams use to secure, observe, and manage microservice traffic in production.

You've learned how to:

  • Deploy a Linkerd service mesh with automatic sidecar proxy injection, verifying that all service-to-service communication is encrypted with mutual TLS.
  • Observe live traffic using golden metrics (success rate, latency, throughput) through the Linkerd Viz dashboard and CLI tools like tap and stat.
  • Perform a canary deployment using HTTPRoute weighted traffic splitting, gradually shifting requests between service versions while monitoring for regressions.
  • Secret Mission: Implement zero-trust authorization policy using Server and AuthorizationPolicy resources, restricting service-to-service communication based on mTLS identity.

Ready to quiz yourself?