How to Install Istio Ambient Mode and Enable mTLS (Istio 1.31, 2026)
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) orvalues.global.platform=gke(istioctl). GKE also needs aResourceQuotaforsystem-node-criticalpods; the ambient profile creates one inistio-system. - EKS with Security Groups for Pods: set
POD_SECURITY_GROUP_ENFORCING_MODE=standardon theaws-nodeDaemonSet, or kubelet health probes fail. - k3d, k3s, minikube, MicroK8s: set
global.platformtok3d,k3s,minikubeormicrok8s. - OpenShift: use
--set profile=openshift-ambientwith istioctl, and installistio-cniandztunnelintokube-system. - Cilium as the CNI: set
cni.exclusive=falseand do not enablebpf.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.
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
How to Detect Runtime Threats with Falco on Kubernetes (2026)
Step-by-step Falco 0.45 setup on Kubernetes: Helm chart 9.2.0 with the modern eBPF driver, default and custom rules, Falcosidekick alerts to Slack, tuning noise, Falco Talon response actions, drop metrics, and a troubleshooting table for driver errors.
15 minOct 31, 2026How to Encrypt Kubernetes Secrets in Git with SOPS and age (2026)
Step-by-step SOPS 3.13.3 and age 1.3.2 setup: generate age keys, write .sops.yaml creation rules, encrypt and edit Secrets, decrypt with Flux kustomize-controller or Argo CD via KSOPS, rotate keys safely, and fix MAC mismatch and no identity matched errors.
11 minOct 31, 2026How to Fix Kubernetes RBAC Forbidden: User Cannot List Resource (2026)
Fix the Kubernetes error 'Error from server (Forbidden): User cannot list resource' step by step: parse the message, check identity with kubectl auth whoami and can-i --as, write a least-privilege Role and RoleBinding, fix service account tokens, EKS access entries, GKE IAM and AKS.
13 min