Linkerd End to End: Install, Canary, Lock Down, Monitor

Most service mesh tutorials stop after install and a green check. That leaves you with an encrypted cluster and no idea how to ship a canary, refuse a caller, or tell whether any of it is working. This guide goes all the way through: install, one canary, one authorization policy, one failing dependency survived, and two layers of monitoring.

By the end you will have a mesh where a canary shifts traffic on demand, an unauthorized pod gets a 403, a slow dependency times out instead of hanging, and a Grafana dashboard that does not lie to you. Budget about an hour on a kind cluster. Every command below was run against Linkerd edge-26.7.2 on Kubernetes 1.35, and every number is measured rather than quoted.

Before you start

  • A Kubernetes cluster you can install CRDs into. Two nodes is plenty.
  • kubectl and helm already on your path.
  • An app with at least two services. Mine is a backend serving one line of text in two versions, a go-httpbin deployment for producing failures on demand, and a curl pod as the client.

The linkerd CLI is the one tool you probably do not have yet, and every step below starts with it:

curl --proto '=https' --tlsv1.2 -sSfL https://run.linkerd.io/install-edge | sh
export PATH=$PATH:$HOME/.linkerd2/bin
linkerd version --client
Client version: edge-26.7.2

The installer drops the binary in $HOME/.linkerd2/bin and does not touch your PATH, so put that export in your shell profile or the next command will not find it. Linkerd publishes no stable channel, so edge is the current build rather than a preview, and the installer pins the version you see above. That is the build every number in this guide was measured against.

One decision to make before anything else, because retrofitting it is annoying: give every workload its own ServiceAccount. All three major meshes derive workload identity from it. Leave everything on default and “allow the client” quietly means “allow anything in this namespace”, which is not a policy.

Here is the shape of the app, trimmed to the parts that matter. Deploy it unmeshed first and enrol it in Step 2, so you can see what the mesh changes:

apiVersion: v1
kind: ServiceAccount
metadata: { name: backend, namespace: shop }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: backend-v1, namespace: shop }
spec:
  selector:
    matchLabels: { app: backend, version: v1 }
  template:
    metadata:
      labels: { app: backend, version: v1 }
    spec:
      serviceAccountName: backend
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          readinessProbe:
            httpGet: { path: /, port: 80 }
---
apiVersion: v1
kind: Service
metadata: { name: backend, namespace: shop }
spec:
  selector: { app: backend }        # both versions, on purpose
  ports: [{ name: http, port: 80, targetPort: 80 }]

backend-v2 is the same with version: v2 and a different response body. The client is a curl image sleeping forever, with its own ServiceAccount. For Step 7 you also want something that fails on demand: mccutchen/go-httpbin serves /delay/5 and /status/200,503 and saves you writing one.

Step 1 - Install Linkerd, and install Gateway API first

Run the pre-check before the install. It catches a real prerequisite:

linkerd check --pre
× Gateway API CRDs are installed
    The Gateway API CRDs must be installed prior to installing Linkerd.

This is not optional and it is not cosmetic. Linkerd has no traffic-split object of its own any more. It uses Gateway API HTTPRoute, which means the CRDs are load bearing for both the canary and the timeout work later in this guide. Install them separately, which is what the tool recommends, rather than letting Linkerd bundle them:

kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
linkerd install --crds | kubectl apply -f -
linkerd install | kubectl apply -f -
linkerd check

linkerd check should end with Status check results are √. If it hangs rather than failing, that means the control plane is unreachable, not healthy.

Step 2 - Enroll your app

Injection is a namespace annotation plus a restart. Nothing rewrites running pods:

kubectl annotate namespace shop linkerd.io/inject=enabled
kubectl -n shop rollout restart deploy/backend-v1 deploy/backend-v2 deploy/flaky deploy/client

Pods come back 2/2. Confirm the identities are distinct, because everything in Step 5 depends on it:

linkerd identity -n shop $(kubectl -n shop get pod -l app=client -o name | head -1)
Subject: CN=client.shop.serviceaccount.identity.linkerd.cluster.local

If that reads default.shop.serviceaccount... for every pod, go back and fix your ServiceAccounts now.

Step 3 - Get metrics before you need them

Install the viz extension. This is the fastest useful thing in Linkerd and it needs no configuration:

linkerd viz install | kubectl apply -f -
linkerd viz check
linkerd viz stat deploy -n shop
NAME         MESHED   SUCCESS      RPS   LATENCY_P50   P95   P99
backend-v1      1/1   100.00%   0.2rps           1ms   1ms   1ms
backend-v2      1/1   100.00%   0.2rps           1ms   1ms   1ms
flaky           3/3   100.00%   0.5rps           1ms   1ms   1ms

Golden metrics per workload, with no scrape config and no instrumentation in your app. Do this early: every step below is easier to verify when you can already see traffic.

Its Prometheus keeps about six hours by default and is not meant to be your long term store. Step 8 replaces it.

Prove the encryption instead of trusting it

linkerd viz will happily tell you traffic is meshed. Checking it with the mesh’s own metrics is a little circular, and it takes about two minutes to check properly.

Run a privileged pod with hostNetwork: true on the node your backend is on. The host ends of every pod’s virtual interface live in the node’s network namespace, so tcpdump -i any there sees pod to pod traffic. Filter to the two pod IPs, send a handful of requests, then grep the capture for your response body.

Capture Packets Response body in clear Plaintext GET
meshed client to backend 148 0 0
unmeshed caller to an unprotected service 76 visible 2

The second row is the one people skip, and it is the one that makes the first row mean anything. A capture that never started also reports zero. Point an unmeshed caller at something without a Server object in front of it, confirm you can read the traffic, and only then believe the zero.

Where each object attaches

Before configuring anything, this is the part that trips people up. Linkerd has four features you are likely to want and they bind in three different places, and the object names do not tell you which is which.

Where each Linkerd configuration object attachesA request travels from a client pod, through a Kubernetes Service, to two backend pods. Three groups of Linkerd objects attach at different points. An HTTPRoute attaches to the Service and controls canary weights and timeouts. A failure accrual annotation sits on the same Service and controls circuit breaking. A Server, AuthorizationPolicy and MeshTLSAuthentication attach to the backend pods' port and control who is allowed to call it.Three attachment points, not oneclient podits own identityService: backendthe address clients callbackend v1port 80backend v2port 80HTTPRoutecanary weights, timeouts, retriesannotation on the Servicecircuit breaking (failure accrual)Server+ AuthorizationPolicy+ MeshTLSAuthenticationREAD IT THIS WAYTraffic management parents to the Service.Circuit breaking is an annotation on thatsame Service, not on the route.Authorization binds to the pods' port,so it applies however you were routed.

Traffic management parents to the Service. Circuit breaking is an annotation on that same Service rather than on the route. Authorization binds to the pod’s port, so it applies no matter how the caller reached you. Keep that picture in mind and the rest of this guide stops feeling arbitrary.

Step 4 - Ship a canary

HTTPRoute backendRefs point at Services, not at label subsets, so each version needs its own Service. Your existing backend Service stops being a load balancer and becomes the route’s parent.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: backend-canary
  namespace: shop
spec:
  parentRefs:
    - name: backend
      kind: Service
      group: ""
      port: 80
  rules:
    - backendRefs:
        - { name: backend-v1, port: 80, weight: 90 }
        - { name: backend-v2, port: 80, weight: 10 }

Verify with volume, not with one request. Two hundred requests through the route:

Weights v1/v2 Result
90 / 10 183 v1, 17 v2
50 / 50 51 v1, 49 v2
0 / 100 100 v2

Shifting the canary is editing two numbers. Rolling back is editing them again, and it takes effect in seconds without restarting anything.

Step 5 - Refuse an unauthorized caller

Linkerd splits authorization into three objects, which feels heavy until you have several policies sharing one Server:

  • Server marks a port as policy controlled. Creating it flips that port to deny by default.
  • MeshTLSAuthentication names who a caller is, by mesh identity.
  • AuthorizationPolicy binds the two.
apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata: { name: backend-http, namespace: shop }
spec:
  podSelector:
    matchLabels: { app: backend }
  port: 80
  proxyProtocol: HTTP/1
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata: { name: client-identity, namespace: shop }
spec:
  identities:
    - 'client.shop.serviceaccount.identity.linkerd.cluster.local'
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata: { name: backend-allow-client, namespace: shop }
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: backend-http
  requiredAuthenticationRefs:
    - group: policy.linkerd.io
      kind: MeshTLSAuthentication
      name: client-identity

Test it with a second pod that has a different ServiceAccount. A policy that allows the only caller in the namespace proves nothing.

The trap: your probe path is exempt

My first test said the policy did nothing. The intruder got a 200. It was not a propagation delay and the policy was not wrong:

Path client (allowed) intruder (not allowed)
GET / 200 200, walks straight through
GET /index.html 200 403

Linkerd auto-authorizes the workload’s own probe path for unauthenticated callers from 0.0.0.0/0, so probes keep working after the mesh takes over the pod’s network. My backend’s readiness probe was httpGet: { path: /, port: 80 }, which is an entirely ordinary choice, and / is therefore exempt. Part 1 of this series found all three major meshes doing a version of this.

The fix is in your app, not in the mesh. Give the probe a path that returns nothing worth having:

readinessProbe:
  httpGet: { path: /healthz, port: 80 }

Serve an empty file at that path and re-test:

Path client intruder
GET / 200 403
GET /index.html 200 403
GET /healthz 200 200, and it is empty

The backend stayed Ready throughout. Resist the temptation to narrow probeNetworks to your node subnet instead: it looks correct, and about twenty five seconds later the backend fails its own readiness probe and drops out of its Service.

Step 6 - Let traffic in from outside

Linkerd ships no ingress of its own. You bring one and mesh it, and it becomes an ordinary mesh member with an identity.

Do not reach for ingress-nginx here. It was archived on 24 March 2026 and moved to kubernetes-retired: no releases, no bugfixes, no CVE patches. Its maintainers put it plainly, that if you are not already running it you should not deploy it, and should pick a Gateway API implementation instead.

That suits this guide anyway. You already installed the Gateway API CRDs in Step 1, so routing in and routing inside end up on one API rather than two. I used NGINX Gateway Fabric, which keeps NGINX as the data plane and drives it with Gateway API:

kubectl create namespace nginx-gateway
kubectl annotate namespace nginx-gateway linkerd.io/inject=enabled
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric -n nginx-gateway

Annotate the namespace before installing so everything in it is injected on creation. Worth checking afterwards: the CRDs stayed on the version Step 1 installed rather than being overwritten.

Now the split Gateway API is built around. The Gateway is infrastructure:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: shop, namespace: nginx-gateway }
spec:
  gatewayClassName: nginx
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      hostname: shop.example.com
      allowedRoutes:
        namespaces: { from: All }

The HTTPRoute is application config and lives with the app:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: backend-external, namespace: shop }
spec:
  parentRefs:
    - { name: shop, namespace: nginx-gateway, sectionName: http }
  hostnames: ['shop.example.com']
  rules:
    - matches: [{ path: { type: PathPrefix, value: / } }]
      backendRefs:
        - { name: backend, port: 80 }

Applying the Gateway makes NGF provision a data plane pod for it, and because the namespace is annotated that pod comes up meshed. Its identity comes from a ServiceAccount created per Gateway and named after it, which is the detail that will bite you:

Subject: CN=shop-nginx.nginx-gateway.serviceaccount.identity.linkerd.cluster.local

Rename the Gateway and that identity changes, so the policy you are about to write stops matching.

Now send a request from outside and watch Steps 5 and 6 collide:

From outside, through the Gateway Result
with the policy from Step 5 in force 403
after adding the Gateway’s identity 200
a request with the wrong Host 404, refused by the Gateway before the mesh sees it
intruder inside the cluster, same moment 403

That 403 is correct behaviour and worth sitting with. Your gateway is not special. It is another workload with another identity, and if your policy does not name it, it does not get in:

identities:
  - 'client.shop.serviceaccount.identity.linkerd.cluster.local'
  - 'shop-nginx.nginx-gateway.serviceaccount.identity.linkerd.cluster.local'

Keep the 404 row separate in your head. Hostname matching is the Gateway’s job and runs before any mesh policy, so a bad Host never reaches a proxy that could evaluate identity.

Step 7 - Survive a bad dependency

Three separate mechanisms, and they are not in the same place. Timeouts are a Gateway API field. Retries are Linkerd annotations on the route. Circuit breaking is an annotation on the Service.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: flaky-resilience
  namespace: shop
  annotations:
    retry.linkerd.io/http: 5xx
    retry.linkerd.io/limit: '3'
spec:
  parentRefs:
    - { name: flaky, kind: Service, group: "", port: 8080 }
  rules:
    - timeouts:
        request: 2s
      backendRefs:
        - { name: flaky, port: 8080 }

Measured against an endpoint that sleeps for five seconds, and one that fails randomly:

Measurement Before After
/delay/5 with a 2 s request timeout 200 in 5.09 s 504 in 1.97 s
/status/200,503, 30 requests, retry 5xx limit 3 16/30 succeeded 27/30 succeeded

Retries turned a coin flip into a 90% success rate without touching the application.

Circuit breaking is an annotation on the Service:

kubectl -n shop annotate svc flaky \
  balancer.linkerd.io/failure-accrual=consecutive \
  balancer.linkerd.io/failure-accrual-consecutive-max-failures=3 \
  balancer.linkerd.io/failure-accrual-consecutive-min-penalty=10s

Then trip it and watch what it costs you:

Step A request to a healthy path
before tripping anything 200 200 200
immediately after 30 consecutive failures 503 503 503 503 503
after the 10 s penalty window 200 200 200 200 200

The middle row is the part to understand before you enable this in production. The breaker ejects the endpoint, not the failing route, so healthy requests to the same Service are refused too while the penalty runs. It recovers on its own, and you do not get to keep the good traffic in the meantime.

One honest caveat: I could not find a proxy metric for this. failure_accrual does not appear in the proxy’s metrics at all, so the behavioural test above is the evidence.

Step 8 - Monitoring you can keep

The viz bundle is for looking at something now. For anything you keep, run your own Prometheus and Grafana. The only Linkerd specific part is the scrape config, because Linkerd ships no ServiceMonitor and kube-prometheus-stack scrapes nothing by default:

- job_name: 'linkerd-proxy'
  kubernetes_sd_configs: [{ role: pod }]
  relabel_configs:
    - source_labels:
        - __meta_kubernetes_pod_container_name
        - __meta_kubernetes_pod_container_port_name
        - __meta_kubernetes_pod_label_linkerd_io_control_plane_ns
      action: keep
      regex: ^linkerd-proxy;linkerd-admin;linkerd$

That three label regex is what stops it scraping every port in the cluster. With it, seventeen proxy targets came up, all up=1: seven in the app namespace, three for the control plane, five for viz, and two for the gateway, which is both the NGINX Gateway Fabric controller and the data plane pod it provisioned.

Two facts about Linkerd’s labels decide every query you will write.

classification="failure" means 5xx only. A 403 is classified as success, because the server answered correctly. Verified directly: the denied calls from Step 5 carry classification="success", status_code="403". So a success-rate panel will not dip when your authorization policy starts refusing traffic. That is usually what you want, and it is startling the first time.

dst_deployment only exists on outbound series. It is the label that answers “who was I calling”. Inbound series describe the callee, so any “what am I depending on” query has to filter to direction="outbound" or it returns nothing useful.

The mTLS panel will lie to you unless you scope it

The obvious query for “how much of my traffic is encrypted” reads 82.4% on a perfectly healthy mesh. The entire shortfall is one thing:

inbound  no_tls_reason=no_tls_from_remote  srv=all-unauthenticated  rate=2.408
inbound  no_tls_reason=no_tls_from_remote  srv=backend-http         rate=0.201

That is the kubelet running health probes. The kubelet is not a mesh member and can never present a mesh certificate, so it drags the ratio down permanently and makes a working mesh look broken. Scope the panel to pod-to-pod traffic and the same mesh reads 100%:

sum(rate(response_total{namespace="shop",direction="outbound",tls="true"}[5m]))
/
sum(rate(response_total{namespace="shop",direction="outbound"}[5m]))

Provision the dashboard as a ConfigMap labelled grafana_dashboard: "1" rather than clicking it together, so it survives the cluster:

apiVersion: v1
kind: ConfigMap
metadata:
  name: linkerd-mesh-dashboard
  namespace: monitoring
  labels:
    grafana_dashboard: '1'
data:
  linkerd-mesh.json: |
    { "title": "Linkerd mesh, golden metrics", ... }

Two panels earn their place beyond the usual rate and latency. One is the mTLS ratio above. The other is authorization denials by calling pod:

sum(rate(response_total{namespace="shop",status_code="403"}[5m])) by (pod)

Because 403s are classified as success, that traffic is invisible on every other panel. This is the one that tells you a policy is doing something, or that something has been quietly locked out since Tuesday.

Grafana dashboard with seven panels. mTLS share of pod-to-pod traffic reads 100 percent in green, throughput 7.09 requests per second, and 5xx 0.89 per second. A request rate panel shows backend-v1 at 2.08 and backend-v2 at 0.298 requests per second. A success rate panel shows both backend versions at 100 percent and the flaky service bottoming out at 36.4 percent. A latency panel shows p50, p95 and p99 all under one millisecond. An authorization denials panel lists two intruder pods.
The finished dashboard, against the app from this guide. The canary is visible as the gap between backend-v1 and backend-v2, the flaky service drags its own success rate down without touching the others, and the denial panel names the pods being refused.

One practical note: generate load with a Deployment, not a backgrounded kubectl exec loop. A process started in an exec session dies with the session, so your metrics stop the moment you look away.

Where to go next

You now have a mesh that shifts traffic on demand, refuses callers by identity, survives a failing dependency, and reports numbers you can defend in an incident review.

The next thing worth doing is deleting your control plane on purpose and watching what still works, because the answer is not what most people expect. That is Part 3 of this series: established connections keep flowing, and Linkerd is the one mesh of the three that admits a new pod with no proxy at all while the injector is down.