← Back to list

From Ingress Controllers to Istio Ambient: A Practical Gateway API Setup for Internal and External…

Why We Replaced Our Ingress Controller

Pushkar Joshi · 2026-05-05 05:57 · 1 claps · 7.5 min read
#istio #cert-manager #ingress #api-gateway
Open on Medium ↗

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

  1. Install Gateway API CRDs
  2. Install Istio Ambient
  3. Label namespaces for ambient mode
  4. Create waypoint proxies where L7 control is needed
  5. Install cert-manager with Gateway API support
  6. Create public ACME issuer
  7. Create internal CA issuer
  8. Issue gateway certificates
  9. Create internal and external gateways
  10. Publish DNS records
  11. 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

[embed]Introduction - Kubernetes Gateway API Gateway API is an official Kubernetes project focused on L4 and L7 routing in Kubernetes. This project represents the…gateway-api.sigs.k8s.io

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