How to Install Istio Ambient Mode and Enable mTLS (Istio 1.31, 2026)

Oct 31, 2026 · 14 min · Ajay Kumar

This guide is for platform engineers who want Istio's sidecarless ambient mode running with mutual TLS they can prove, not just assume. By the end you will have Istio 1.31.3 installed in ambient mode, a namespace enrolled through ztunnel with no pod restarts, STRICT mTLS, an L4 AuthorizationPolicy enforced by ztunnel, an L7 policy enforced by a waypoint proxy, and a tested order of operations for moving existing sidecar workloads. Everything here follows the Istio 1.31 docs: Istio 1.31.3 (released 21 October 2026), the Gateway API v1.6.0 CRDs those docs pin, Kubernetes 1.32 to 1.37, and Helm 3.6 or later. You need kubectl, cluster-admin on a non-production cluster, and either istioctl or Helm. If you are still deciding whether you need a mesh at all, read the companion explainer on service meshes in 2026 first.

Step 1: Check prerequisites and create a test cluster

Istio 1.31 is supported on Kubernetes 1.32 to 1.37 until roughly February 2027. For a throwaway cluster, kind v0.33.0 with two workers is enough to see cross-node traffic:

# kind v0.33.0, Kubernetes 1.36.4 (inside Istio 1.31's supported range)
cat > kind-ambient.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ambient
nodes:
- role: control-plane
- role: worker
- role: worker
EOF

kind create cluster --config kind-ambient.yaml \
  --image kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed
kubectl get nodes

On a managed or opinionated cluster, read the platform prerequisites before installing, because ambient's CNI agent has to find the node's CNI config. The ones that bite most often:

  • GKE: add global.platform=gke (Helm) or values.global.platform=gke (istioctl). GKE also needs a ResourceQuota for system-node-critical pods; the ambient profile creates one in istio-system.
  • EKS with Security Groups for Pods: set POD_SECURITY_GROUP_ENFORCING_MODE=standard on the aws-node DaemonSet, or kubelet health probes fail.
  • k3d, k3s, minikube, MicroK8s: set global.platform to k3d, k3s, minikube or microk8s.
  • OpenShift: use --set profile=openshift-ambient with istioctl, and install istio-cni and ztunnel into kube-system.
  • Cilium as the CNI: set cni.exclusive=false and do not enable bpf.masquerade=true, which the Istio docs say breaks health checks in ambient mode.
# EKS only, and only if you use Security Groups for Pods (ENABLE_POD_ENI=true)
kubectl set env daemonset aws-node -n kube-system --list | grep ENABLE_POD_ENI
kubectl set env daemonset aws-node -n kube-system POD_SECURITY_GROUP_ENFORCING_MODE=standard

Step 2: Download istioctl 1.31.3 and install the Gateway API CRDs

Pin the version so the script does not pick whatever is newest on the day:

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.31.3 sh -
cd istio-1.31.3
export PATH=$PWD/bin:$PATH
istioctl version

Expected output before anything is installed:

Istio is not present in the cluster: no running Istio pods in namespace "istio-system"
client version: 1.31.3

Waypoints are Gateway API Gateway resources, so the CRDs must exist. The Istio 1.31 docs install the v1.6.0 experimental channel, which also carries TCPRoute and TLSRoute:

kubectl get crd gateways.gateway.networking.k8s.io &> /dev/null || \
  kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.0/experimental-install.yaml

# Optional: let istioctl inspect the cluster for install blockers
istioctl x precheck

Step 3: Install Istio in ambient mode with istioctl or Helm

Pick one method and stick with it for upgrades.

Option A, istioctl. The ambient profile installs istiod, the istio-cni node agent and the ztunnel DaemonSet (install docs):

istioctl install --set profile=ambient --skip-confirmation
# GKE: istioctl install --set profile=ambient --set values.global.platform=gke --skip-confirmation
✔ Istio core installed
✔ Istiod installed
✔ CNI installed
✔ Ztunnel installed
✔ Installation complete

Option B, Helm. Four charts, in this order (Helm docs). Note the repository URL: Istio 1.31 moved artifacts to blob.istio.io and is retiring the old Google Cloud bucket.

helm repo add istio https://blob.istio.io/istio-release/charts
helm repo update

helm install istio-base istio/base -n istio-system --create-namespace --version 1.31.3 --wait
helm install istiod istio/istiod -n istio-system --version 1.31.3 --set profile=ambient --wait
helm install istio-cni istio/cni -n istio-system --version 1.31.3 --set profile=ambient --wait
#   GKE: add --set global.platform=gke to the istio-cni install
helm install ztunnel istio/ztunnel -n istio-system --version 1.31.3 --wait
helm ls -n istio-system

If you render the charts with helm template and apply them server-side through Argo CD or Flux, the docs recommend setting base.validationFailurePolicy=Fail to avoid a field-manager conflict on the validating webhook.

Either way, check the data plane:

kubectl get pods -n istio-system
kubectl get daemonset -n istio-system
NAME                             READY   STATUS    RESTARTS   AGE
istio-cni-node-g97z5             1/1     Running   0          10m
istiod-5f4c75464f-gskxf          1/1     Running   0          10m
ztunnel-c2z4s                    1/1     Running   0          10m

You should see one istio-cni-node and one ztunnel pod per node (the output above is from a single-node cluster).

Step 4: Deploy a sample app and add the namespace to the ambient mesh

Use Bookinfo from the release directory so the policies later in this guide match the service accounts exactly:

kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml
kubectl apply -f samples/bookinfo/platform/kube/bookinfo-versions.yaml
kubectl apply -f samples/curl/curl.yaml

# Ingress through Gateway API; ClusterIP because we will port-forward
kubectl apply -f samples/bookinfo/gateway-api/bookinfo-gateway.yaml
kubectl annotate gateway bookinfo-gateway networking.istio.io/service-type=ClusterIP --namespace=default
kubectl get gateway

Wait for PROGRAMMED to show True. Now enrol the namespace. This is the whole of ambient onboarding:

kubectl label namespace default istio.io/dataplane-mode=ambient
kubectl get pods

Pods stay at 1/1 with no restart and no extra container; the add-workloads docs confirm that labelling is enough. To keep a single workload out, put istio.io/dataplane-mode: none on its pod template. Never put istio-injection=enabled and istio.io/dataplane-mode=ambient on the same namespace: if both apply, sidecar mode wins.

Step 5: Verify mTLS and turn on STRICT mode

Ambient encrypts enrolled-to-enrolled traffic as soon as the label lands, but "the docs say so" is not verification. Check four ways, following the verify mTLS guide.

First, ztunnel's view of the workloads. HBONE means the pod sends and accepts mTLS through ztunnel on port 15008 (output abbreviated):

istioctl ztunnel-config workloads
NAMESPACE    POD NAME                         IP           NODE             WAYPOINT PROTOCOL
default      details-v1-cf74bb974-5sqkp       10.244.1.5   ambient-worker   None     HBONE
default      productpage-v1-87d54dd59-fn6vw   10.244.1.10  ambient-worker   None     HBONE
default      curl-7656cf8794-r7zb9            10.244.1.12  ambient-worker   None     HBONE
istio-system ztunnel-6hvcw                    10.244.1.4   ambient-worker   None     TCP

Second, the ztunnel access logs, which name both SPIFFE identities (each log line is longer than shown here):

kubectl exec deploy/curl -- sh -c 'for i in $(seq 1 5); do curl -s -o /dev/null http://productpage:9080/; done'
kubectl -n istio-system logs -l app=ztunnel --tail=100 | grep -E "inbound|outbound"
info  access  connection complete src.identity="spiffe://cluster.local/ns/default/sa/curl"
  dst.addr=10.244.1.10:15008 dst.identity="spiffe://cluster.local/ns/default/sa/bookinfo-productpage" direction="outbound"

Third, metrics. With the Prometheus addon installed, istio_tcp_connections_opened_total should carry connection_security_policy="mutual_tls":

kubectl apply -f samples/addons/prometheus.yaml
istioctl dashboard prometheus
# Query: sum by (source_workload, destination_workload) (istio_tcp_connections_opened_total{connection_security_policy="mutual_tls"})

Fourth, the wire itself. Payloads on port 15008 should be unreadable:

POD=$(kubectl get pods -l app=details -o jsonpath="{.items[0].metadata.name}")
kubectl debug "$POD" -i --image=nicolaka/netshoot:v0.16 -- tcpdump -nAi eth0 port 9080 or port 15008

HBONE does not mean plaintext is rejected. The ambient default is PERMISSIVE, so a pod outside the mesh can still call in. Prove it, then close it:

kubectl create namespace outside
kubectl apply -n outside -f samples/curl/curl.yaml
kubectl exec -n outside deploy/curl -- curl -s -o /dev/null -w "%{http_code}\n" http://productpage.default:9080/productpage
# 200: plaintext from a non-mesh pod is accepted under PERMISSIVE
# mesh-wide-strict.yaml: in the root namespace, applies to the whole mesh
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: STRICT
kubectl apply -f mesh-wide-strict.yaml
kubectl exec -n outside deploy/curl -- curl -s -o /dev/null -w "%{http_code}\n" http://productpage.default:9080/productpage
# 000 and a non-zero exit: the destination ztunnel now refuses plaintext

DISABLE mode is ignored in ambient, per the L4 policy docs, because ztunnel and HBONE always imply mTLS. Kubelet health probes keep working under STRICT because ambient SNATs them to a link-local address that policy skips.

Istio ambient request path (Istio 1.31): where mTLS and policy happen Client pod curl, on node A plain TCP out ztunnel A L4 proxy, per node wraps in HBONE Waypoint Envoy, optional L7 policy, routes ztunnel B L4 AuthZ, STRICT unwraps HBONE Server pod productpage, node B plain TCP in redirect HBONE :15008 HBONE :15008 local No waypoint enrolled: ztunnel A sends straight to ztunnel B, L4 policy only 1. kubectl label namespace default istio.io/dataplane-mode=ambient: mTLS on, no pod restart 2. PeerAuthentication STRICT in istio-system: plaintext into meshed pods is refused 3. istioctl waypoint apply --enroll-namespace: L7 AuthorizationPolicy via targetRefs Verify: istioctl ztunnel-config workloads shows HBONE; ztunnel logs show spiffe:// identities
Traffic path and enforcement points as described in Istio's ambient data plane, waypoint and L4 policy documentation for Istio 1.31. Traffic always passes through ztunnel, even between pods on the same node.

Step 6: Enforce an L4 AuthorizationPolicy with ztunnel

ztunnel enforces L4 policy at the destination node. Allow only the Bookinfo gateway's service account to reach productpage:

# productpage-ztunnel.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: productpage-ztunnel
  namespace: default
spec:
  selector:
    matchLabels:
      app: productpage
  action: ALLOW
  rules:
  - from:
    - source:
        principals:
        - cluster.local/ns/default/sa/bookinfo-gateway-istio
kubectl apply -f productpage-ztunnel.yaml
istioctl ztunnel-config policies          # the policy should be listed
kubectl exec deploy/curl -- curl -s "http://productpage:9080/productpage"
command terminated with exit code 56

The browser path still works (kubectl port-forward svc/bookinfo-gateway-istio 8080:80, then http://localhost:8080/productpage). ztunnel only understands source principals, namespaces, IP blocks and ports. If you add an L7 field such as methods or paths to a selector-based policy, ztunnel cannot evaluate it and the policy fails safe to DENY. That is the single most common surprise in ambient, and it looks exactly like the exit code above.

Step 7: Deploy a waypoint proxy for L7 policy

Anything HTTP-aware (methods, paths, headers, retries, HTTPRoute splits, HTTP metrics) needs a waypoint. Create one for the namespace and enrol it in one command:

istioctl waypoint apply --enroll-namespace --wait
kubectl get gtw waypoint
✅ waypoint default/waypoint applied
✅ waypoint default/waypoint is ready!
✅ namespace default labeled with "istio.io/use-waypoint: waypoint"
NAME       CLASS            ADDRESS       PROGRAMMED   AGE
waypoint   istio-waypoint   10.96.58.95   True         42s

L7 policies attach to the waypoint with targetRefs, not selector. This one lets curl send only GET to productpage:

# productpage-waypoint.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: productpage-waypoint
  namespace: default
spec:
  targetRefs:
  - kind: Service
    group: ""
    name: productpage
  action: ALLOW
  rules:
  - from:
    - source:
        principals:
        - cluster.local/ns/default/sa/curl
    to:
    - operation:
        methods: ["GET"]

The waypoint terminates the client's connection and opens a new one, so the destination ztunnel now sees the waypoint's identity. Update the L4 policy to allow it, or every request is denied:

kubectl apply -f productpage-waypoint.yaml
kubectl patch authorizationpolicy productpage-ztunnel -n default --type=json -p='[
  {"op":"add","path":"/spec/rules/0/from/0/source/principals/-","value":"cluster.local/ns/default/sa/waypoint"}]'

kubectl exec deploy/curl -- curl -s "http://productpage:9080/productpage" -X DELETE   # RBAC: access denied
kubectl exec deploy/reviews-v1 -- curl -s http://productpage:9080/productpage         # RBAC: access denied
kubectl exec deploy/curl -- curl -s http://productpage:9080/productpage | grep -o "<title>.*</title>"
# <title>Simple Bookstore App</title>

A waypoint handles traffic addressed to Services by default (istio.io/waypoint-for: service). If clients call pod IPs directly, create it with istioctl waypoint apply --for workload or --for all. For a single Service instead of the whole namespace, run istioctl waypoint apply -n default --name reviews-svc-waypoint and label the Service with istio.io/use-waypoint=reviews-svc-waypoint.

Step 8: Migrate existing sidecar workloads to ambient

The migration guide is explicit that there is no zero-downtime path when L7 policies are involved, because selector-based sidecar policies must be removed before waypoint policies take over. Schedule a window per namespace. Order matters: get it wrong and, in the docs' words, traffic can end up handled by neither the sidecar nor ztunnel.

# 1. Audit: which AuthorizationPolicies use L7 rules? These must be rewritten with targetRefs.
kubectl get authorizationpolicy -A --no-headers | while read ns name rest; do
  if kubectl get authorizationpolicy "$name" -n "$ns" -o yaml | grep -qE "(methods:|paths:|headers:|action: CUSTOM|action: AUDIT)"; then
    echo "$ns/$name"
  fi
done
kubectl get peerauthentication -A -o yaml | grep -A2 "mtls:"   # mode: DISABLE must be removed first

# 2. Back up, and store the file outside the cluster
kubectl get virtualservice,destinationrule,authorizationpolicy,requestauthentication,peerauthentication,gateway,httproute,telemetry -A -o yaml > istio-config-backup.yaml

# 3. Add ambient components to the existing control plane (pick your install method)
istioctl upgrade --set profile=ambient
# Helm:
#   helm upgrade istio-base istio/base -n istio-system --version 1.31.3
#   helm upgrade istiod istio/istiod -n istio-system --version 1.31.3 --set profile=ambient
#   helm upgrade istio-cni istio/cni -n istio-system --version 1.31.3 --set profile=ambient
#   helm install ztunnel istio/ztunnel -n istio-system --version 1.31.3

# 4. Restart sidecar workloads so their proxies speak HBONE; deploy (do not activate) waypoints
kubectl rollout restart deployment -n shop
istioctl waypoint apply -n shop

Then, per namespace, during the window:

NS=shop
kubectl label namespace "$NS" istio.io/use-waypoint=waypoint      # only if you need L7 features
kubectl label namespace "$NS" istio.io/dataplane-mode=ambient
istioctl ztunnel-config workloads | grep "$NS"                    # confirm before going further
kubectl label namespace "$NS" istio-injection-                    # or: istio.io/rev- for revision labels
kubectl rollout restart deployment -n "$NS"
kubectl rollout status deployment -n "$NS"
kubectl delete authorizationpolicy <selector-based-l7-policy> -n "$NS"   # immediately after the restart
kubectl get pods -n "$NS"                                          # 1/1, no istio-proxy

Rollback reverses the labels (istio-injection=enabled, istio.io/dataplane-mode-, istio.io/use-waypoint-), restarts the deployments so sidecars return, and re-applies the backup. Two dashboard changes catch people afterwards: waypoint metrics carry reporter="waypoint", and istioctl proxy-status no longer shows ambient pods, so use istioctl ztunnel-config workloads.

Troubleshooting

Start every investigation with istioctl ztunnel-config workloads on the source and destination, then the ztunnel logs on both nodes, as the ztunnel troubleshooting guide suggests. For waypoint issues, istioctl analyze, istioctl ztunnel-config service (check the WAYPOINT column) and kubectl logs deploy/waypoint cover most cases.

Symptom Likely cause Fix
Pods show TCP instead of HBONE in ztunnel-config workloads Namespace not labelled, pod opted out with istio.io/dataplane-mode=none, or pod still has a sidecar (sidecar mode wins) kubectl get ns -L istio.io/dataplane-mode; remove istio-injection and restart; look for the sidecar.istio.io/status annotation
Readiness or liveness probes fail right after enrolment NetworkPolicy blocks SNAT-ed kubelet probes from 169.254.7.127; EKS pod security groups in strict mode; Cilium bpf.masquerade=true Allow ipBlock: 169.254.7.127/32 (and the IPv6 equivalent); set POD_SECURITY_GROUP_ENFORCING_MODE=standard; disable BPF masquerade
Meshed pods time out to each other but worked before NetworkPolicy allows only app ports, while HBONE arrives on TCP 15008 Add TCP 15008 to the ingress rules of every meshed pod
exit code 56 after adding methods, paths or headers L7 attribute in a selector-based policy that ztunnel enforces; it fails closed to DENY Move the L7 rules to a policy with targetRefs and deploy a waypoint
L7 policy or HTTPRoute has no effect No waypoint, missing istio.io/use-waypoint label, or clients call pod IPs while the waypoint is waypoint-for: service Check WAYPOINT in istioctl ztunnel-config service; label the namespace or Service; use --for workload or --for all
RBAC: access denied for everything after adding a waypoint Destination L4 policy allows the original clients, but traffic now arrives with the waypoint's identity Add cluster.local/ns/<ns>/sa/waypoint to the ztunnel policy principals
istioctl waypoint apply fails or the waypoint never becomes Programmed Gateway API CRDs missing Install the v1.6.0 CRDs from Step 2; kubectl get gtw waypoint; istioctl analyze
istio-cni-node or ztunnel pods stay Pending on GKE system-node-critical pods need a ResourceQuota outside istio-system; non-standard CNI paths Install with global.platform=gke; create the quota in any non-default install namespace
helm repo update or image pulls fail on 17 Nov or 8 to 9 Dec 2026 Still using retired GCP-hosted artifacts during Istio's scheduled scream tests Use https://blob.istio.io/istio-release/charts and Docker Hub images
Repeated IPv6 network is unreachable warnings on an IPv4-only cluster Auto-allocated services get both IPv4 and IPv6 VIPs Harmless; or helm upgrade ztunnel istio/ztunnel -n istio-system --version 1.31.3 --reuse-values --set-string env.IPV6_ENABLED=false

For the NetworkPolicy rows, my NetworkPolicy guide has the default-deny patterns to adapt; just add port 15008 and the probe CIDR.

FAQ

Do I need to restart pods to enable Istio ambient mode?

No. Labelling a namespace or pod with istio.io/dataplane-mode=ambient enrols running pods through the CNI agent without a restart. You only restart when migrating from sidecars, because removing the injection label does not remove sidecars that are already running.

Does Istio ambient mode encrypt traffic by default?

Yes, between enrolled workloads: ztunnel wraps traffic in HBONE, which is mutual TLS on port 15008, using SPIFFE identities from each pod's service account. The default PERMISSIVE mode still accepts plaintext from pods outside the mesh, so apply a STRICT PeerAuthentication if you want that refused.

When do I need a waypoint proxy?

When you need anything above L4: HTTP-method or path-based authorization, header routing, retries, timeouts, traffic splitting with HTTPRoute, or HTTP metrics and traces. Identity-based allow and deny by service account, namespace, IP or port works with ztunnel alone.

Can sidecar and ambient workloads talk to each other?

Yes. With the ambient profile, sidecars get ISTIO_META_ENABLE_HBONE=true and speak HBONE to ambient destinations, and STRICT mode accepts them. The catch is that traffic from a sidecar client bypasses waypoints, so waypoint L7 policies are not enforced for it during a migration.

When something breaks in ambient, I check three things before reading any Envoy config: does istioctl ztunnel-config workloads show HBONE for both ends, is a NetworkPolicy dropping port 15008 or the 169.254.7.127 probe address, and does any selector-based policy contain an L7 field that silently turned it into a deny. In production I install with Helm pinned to an exact chart version, roll STRICT out namespace by namespace with the outside-namespace curl test as a gate, and add waypoints only where a team can name the L7 feature it needs. The official references are the ambient getting started guide, the Helm install page, the waypoint guide, the L4 policy reference and the migration section.

Related: How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026), How to Migrate From ingress-nginx to Gateway API: Step-by-Step (2026) and How to Troubleshoot Kubernetes DNS and CoreDNS: Step by Step (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related