Istio Ambient Mesh on EKS: A Complete Hands-On Guide
What is ambient mesh ?
Istio ambient mesh gives you mTLS, workload identity, and L4 authorization. In ambient mode we run a per node L4 proxy (ztunnel). ztunnel is responsible for securing connection on specific nodes.
More about side car vs ambient
What are going to do ?
In this post we are configuring ambient mesh on Amazon EKS declaratively using kustomize.
- An EKS Cluster created using
eksctl - Gateway API CRDs, and Istio CRDs installation
- Install istio components (istiod, ztunnel)
- Google’s public online boutique demo installation
- An istio ingress gateway behind an AWS NLB
- Strict mTLS and L4 authorization policies enforced by ztunnel
- Waypoint proxy (L7 authorization) is out of scope for this article.
Following version are used in demonstration
All the manifest used are in repo
How ambient mode works ?
Ambient mode moves data plane out of your pods and onto each nodes. A daemon set component called ztunnel is deployed along with other components, which handles the transparent traffic interception and mTLS encryption.
Traffic between meshed pods travel through ztunnel, over an mTLS tunnel called HBONE (HTTP Connection over HTTP/2) on port 15008. ztunnel checks the caller’s identity against the policy before the connection ever reaches the destination workload.
Following components are configured while deploying ambient mesh
- CRDs — istio-base, and Gateway API CRDs.
- istiod — Runs as a deployment, and is responsible for configuration, certificates (SPIFFE Indentities), and xDS.
- istio-cni — Runs as a DaemonSet, and is responsible for detecting pods joining mesh and setting up traffic redirection inside the pods’s network namespace.
- ztunnel — Runs as a DaemonSet, and is responsble for mTLS, L4 authorization, and L4 telemetry.
Step 1: Creating the EKS Cluster
A three node managed node group cluster is created using eksctl . We will be using a default Amazon VPC CNI.
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ambient-demo
region: eu-west-1
version: "1.36"
tags:
project: ambient-demo
# 3 AZs -> 3 public + 3 private subnets
availabilityZones: [eu-west-1a, eu-west-1b, eu-west-1c]
vpc:
cidr: 10.20.0.0/16
nat:
gateway: Single # Single | HighlyAvailable
clusterEndpoints:
publicAccess: true # Only do this for demo. Actual production should block public access.
privateAccess: true # nodes talk to the API server inside the VPC
publicAccessCIDRs: # ONLY these IPs can reach the public API endpoint
- "27.34.65.32/32"
# - "REPLACE_WITH_IP_2/32"
iam:
withOIDC: true
accessConfig:
authenticationMode: API
bootstrapClusterCreatorAdminPermissions: true
addons:
- name: vpc-cni
version: latest
configurationValues: |-
env:
ENABLE_PREFIX_DELEGATION: "true"
WARM_PREFIX_TARGET: "1"
POD_SECURITY_GROUP_ENFORCING_MODE: "standard"
- name: coredns
version: latest
- name: kube-proxy
version: latest
- name: eks-pod-identity-agent
version: latest
managedNodeGroups:
- name: ng-default
instanceType: t3.medium
amiFamily: Bottlerocket
desiredCapacity: 3
minSize: 3
maxSize: 5
volumeSize: 40 # Bottlerocket data volume
privateNetworking: true # nodes in private subnets only
labels:
role: general
bottlerocket:
settings:
kubernetes:
max-pods: 110 # pairs with prefix delegationWe can create cluster using following command
eksctl create cluster -f cluster/cluster.yamlPrefix delegation on, with max-pods: 110, and POD_SECURITY_GROUP_ENFORCING_MODE: standard for Istio ambient.
This is set because when we set this to strict mode, kubelet health probes fail because istio SNATs them from link-local address.
kubectl get ds aws-node -n kube-system \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="aws-node")].env[?(@.name=="POD_SECURITY_GROUP_ENFORCING_MODE")].value}'; echoStep 2: Install the CRDs
In this step we will be installing CRDs
- Kubernetes Gateway API
- Istio CRDs
00-crds/gateway-api/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.2/standard-install.yaml00-crds/istio-base/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: istio-system00-crds/istio-base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
helmCharts:
- name: base
repo: https://blob.istio.io/istio-release/charts
version: 1.31.1
releaseName: istio-base
namespace: istio-system
kubeVersion: "1.36.0"
includeCRDs: true
valuesInline:
defaultRevision: default
base:
# Required when applying rendered charts with server-side apply,
# otherwise the chart and istiod fight over failurePolicy.
validationFailurePolicy: FailApply
kustomize build 00-crds/gateway-api | kubectl apply --server-side -f -
kustomize build --enable-helm 00-crds/istio-base | kubectl apply --server-side -f -Step 3: Install istiod, istio-cni, and ztunnel
We will be deploying all three charts in one kustomization, each with its own values files. The profile: ambient values is the key here.
10-istio/values-istiod.yaml
profile: ambient
global:
hub: docker.io/istio
defaultPodDisruptionBudget:
enabled: false # single replica — avoid PDB blocking EKS node drains/AZ rebalancing
base:
validationFailurePolicy: Fail
meshConfig:
accessLogFile: /dev/stdout
pilot:
autoscaleEnabled: false # single demo replica by design — no HA/autoscaling
replicaCount: 1
resources:
requests:
cpu: 200m
memory: 512Mi
limits: # chart default request alone is 500m/2Gi; cap since we've downsized
cpu: 1000m
memory: 1Gi10-istio/values-cni.yaml
profile: ambient
global:
hub: docker.io/istio
ambient:
dnsCapture: true # already the chart default once ambient is on
resources:
requests:
cpu: 100m
memory: 100Mi
limits: # chart ships requests only — bound worst case on t3.medium nodes
cpu: 200m
memory: 200Mi
terminationGracePeriodSeconds: 60 # headroom for CNI cleanup during rollouts (upstream chart comment)10-istio/values-ztunnel.yaml
profile: ambient
global:
hub: docker.io/istio
resources:
requests:
cpu: 200m
memory: 512Mi
limits: # ztunnel owns all L4 data-plane traffic per node — cap explicitly
cpu: 500m
memory: 1Gi
logLevel: info 10-istio/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: istiod
repo: https://blob.istio.io/istio-release/charts
version: 1.31.1
releaseName: istiod
namespace: istio-system
kubeVersion: "1.36.0"
valuesFile: values-istiod.yaml
- name: cni
repo: https://blob.istio.io/istio-release/charts
version: 1.31.1
releaseName: istio-cni
namespace: istio-system
kubeVersion: "1.36.0"
valuesFile: values-cni.yaml
- name: ztunnel
repo: https://blob.istio.io/istio-release/charts
version: 1.31.1
releaseName: ztunnel
namespace: istio-system
kubeVersion: "1.36.0"
valuesFile: values-ztunnel.yamlApply Manifest
kustomize build --enable-helm 10-istio | kubectl apply --server-side -f -Step 4: Deploy Online Boutique
Online Boutique is a cloud-first microservices demo application. The application is a web-based e-commerce app where users can browse items, add them to the cart, and purchase them.
Every service runs under its own Kubernetes ServiceAccount, which gives each one a distinct SPIFFE identity in the mesh. We rely on that in the policy steps.
30-online-boutique/namespace.yaml
Get Binaya Sharma’s stories in your inbox
Join Medium for free to get updates from this writer.
This namespace is not yet part of the mesh. We will be adding it later steps.
apiVersion: v1
kind: Namespace
metadata:
name: app30-online-boutique/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: app
resources:
- namespace.yaml
- https://github.com/GoogleCloudPlatform/microservices-demo/kustomize/base?ref=v0.10.7
patches:
# The base creates a public LoadBalancer for the frontend.
# We expose it through the Istio ingress gateway instead.
- patch: |-
$patch: delete
apiVersion: v1
kind: Service
metadata:
name: frontend-externalApply Manifest
kustomize build 30-online-boutique | kubectl apply --server-side -f -Step 5: Add namespace to the mesh
One label enrolls every pod in the namespace. istio-cni sees the label, sets up redirection inside each running pod’s network namespace, and hands the pod to its ztunnel. No restarts is required.
We would need to update 30-online-boutique/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: app
labels:
istio.io/dataplane-mode: ambientApply manifest
kustomize build 30-online-boutique | kubectl apply --server-side -f -Verify mTLS
We can verify mTLS using using the following command
istioctl ztunnel-config workloads --workload-namespace app- PROTOCOL: HBONE — It means ztunnel is intercepting each pod’s traffic and tunneling it over HBONE (HTTP-based mTLS overlay tunnel) to the
destination’s ztunnel. - WAYPOINT: None — this just means no L7 waypoint proxy is attached to these workloads. In ambient mode, ztunnel alone gives you L4: mTLS, identity, and basic telemetry between pods. A waypoint is an optional, separate proxy you deploy only when you need L7 features (HTTP routing, retries, AuthorizationPolicy with HTTP-level conditions, etc.).
In online-boutique there is already a load-generator deployment. We can inspect the traffic, and see the actual SPIFEE identities
$ kubectl logs -n istio-system -l app=ztunnel -f --prefix --max-log-requests=5
[pod/ztunnel-drqkh/istio-proxy] 2026-09-26T12:00:01.395988Z info access connection complete src.addr=10.20.127.85:42032 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="outbound" bytes_sent=72 bytes_recv=10752 duration="63ms"
[pod/ztunnel-7p2k4/istio-proxy] 2026-09-26T12:00:01.395392Z info access connection complete src.addr=10.20.127.85:49960 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="inbound" bytes_sent=10752 bytes_recv=72 duration="38ms"
[pod/ztunnel-7p2k4/istio-proxy] 2026-09-26T12:00:02.432301Z info access connection complete src.addr=10.20.127.85:49960 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="inbound" bytes_sent=10752 bytes_recv=72 duration="26ms"
[pod/ztunnel-drqkh/istio-proxy] 2026-09-26T12:00:02.432758Z info access connection complete src.addr=10.20.127.85:42034 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="outbound" bytes_sent=72 bytes_recv=10752 duration="27ms"
[pod/ztunnel-drqkh/istio-proxy] 2026-09-26T12:00:03.489273Z info access connection complete src.addr=10.20.127.85:42050 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="outbound" bytes_sent=72 bytes_recv=10752 duration="46ms"
[pod/ztunnel-7p2k4/istio-proxy] 2026-09-26T12:00:03.488812Z info access connection complete src.addr=10.20.127.85:49960 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="inbound" bytes_sent=10752 bytes_recv=72 duration="45ms"
[pod/ztunnel-7p2k4/istio-proxy] 2026-09-26T12:00:04.522673Z info access connection complete src.addr=10.20.127.85:49960 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="inbound" bytes_sent=10752 bytes_recv=72 duration="26ms"
[pod/ztunnel-drqkh/istio-proxy] 2026-09-26T12:00:04.523132Z info access connection complete src.addr=10.20.127.85:42066 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="outbound" bytes_sent=72 bytes_recv=10752 duration="26ms"
[pod/ztunnel-7p2k4/istio-proxy] 2026-09-26T12:00:05.551088Z info access connection complete src.addr=10.20.127.85:49960 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="inbound" bytes_sent=10752 bytes_recv=72 duration="19ms"
[pod/ztunnel-drqkh/istio-proxy] 2026-09-26T12:00:05.551546Z info access connection complete src.addr=10.20.127.85:49344 src.workload="curl-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.21:15008 dst.hbone_addr=10.20.181.21:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-58b4dc4d4d-r69c6" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/frontend" dst.cluster="Kubernetes" direction="outbound" bytes_sent=72 bytes_recv=10752 duration="20ms"src.identity / dst.identity pair is the proof that the connection was mutually authenticated with workload certificates issued by istiod.
Step 6: Expose the Frontend through an Ingress Gateway
Now we will be using a NLB to expose the frontend. We will be using API Gateway, and thus we will create a HTTP Route as well.
20-ingress/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: istio-ingress20-ingress/gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public-gateway
namespace: istio-ingress
spec:
gatewayClassName: istio
infrastructure:
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: app20-ingress/httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: frontend
namespace: app
spec:
parentRefs:
- name: public-gateway
namespace: istio-ingress
rules:
- backendRefs:
- name: frontend
port: 8020-ingress/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- gateway.yamlApply
kustomize build 20-ingress | kubectl apply --server-side -f -In side it show local . I wanted to change it to AWS. It can be easily done by editing 30-online-boutique/kustomization.yaml
Add the following patch to the patches section
# Show "AWS" in the frontend's platform badge instead of "local".
- patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
template:
spec:
containers:
- name: server
env:
- name: ENV_PLATFORM
value: "aws"kustomize build 30-online-boutique | kubectl apply --server-side -f -Now it is updated to
Step 7: Securing with L4 PeerAuthentication, and Authorization policy (ztunnel)
We already have mTLS configured in cluster. Now we want to have a authorization and authentication policies that depends on L4. We will be using L4 attributes: source principal, namespaces, IP, Destination Port will are available to ztunnel.
- Strict mTLS, so plaintext from outside the mesh is refused.
- A namespace wide allow list: only callers in
appnamespace or the ingress gateway - A database rule that denty every caller of
redis-cartexceptcartservice
40-policies/authz-l4.yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: strict-mtls
namespace: app
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-namespace-and-ingress
namespace: app
spec:
action: ALLOW
rules:
- from:
- source:
namespaces: ["app"]
- source:
principals:
- cluster.local/ns/istio-ingress/sa/public-gateway-istio
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: redis-only-from-cartservice
namespace: app
spec:
selector:
matchLabels:
app: redis-cart
action: DENY
rules:
- from:
- source:
notPrincipals:
- cluster.local/ns/app/sa/cartserviceBefore applying these policies, let was create a test redis pod in app namespace. This pod should be able to be PING and get back PONG from the application’s redis pod.
kubectl -n app run redis-test --image=redis:7-alpine --restart=Never -- sleep 3600
kubectl -n app wait --for=condition=Ready pod/redis-test
kubectl -n app exec redis-test -- redis-cli -h redis-cart pingNow let’s apply the policies
kustomize build 40-policies | kubectl apply --server-side -f -After applying the policy PING failed, because we only allowed redis to be called by cartservice
We can also see failure logs in ztunnel
❯ kubectl -n istio-system logs ds/ztunnel --tail=50 | grep -i denied
Found 3 pods, using pod/ztunnel-7p2k4
2026-09-26T12:35:08.970618Z error access connection complete src.addr=10.20.181.26:44112 src.workload="redis-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.23:15008 dst.hbone_addr=10.20.181.23:6379 dst.service="redis-cart.app.svc.cluster.local" dst.workload="redis-cart-788fbddc7-76pmt" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/default" dst.cluster="Kubernetes" direction="inbound" bytes_sent=0 bytes_recv=0 duration="0ms" error="connection closed due to policy rejection: explicitly denied by: app/redis-only-from-cartservice"We also had another policy to block anything from other namespace (apart from istio-ingress namespace). Lets test that
❯ kubectl -n istio-system logs ds/ztunnel --tail=50 | grep -i denied
Found 3 pods, using pod/ztunnel-7p2k4
2026-09-26T12:35:08.970618Z error access connection complete src.addr=10.20.181.26:44112 src.workload="redis-test" src.namespace="app" src.identity="spiffe://cluster.local/ns/app/sa/default" src.cluster="Kubernetes" dst.addr=10.20.181.23:15008 dst.hbone_addr=10.20.181.23:6379 dst.service="redis-cart.app.svc.cluster.local" dst.workload="redis-cart-788fbddc7-76pmt" dst.namespace="app" dst.identity="spiffe://cluster.local/ns/app/sa/default" dst.cluster="Kubernetes" direction="inbound" bytes_sent=0 bytes_recv=0 duration="0ms" error="connection closed due to policy rejection: explicitly denied by: app/redis-only-from-cartservice"
2026-09-26T12:40:03.337349Z error access connection complete src.addr=10.20.127.86:47312 dst.addr=10.20.181.25:8080 dst.service="frontend.app.svc.cluster.local" dst.workload="frontend-697646cccf-hvxfm" dst.namespace="app" dst.cluster="Kubernetes" direction="inbound" bytes_sent=0 bytes_recv=0 duration="0ms" error="connection closed due to policy rejection: explicitly denied by: istio-system/istio_converted_static_strict"Step 8: Visualize in Kiali
ztunnel exports L4 metrics, and we can use kiali to simply visualize mesh configuration.
50-addons/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- https://raw.githubusercontent.com/istio/istio/release-1.31/samples/addons/prometheus.yaml
- https://raw.githubusercontent.com/istio/istio/release-1.31/samples/addons/kiali.yamlApply
kustomize build 50-addons | kubectl apply --server-side -f -
kubectl -n istio-system rollout status deploy/kiali
istioctl dashboard kialiAlthough we have also installed prometheus, but there is no persistent storage. Please used you monitoring stack or managed solutions.
Clean up
Delete in reverse order. Remove the Gateway before the cluster so the NLB is released and doesn’t linger in your account:
kubectl -n app delete pod redis-test --ignore-not-found
kustomize build 50-addons | kubectl delete --ignore-not-found -f -
kustomize build 40-policies | kubectl delete --ignore-not-found -f -
kustomize build 30-online-boutique | kubectl delete --ignore-not-found -f -
kustomize build 20-ingress | kubectl delete --ignore-not-found -f -
kustomize build --enable-helm 10-istio | kubectl delete --ignore-not-found -f -
kustomize build --enable-helm 00-crds/istio-base | kubectl delete --ignore-not-found -f -
kustomize build 00-crds/gateway-api | kubectl delete --ignore-not-found -f -
eksctl delete cluster -f cluster/cluster.yamlWrapping up and Future
We went from an empty EKS cluster to a ambient mesh with strict mTLS everywhere and an L4 database lockdown enforced by ztunnel, all as plain Kustomize directories you can commit and hand to Argo CD or Flux.
When you need HTTP-aware policy, retries or traffic splitting, ambient’s optional waypoint proxies add that L7 layer on top of what we have.
May the Force be with you!








