Skip to content

Linkerd How-to Guides

Scope

Task recipes for running open-source Linkerd from edge releases (commands checked against the 2-edge docs for Linkerd 2.20, 2026-09). Distributions such as Buoyant Enterprise for Linkerd (BEL) have their own install and upgrade instructions. For defaults, ports and CRD versions, see the Reference. For background, see the Explanation.

Install the CLI

# Latest edge CLI (pin a version with LINKERD2_VERSION=edge-26.9.3)
curl --proto '=https' --tlsv1.2 -sSfL https://run.linkerd.io/install-edge | sh
export PATH=$HOME/.linkerd2/bin:$PATH

linkerd version --client

Check the Kubernetes compatibility table first. Linkerd 2.20 needs Kubernetes 1.31 or later.

Install the Gateway API CRDs

Since 2.19, Linkerd no longer installs the Gateway API types by default. Check what is already there, then install a version in Linkerd's supported range (1.2.1 to 1.5.1 for 2.20).

Gateway API version skew

Gateway API CRDs are cluster-scoped, so every controller in the cluster shares one version. Envoy Gateway v1.9 bundles Gateway API v1.6.1, outside Linkerd 2.20's documented range. If both run in one cluster, install the CRDs once, deliberately, and check each project's compatibility table before upgrading either. See the Envoy Gateway topic.

# Prints the bundle version, or NotFound if the Gateway API is absent
kubectl get crds/httproutes.gateway.networking.k8s.io \
  -o "jsonpath={.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}"

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml

Warning

An incompatible Gateway API version on the cluster causes hard-to-debug Linkerd problems. When you upgrade from Gateway API 1.1.1 to 1.2.0 or later and use GRPCRoute, read the Gateway API 1.2.0 release notes first.

Install the Control Plane with the CLI

This is the quickest path, suitable for evaluation. The CLI generates a trust anchor that is valid for one year.

linkerd check --pre                           # validate the cluster
linkerd install --crds | kubectl apply -f -   # Linkerd CRDs first
linkerd install | kubectl apply -f -          # control plane
linkerd check                                 # wait for healthy

For production, add --ha (see Enable High Availability) and bring your own trust anchor (next section).

Install with Helm and Your Own Trust Anchor

  1. Generate a long-lived trust anchor and an issuer with the step CLI:

    # Root (trust anchor). Example: 10 years instead of the default 1 year.
    step certificate create root.linkerd.cluster.local ca.crt ca.key \
      --profile root-ca --no-password --insecure --not-after=87600h
    
    # Issuer (intermediate CA), 1 year
    step certificate create identity.linkerd.cluster.local issuer.crt issuer.key \
      --profile intermediate-ca --not-after 8760h --no-password --insecure \
      --ca ca.crt --ca-key ca.key
    
  2. Install the charts from the edge Helm repo:

    helm repo add linkerd-edge https://helm.linkerd.io/edge
    helm repo update
    
    helm install linkerd-crds linkerd-edge/linkerd-crds \
      -n linkerd --create-namespace
    
    helm install linkerd-control-plane -n linkerd \
      --set-file identityTrustAnchorsPEM=ca.crt \
      --set-file identity.issuer.tls.crtPEM=issuer.crt \
      --set-file identity.issuer.tls.keyPEM=issuer.key \
      linkerd-edge/linkerd-control-plane
    

    Add --set cniEnabled=true to both commands if you use the CNI plugin. For HA, add -f values-ha.yaml from the fetched chart (helm fetch --untar linkerd-edge/linkerd-control-plane).

Keep ca.key offline. Every cluster you plan to link for multicluster must use the same trust anchor.

Automate Issuer Rotation with cert-manager

This follows the official "automatically rotating control plane TLS credentials" guide. cert-manager issues the trust anchor and a short-lived issuer. trust-manager publishes the trust bundle ConfigMap that Linkerd reads.

kubectl create namespace linkerd
kubectl label namespace linkerd linkerd.io/is-control-plane=true

helm repo add jetstack https://charts.jetstack.io --force-update
helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --set crds.enabled=true
helm install trust-manager jetstack/trust-manager -n cert-manager --set app.trust.namespace=cert-manager --wait
# 1. Self-signed root issuer and the trust anchor certificate (cert-manager namespace)
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: linkerd-trust-root-issuer
  namespace: cert-manager
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: linkerd-trust-anchor
  namespace: cert-manager
spec:
  issuerRef:
    kind: Issuer
    name: linkerd-trust-root-issuer
  secretName: linkerd-trust-anchor
  isCA: true
  commonName: root.linkerd.cluster.local
  duration: 8760h0m0s     # 1 year
  renewBefore: 7320h0m0s  # renew ~2 months before expiry
  privateKey:
    rotationPolicy: Always
    algorithm: ECDSA
---
# 2. ClusterIssuer backed by the trust anchor, and the short-lived Linkerd issuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: linkerd-identity-issuer
spec:
  ca:
    secretName: linkerd-trust-anchor
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: linkerd-identity-issuer
  namespace: linkerd
spec:
  issuerRef:
    name: linkerd-identity-issuer
    kind: ClusterIssuer
  secretName: linkerd-identity-issuer
  isCA: true
  commonName: identity.linkerd.cluster.local
  duration: 48h0m0s
  renewBefore: 25h0m0s
  privateKey:
    rotationPolicy: Always
    algorithm: ECDSA
---
# 3. trust-manager Bundle writes the linkerd-identity-trust-roots ConfigMap
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: linkerd-identity-trust-roots
spec:
  sources:
    - secret:
        name: "linkerd-trust-anchor"
        key: "tls.crt"
    - secret:                       # previous anchor, kept during a rotation
        name: "linkerd-previous-anchor"
        key: "tls.crt"
  target:
    configMap:
      key: "ca-bundle.crt"
    namespaceSelector:
      matchLabels:
        linkerd.io/is-control-plane: "true"

The official guide creates linkerd-previous-anchor as a copy of the current anchor before it applies the Bundle. Then install Linkerd so that it uses the external issuer:

linkerd install --crds | kubectl apply -f -
linkerd install \
  --set identity.externalCA=true \
  --set identity.issuer.scheme=kubernetes.io/tls \
  | kubectl apply -f -

Note

cert-manager rotates the issuer every ~2 days in this setup with no proxy restarts. Rotating the trust anchor still needs a staged process: bundle the old and new anchors, restart the control plane, restart workloads, then drop the old anchor. BEL 2.20 automates trust anchor rotation.

Enable High Availability

linkerd install --ha | kubectl apply -f -                           # new install
linkerd upgrade --ha | kubectl apply -f -                           # existing install
linkerd install --ha --controller-replicas=2 | kubectl apply -f -   # override replicas
linkerd viz install --ha | kubectl apply -f -                       # viz extension

HA mode runs 3 replicas of critical components, sets resource requests and anti-affinity, and makes the injector webhook fail closed. With a fail-closed injector, annotated pods cannot start while the injector is down. Always leave kube-system unannotated.

Use the CNI Plugin Instead of linkerd-init

Use this where pods may not have CAP_NET_ADMIN:

linkerd install-cni | kubectl apply -f -
linkerd install --crds | kubectl apply -f -
linkerd install --linkerd-cni-enabled | kubectl apply -f -

On Cilium, install Cilium with cni.exclusive=false so that chained plugins can write their configuration. Also set socketLB.hostNamespaceOnly=true so that Linkerd sees ClusterIPs.

Mesh Workloads

# Annotate a namespace, then restart workloads so the injector sees them
kubectl annotate namespace myapp linkerd.io/inject=enabled
kubectl -n myapp rollout restart deploy

# Or add the annotation to specific manifests
kubectl get deploy -n myapp -o yaml | linkerd inject - | kubectl apply -f -

# Verify proxies are present and healthy
linkerd check --proxy -n myapp

Since 2.20 the proxy is injected as a native sidecar. To opt a workload out (for example on a platform with sidecar quirks), set the following annotation on the namespace or pod template, or set proxy.nativeSidecar=false globally:

metadata:
  annotations:
    config.linkerd.io/proxy-enable-native-sidecar: "false"

For an ingress controller that cannot target Service IPs, use linkerd.io/inject: ingress (linkerd inject --ingress). Make the ingress strip any client-supplied l5d-dst-override header, or it becomes an open relay.

Declare Protocols and Opaque Ports

Avoid protocol-detection surprises (Linkerd 2.18+):

apiVersion: v1
kind: Service
metadata:
  name: backend
spec:
  ports:
    - name: http
      port: 8090
      targetPort: 8090
      protocol: TCP
      appProtocol: http   # skip detection, treat as HTTP

For server-speaks-first protocols that are not in the default list, mark the ports opaque. The value replaces the default list.

kubectl annotate svc galera -n db config.linkerd.io/opaque-ports=3306,4444,4567,4568

Split Traffic for a Canary

Use a Gateway API HTTPRoute whose parent is the Service (GAMMA pattern). This replaces SMI TrafficSplit.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myapp-canary
  namespace: myapp
spec:
  parentRefs:
    - name: myapp          # the Service clients call
      kind: Service
      group: core          # Linkerd docs use "core"; "" is the Gateway API spelling
      port: 8080
  rules:
    - backendRefs:
        - name: myapp-v1
          port: 8080
          weight: 90
        - name: myapp-v2
          port: 8080
          weight: 10

Shift weights step by step and watch linkerd viz stat-outbound between steps. Progressive delivery tools such as Flagger or Argo Rollouts can automate the steps through the Gateway API.

Configure Retries and Timeouts

Retries and timeouts are annotations on an HTTPRoute, a GRPCRoute, or a whole Service (2.16+). They run on the client proxy, so the caller must be meshed.

kubectl -n booksapp annotate httproutes.gateway.networking.k8s.io/books-create \
  retry.linkerd.io/http=5xx retry.linkerd.io/limit=3 retry.linkerd.io/timeout=300ms

kubectl -n booksapp annotate httproutes.gateway.networking.k8s.io/books-create \
  timeout.linkerd.io/request=15s

linkerd viz -n booksapp stat-outbound deploy/webapp   # shows RETRIES and TIMEOUTS columns

Only retry idempotent requests. If a ServiceProfile exists for the Service, these annotations are ignored. Delete the profile first.

Enable Circuit Breaking

kubectl annotate svc/backend -n myapp \
  balancer.linkerd.io/failure-accrual=consecutive \
  balancer.linkerd.io/failure-accrual-consecutive-max-failures=5 \
  balancer.linkerd.io/failure-accrual-consecutive-min-penalty=5s \
  balancer.linkerd.io/failure-accrual-consecutive-max-penalty=1m

The outbound_http_balancer_endpoints metric reports endpoints marked "pending", which includes those with tripped breakers.

Handle Rate-Limited Backends

These are experimental 2.20 features. Apply them to the Service that returns 429 or RESOURCE_EXHAUSTED:

# Load Biaser: treat 429s as slow so EWMA steers away
kubectl annotate svc/quota-api -n myapp \
  balancer.alpha.linkerd.io/penalize-failures=true \
  balancer.alpha.linkerd.io/load-biaser-penalty=5s

# Unified circuit breaker: trip on success rate (429 counts as failure) or consecutive failures
kubectl annotate svc/quota-api -n myapp \
  balancer.linkerd.io/failure-accrual=unified \
  balancer.alpha.linkerd.io/failure-accrual-success-rate-threshold=0.8 \
  balancer.alpha.linkerd.io/failure-accrual-success-rate-window=10s

Restrict Access with Authorization Policy

Start in audit mode, watch the logs and metrics, then switch to deny.

apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata:
  name: backend-http
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  port: http               # named container port
  proxyProtocol: HTTP/2
  accessPolicy: audit      # change to deny once vetted
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: frontend-only
  namespace: production
spec:
  identities:
    - "frontend.production.serviceaccount.identity.linkerd.cluster.local"
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: backend-from-frontend
  namespace: production
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: backend-http
  requiredAuthenticationRefs:
    - name: frontend-only
      kind: MeshTLSAuthentication
      group: policy.linkerd.io
# Require mTLS for everything in a namespace (applies to pods created after this)
kubectl annotate ns production config.linkerd.io/default-inbound-policy=all-authenticated

# Find audit hits: proxy logs carry authz.name=audit and metrics carry authz_name="audit"
kubectl -n production logs deploy/backend -c linkerd-proxy | grep 'authz.name=audit'
linkerd viz authz -n production deploy/backend

Probes and routes

Once you attach any HTTPRoute to a Server, Linkerd stops authorizing kubelet probes automatically. Add a route and policy (for example a NetworkAuthentication for the node CIDR) that cover /ready and /live.

Legacy ServerAuthorization example (pre-2.12 style)

apiVersion: policy.linkerd.io/v1beta1
kind: ServerAuthorization
metadata:
  name: backend-authz
  namespace: production
spec:
  server:
    name: backend-http
  client:
    meshTLS:
      identities:
        - "frontend.production.serviceaccount.identity.linkerd.cluster.local"
Prefer AuthorizationPolicy. It can also target routes and namespaces.

Rate-Limit Inbound Traffic

apiVersion: policy.linkerd.io/v1alpha1
kind: HTTPLocalRateLimitPolicy
metadata:
  name: web-rlpolicy
  namespace: emojivoto
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: web-http
  total:
    requestsPerSecond: 100   # per pod (local), GCRA with 1s tolerance

Per-client fairness and per-identity overrides are also available. See the HTTPLocalRateLimitPolicy reference in the Linkerd docs.

Control Egress Traffic

  1. Observe first. Create an EgressNetwork that allows everything, and enable hostname labels on the client:

    apiVersion: policy.linkerd.io/v1alpha1
    kind: EgressNetwork
    metadata:
      name: all-egress-traffic
      namespace: egress-test
    spec:
      trafficPolicy: Allow
    
    kubectl annotate ns egress-test config.linkerd.io/proxy-metrics-hostname-labels=true
    linkerd diagnostics proxy-metrics -n egress-test po/client \
      | grep outbound_http_route_request_statuses_total
    
  2. Lock down, then allowlist. Switch to Deny and attach Gateway API routes to the EgressNetwork:

    kubectl patch egressnetwork -n egress-test all-egress-traffic \
      -p '{"spec":{"trafficPolicy": "Deny"}}' --type=merge
    
    apiVersion: gateway.networking.k8s.io/v1
    kind: HTTPRoute
    metadata:
      name: httpbin-get
      namespace: egress-test
    spec:
      parentRefs:
        - name: all-egress-traffic
          kind: EgressNetwork
          group: policy.linkerd.io
          namespace: egress-test
          port: 80
      rules:
        - matches:
            - path:
                value: "/get"
    

    HTTPS traffic needs a TLSRoute (SNI match) to be allowed. Denied HTTP requests get 403.

Observe Traffic with Viz

linkerd viz install | kubectl apply -f -
linkerd viz check
linkerd viz dashboard &

linkerd viz stat deploy -n myapp                     # golden metrics per workload
linkerd viz stat-inbound deploy/web -n myapp         # server-side, by route
linkerd viz stat-outbound deploy/web -n myapp        # client-side, by route and backend
linkerd viz top deploy/web -n myapp                  # live top paths
linkerd viz tap deploy/web -n myapp                  # live request stream
linkerd viz edges deploy -n myapp                    # who talks to whom, with mTLS identity

linkerd viz routes reports per-route metrics only for ServiceProfile routes. For Gateway API routes, use stat-inbound and stat-outbound. The old top-level linkerd stat, linkerd top and linkerd edges commands moved under linkerd viz in 2.10.

Verify mTLS

linkerd viz edges deploy -n myapp          # SECURED column shows mTLS per edge
linkerd identity -n myapp <pod-name>       # print the pod's proxy certificate
linkerd viz tap deploy/web -n myapp | grep 'tls=true'
linkerd check --proxy                      # includes certificate validity checks

These steps are for the hierarchical (gateway) mode. Both clusters must share a trust anchor.

# 1. Install the extension in both clusters
for ctx in west east; do
  linkerd --context=${ctx} multicluster install | kubectl --context=${ctx} apply -f -
done

# 2. Tell west to run a controller for a link named "east"
cat <<EOF > values.yaml
controllers:
- link:
    ref:
      name: east
EOF
linkerd --context=west multicluster install -f values.yaml | kubectl --context=west apply -f -

# 3. Generate the Link resource and credentials from east, apply them in west (GitOps-friendly)
linkerd --context=east multicluster link-gen --cluster-name east | kubectl --context=west apply -f -
linkerd --context=west multicluster check
linkerd --context=west multicluster gateways

# 4. Export a service from east; it appears in west as podinfo-east
kubectl --context=east -n test label svc/podinfo mirror.linkerd.io/exported=true

linkerd multicluster link-gen (2.18+) replaces the older linkerd multicluster link workflow in current docs. Commit its output to Git instead of piping it straight to kubectl.

Federate a Service Across Clusters

Federated services use flat networking (pod-to-pod reachability), not gateways.

for ctx in west east north; do
  linkerd --context ${ctx} multicluster install --gateway=false | kubectl --context ${ctx} apply -f -
done
linkerd --context east  multicluster link-gen --cluster-name=east  --gateway=false | kubectl --context west apply -f -
linkerd --context north multicluster link-gen --cluster-name=north --gateway=false | kubectl --context west apply -f -

# Mark each cluster's copy as a federation member
for ctx in west east north; do
  kubectl --context ${ctx} -n mc-demo label svc/bb mirror.linkerd.io/federated=member
done

kubectl --context west -n mc-demo get svc bb-federated
linkerd --context west diagnostics endpoints bb-federated.mc-demo.svc.cluster.local:8080

Upgrade Linkerd

The order is CLI, then CRDs and control plane, then extensions, then data plane. Keep the data plane within one major version of the control plane.

linkerd check && linkerd check --proxy            # start healthy

curl --proto '=https' --tlsv1.2 -sSfL https://run.linkerd.io/install-edge | sh
linkerd version --client

linkerd upgrade --crds | kubectl apply -f -
linkerd upgrade | kubectl apply -f -
linkerd prune | kubectl delete -f -               # remove resources dropped in the new version
linkerd check

linkerd viz install | kubectl apply -f -          # extensions: re-run install
linkerd viz prune | kubectl delete -f -
linkerd multicluster install | kubectl apply -f -

kubectl -n myapp rollout restart deploy           # data plane picks up the new proxy
linkerd check --proxy

With Helm, run helm upgrade linkerd-crds linkerd-edge/linkerd-crds and then helm upgrade linkerd-control-plane linkerd-edge/linkerd-control-plane --reset-values -f values.yaml --atomic. Always read the edge release notes: edge releases are not semantically versioned and may carry breaking changes.

Use ServiceProfiles (Legacy)

ServiceProfiles still work but receive no new features. They also disable HTTPRoute retries and circuit breaking for their Service. Keep this only for existing setups.

apiVersion: linkerd.io/v1alpha2
kind: ServiceProfile
metadata:
  name: backend.myapp.svc.cluster.local
  namespace: myapp
spec:
  routes:
    - name: GET /api/health
      condition:
        method: GET
        pathRegex: /api/health
      isRetryable: true
    - name: POST /api/orders
      condition:
        method: POST
        pathRegex: /api/orders
      timeout: 30s
  retryBudget:
    retryRatio: 0.2
    minRetriesPerSecond: 10
    ttl: 10s

Troubleshooting

Symptom Diagnose Likely fix
Pods not injected linkerd check --proxy, kubectl get pod -o yaml (look for linkerd-proxy) Annotate the namespace or workload and restart the pods. Check that the injector is running. HA mode blocks scheduling when it is down.
linkerd check warns about certificates linkerd check identity section Rotate the issuer (or automate it with cert-manager). Plan trust anchor rotation well before expiry.
Meshed calls fail after cert expiry Proxy logs show TLS or identity errors Replace the expired issuer or anchor with linkerd upgrade --identity-trust-anchors-file=... or with Helm values, then restart workloads.
HTTP features missing (no route metrics, retries) on a port linkerd diagnostics proxy-metrics, protocol detection metrics Set appProtocol on the Service port, or fix opaque ports. Remove any ServiceProfile that blocks the new features.
Server-speaks-first protocol hangs about 10 s on connect Connection timing Add the port to config.linkerd.io/opaque-ports
No per-request balancing or routes with Cilium linkerd viz stat-outbound shows no routes Set Cilium socketLB.hostNamespaceOnly=true
Pods fail readiness after adding an HTTPRoute to a Server Proxy logs show 403 on probe paths Explicitly authorize the probe routes
Job pods never complete Pod shows proxy still running Use native sidecars (default in 2.20). On older versions, enable them with the annotation.
Gateway API conflicts with another project CRD bundle-version annotation Pin one Gateway API version within Linkerd's supported range. Do not let two charts install it.
High proxy latency or memory linkerd viz top, pod metrics Set proxy resource annotations. Check destination controller memory on high-churn clusters (improved in 2.20).

Sources