From Ingress Controllers to Istio Ambient: A Practical Gateway API Setup for Internal and External…
Why We Replaced Our Ingress Controller
From Ingress Controllers to Istio Ambient: A Practical Gateway API Setup for Internal and External Traffic
Why We Replaced Our Ingress Controller
Most platform teams don’t replace an ingress controller for fun. The change starts with friction:
- Public and private traffic sharing too much machinery
- TLS issuance becoming inconsistent across internal and external domains
- Host-based routing rules growing unmanageable
- Ingress annotations becoming a legacy control surface
The Architecture at a Glance
We replaced an NGINX ingress-centric model with:
- Istio Ambient Mode — lower-friction mesh adoption
- Gateway API — the durable routing contract
- External Istio Gateway — for internet-facing traffic
- Internal Istio Gateway — for private-only traffic
- cert-manager — public certificate automation via ACME
- Internal CA — private DNS certificate issuance
Traffic Flow
Internet Users → Public DNS → External Gateway → HTTPRoutes → Public Apps
Private Users (VPN) → Private DNS → Internal Gateway → HTTPRoutes → Ops Tools / Monitoring
Step-by-Step Setup Order
- Install Gateway API CRDs
- Install Istio Ambient
- Label namespaces for ambient mode
- Create waypoint proxies where L7 control is needed
- Install cert-manager with Gateway API support
- Create public ACME issuer
- Create internal CA issuer
- Issue gateway certificates
- Create internal and external gateways
- Publish DNS records
- Attach HTTPRoutes
Namespace Enrollment into Ambient Mode
kubectl label namespace apps istio.io/dataplane-mode=ambient --overwrite
kubectl label namespace platform istio.io/dataplane-mode=ambient --overwrite
No sidecars required — ambient mode handles transport-layer mesh participation automatically.
Certificate Strategy
Traffic Type Issuer Method Public DNS Let’s Encrypt ACME DNS-01 / HTTP-01 Private DNS Internal CA cert-manager CA Issuer
Private names like *.internal.example.local cannot use Let's Encrypt. An internal CA is not a fallback — it is the correct model.
Internal Gateway Configuration
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: internal-gateway
namespace: platform-gateway
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
spec:
gatewayClassName: istio
listeners:
- name: https
port: 443
protocol: HTTPS
hostname: "*.internal.example.local"
tls:
mode: Terminate
certificateRefs:
- name: internal-gateway-cert-tls
allowedRoutes:
namespaces:
from: All
External Gateway Configuration
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: external-gateway
namespace: platform-gateway
spec:
gatewayClassName: istio
listeners:
- name: https
port: 443
protocol: HTTPS
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- name: public-wildcard-cert-tls
allowedRoutes:
namespaces:
from: All
Example: Internal App (Ops Dashboard)
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: ops-dashboard-route
namespace: platform
spec:
parentRefs:
- name: internal-gateway
namespace: platform-gateway
sectionName: https
hostnames:
- "ops.internal.example.local"
rules:
- backendRefs:
- name: ops-dashboard
port: 80
Example: External App (Customer Portal)
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: customer-portal-route
namespace: apps
spec:
parentRefs:
- name: external-gateway
namespace: platform-gateway
sectionName: https
hostnames:
- "app.example.com"
rules:
- backendRefs:
- name: customer-portal
port: 80
🛠️ How to Set Up & Run This Architecture — Step by Step with Commands
STEP 1: Prepare Your Environment [4]
bash
# Install required tools
# 1. kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s \
https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
kubectl version --client
# 2. Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
# 3. istioctl
curl -L https://istio.io/downloadIstio | \
ISTIO_VERSION=1.21.0 sh -
export PATH=$PWD/istio-1.21.0/bin:$PATH
istioctl version
STEP 2: Install Gateway API CRDs (REQUIRED FIRST)
bash
# Install Gateway API CRDs
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml
# Verify
kubectl get crd | grep gateway.networking.k8s.io
# Expected output:
# gateways.gateway.networking.k8s.io 2026-05-05T00:00:00Z
# httproutes.gateway.networking.k8s.io 2026-05-05T00:00:00Z
# grpcroutes.gateway.networking.k8s.io 2026-05-05T00:00:00Z
STEP 3: Install Istio Ambient Mode
bash
# Install Istio with ambient profile
istioctl install --set profile=ambient -y
# Verify all Istio components are running
kubectl get pods -n istio-system
# Expected:
# NAME READY STATUS RESTARTS
# istiod-xxx 1/1 Running 0
# ztunnel-xxx 1/1 Running 0 (per node)
# istio-cni-node-xxx 1/1 Running 0 (per node)
# Verify GatewayClass was created
kubectl get gatewayclass
# NAME CONTROLLER ACCEPTED
# istio istio.io/gateway-controller True
STEP 4: Create Namespaces & Enroll into Ambient
bash
# Create namespaces
kubectl create namespace platform-gateway
kubectl create namespace apps
kubectl create namespace platform
kubectl create namespace monitoring
# Label for ambient mesh participation
kubectl label namespace apps \
istio.io/dataplane-mode=ambient --overwrite
kubectl label namespace platform \
istio.io/dataplane-mode=ambient --overwrite
kubectl label namespace monitoring \
istio.io/dataplane-mode=ambient --overwrite
# Verify labels
kubectl get namespace \
-L istio.io/dataplane-mode
# Create waypoint proxy for L7 policy
istioctl waypoint apply \
--namespace apps \
--enroll-namespace
# Verify waypoint
kubectl get gateway -n apps
STEP 5: Install cert-manager [2]
bash
# Add Helm repo
helm repo add jetstack https://charts.jetstack.io
helm repo update
# Install cert-manager with Gateway API feature enabled
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.14.0 \
--set installCRDs=true \
--set "extraArgs={--feature-gates=ExperimentalGatewayAPISupport=true}"
# Verify pods
kubectl get pods -n cert-manager
# Expected:
# cert-manager-xxx 1/1 Running
# cert-manager-cainjector-xxx 1/1 Running
# cert-manager-webhook-xxx 1/1 Running
STEP 6: Create Internal CA Secret
bash
# Generate CA key and certificate
openssl genrsa -out ca.key 4096
openssl req -new -x509 \
-days 3650 \
-key ca.key \
-out ca.crt \
-subj "/CN=Internal Platform CA/O=Platform Engineering"
# Store CA in Kubernetes secret
kubectl create secret tls internal-ca \
--cert=ca.crt \
--key=ca.key \
-n cert-manager
# Verify
kubectl get secret internal-ca -n cert-manager
STEP 7: Apply Certificate Issuers
bash
# Apply all issuers at once
kubectl apply -f - <<'EOF'
---
# Public ACME Issuer (DNS-01)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-production
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-production-key
solvers:
- dns01:
route53:
region: us-east-1
hostedZoneID: ZXXXXXXXXXXXXX
selector:
dnsZones:
- "example.com"
---
# Public ACME Issuer (HTTP-01 via Gateway API)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-http01
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-http01-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: external-gateway
namespace: platform-gateway
kind: Gateway
group: gateway.networking.k8s.io
---
# Internal CA Issuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca-issuer
spec:
ca:
secretName: internal-ca
EOF
# Verify all issuers are Ready
kubectl get clusterissuer
# NAME READY AGE
# letsencrypt-production True 30s
# letsencrypt-http01 True 30s
# internal-ca-issuer True 30s
STEP 8: Issue Gateway Certificates
bash
kubectl apply -f - <<'EOF'
---
# Public wildcard certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: public-wildcard-cert
namespace: platform-gateway
spec:
secretName: public-wildcard-cert-tls
issuerRef:
name: letsencrypt-production
kind: ClusterIssuer
dnsNames:
- "example.com"
- "*.example.com"
duration: 2160h
renewBefore: 360h
---
# Private wildcard certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-gateway-cert
namespace: platform-gateway
spec:
secretName: internal-gateway-cert-tls
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
dnsNames:
- "*.internal.example.local"
- "internal.example.local"
duration: 8760h
renewBefore: 720h
EOF
# Watch certificates being issued
kubectl get certificate -n platform-gateway -w
# Expected (after 1-2 mins):
# NAME READY SECRET AGE
# public-wildcard-cert True public-wildcard-cert-tls 2m
# internal-gateway-cert True internal-gateway-cert-tls 2m
STEP 9: Deploy Internal & External Gateways
bash
kubectl apply -f - <<'EOF'
---
# Internal Gateway
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: internal-gateway
namespace: platform-gateway
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
spec:
gatewayClassName: istio
listeners:
- name: http
port: 80
protocol: HTTP
hostname: "*.internal.example.local"
allowedRoutes:
namespaces:
from: All
- name: https
port: 443
protocol: HTTPS
hostname: "*.internal.example.local"
tls:
mode: Terminate
certificateRefs:
- name: internal-gateway-cert-tls
allowedRoutes:
namespaces:
from: All
---
# External Gateway
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: external-gateway
namespace: platform-gateway
spec:
gatewayClassName: istio
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All
- name: https
port: 443
protocol: HTTPS
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- name: public-wildcard-cert-tls
allowedRoutes:
namespaces:
from: All
- name: https-app1
port: 443
protocol: HTTPS
hostname: "app1.example.com"
tls:
mode: Terminate
certificateRefs:
- name: app1-example-com-cert-tls
allowedRoutes:
namespaces:
from: All
EOF
# Wait for gateways to be programmed
kubectl get gateway -n platform-gateway -w
# Get IPs
echo "External IP:"
kubectl get svc -n platform-gateway \
-l istio.io/gateway-name=external-gateway \
-o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}'
echo "Internal IP:"
kubectl get svc -n platform-gateway \
-l istio.io/gateway-name=internal-gateway \
-o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}'
STEP 10: Publish DNS Records
bash
# Get both IPs
EXTERNAL_IP=$(kubectl get svc -n platform-gateway \
-l istio.io/gateway-name=external-gateway \
-o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}')
INTERNAL_IP=$(kubectl get svc -n platform-gateway \
-l istio.io/gateway-name=internal-gateway \
-o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}')
echo "Add these DNS records:"
echo "PUBLIC A *.example.com → $EXTERNAL_IP"
echo "PUBLIC A example.com → $EXTERNAL_IP"
echo "PRIVATE A *.internal.example.local → $INTERNAL_IP"
Create DNS records in your provider:
ZoneTypeNameValuePublicA*.example.com$EXTERNAL_IPPublicAexample.com$EXTERNAL_IPPrivateA*.internal.example.local$INTERNAL_IP
STEP 11: Deploy Applications & Attach HTTPRoutes [3]
bash
kubectl apply -f - <<'EOF'
---
# Internal App: Ops Dashboard
apiVersion: apps/v1
kind: Deployment
metadata:
name: ops-dashboard
namespace: platform
spec:
replicas: 2
selector:
matchLabels:
app: ops-dashboard
template:
metadata:
labels:
app: ops-dashboard
spec:
containers:
- name: app
image: ghcr.io/example/ops-dashboard:latest
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
---
apiVersion: v1
kind: Service
metadata:
name: ops-dashboard
namespace: platform
spec:
selector:
app: ops-dashboard
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: ops-dashboard-route
namespace: platform
spec:
parentRefs:
- name: internal-gateway
namespace: platform-gateway
sectionName: http
- name: internal-gateway
namespace: platform-gateway
sectionName: https
hostnames:
- "ops.internal.example.local"
rules:
- backendRefs:
- name: ops-dashboard
port: 80
---
# External App: Customer Portal
apiVersion: apps/v1
kind: Deployment
metadata:
name: customer-portal
namespace: apps
spec:
replicas: 3
selector:
matchLabels:
app: customer-portal
template:
metadata:
labels:
app: customer-portal
spec:
containers:
- name: web
image: ghcr.io/example/customer-portal:latest
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /ready
port: 3000
---
apiVersion: v1
kind: Service
metadata:
name: customer-portal
namespace: apps
spec:
selector:
app: customer-portal
ports:
- name: http
port: 80
targetPort: 3000
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: customer-portal-route
namespace: apps
spec:
parentRefs:
- name: external-gateway
namespace: platform-gateway
sectionName: http
- name: external-gateway
namespace: platform-gateway
sectionName: https
hostnames:
- "app.example.com"
rules:
- backendRefs:
- name: customer-portal
port: 80
EOF
# Verify routes are accepted
kubectl get httproute -A
STEP 12: Full Verification
bash
#!/bin/bash
echo "=== Checking Gateway API CRDs ==="
kubectl get crd | grep gateway.networking.k8s.io | wc -l
echo "=== Checking Istio Pods ==="
kubectl get pods -n istio-system \
--field-selector=status.phase=Running
echo "=== Checking Gateways ==="
kubectl get gateway -A
echo "=== Checking Certificates ==="
kubectl get certificate -A
echo "=== Checking HTTPRoutes ==="
kubectl get httproute -A
echo "=== Testing External Endpoint ==="
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" \
https://app.example.com/ready
echo "=== Testing HTTP Redirect ==="
curl -s -o /dev/null -w "Redirect Status: %{http_code}\n" \
--max-redirs 0 http://app.example.com/
echo "=== Done ==="
✅ Complete Setup Checklist [5]
[ ] Step 1: kubectl, helm, istioctl installed
[ ] Step 2: Gateway API CRDs applied
[ ] Step 3: Istio ambient running (istiod + ztunnel + cni)
[ ] Step 4: Namespaces created + labeled, waypoint deployed
[ ] Step 5: cert-manager installed with Gateway API flag
[ ] Step 6: Internal CA secret created
[ ] Step 7: All 3 ClusterIssuers showing READY=True
[ ] Step 8: Both certificates showing READY=True
[ ] Step 9: Both gateways showing PROGRAMMED=True
[ ] Step 10: DNS records published
[ ] Step 11: Apps deployed + HTTPRoutes ACCEPTED
[ ] Step 12: curl tests passing
🔧 Quick Troubleshooting Commands
bash
# Gateway not ready?
kubectl describe gateway -n platform-gateway
# Certificate stuck?
kubectl describe certificaterequest -n platform-gateway
kubectl describe order -n platform-gateway
# Route not accepted?
kubectl describe httproute -n apps
# Pod not in mesh?
kubectl get pod -n apps \
-o jsonpath='{range .items[*]}{.metadata.name}: \
{.metadata.annotations.ambient\.istio\.io/redirection}{"\n"}{end}'
# Check ztunnel logs
kubectl logs -n istio-system \
-l app=ztunnel --tail=50
🌐 Sources
📚 Official Documentation References
Official Docs Istio Ambient
[embed]Ambient Mode Information for setting up and operating Istio with support for ambient mode.istio.io
Gateway API
Cert-manager
https://cert-manager.io/docs/Kuberneteshttps://kubernetes.io/docs/
메타데이터
- post_id
- d880cd99a4f9
- slug
- from-ingress-controllers-to-istio-ambient-a-practical-gateway-api-setup-for-internal-and-external-d880cd99a4f9
- url
- https://medium.com/@pushkar-sre/from-ingress-controllers-to-istio-ambient-a-practical-gateway-api-setup-for-internal-and-external-d880cd99a4f9
- canonical_url
- https://medium.com/@pushkar-sre/from-ingress-controllers-to-istio-ambient-a-practical-gateway-api-setup-for-internal-and-external-d880cd99a4f9
- author_url
- https://medium.com/@pushkar-sre
- status
- ok
- fetched_at
- 2026-08-10 11:51:09