Creating a private Azure Kubernetes Service (AKS) cluster is often about more than just network isolation; it’s about controlling ingress and egress, and managing your blast radius.
Let’s see it in action. We’ll deploy a simple Nginx ingress controller and expose a basic hello-world application.
First, the prerequisites. You’ll need an Azure subscription, the Azure CLI installed and logged in, and kubectl configured.
# Log in to Azure
az login
# Set your subscription
az account set --subscription "your-subscription-id"
# Create a resource group
az group create --name my-private-aks-rg --location eastus
# Create a virtual network and subnet for AKS
az network vnet create \
--resource-group my-private-aks-rg \
--name my-aks-vnet \
--address-prefix 10.0.0.0/8
az network vnet subnet create \
--resource-group my-private-aks-rg \
--vnet-name my-aks-vnet \
--name aks-subnet \
--address-prefix 10.1.0.0/16
Now, create the private AKS cluster. The key here is --enable-private-cluster. This creates a private API server endpoint. We’ll also specify the --private-dns-zone to ensure your nodes can resolve the API server.
az aks create \
--resource-group my-private-aks-rg \
--name my-private-aks \
--node-count 1 \
--enable-private-cluster \
--private-dns-zone system \
--vnet-subnet-id "/subscriptions/your-subscription-id/resourceGroups/my-private-aks-rg/providers/Microsoft.Network/virtualNetworks/my-aks-vnet/subnets/aks-subnet" \
--service-cidr 10.2.0.0/24 \
--dns-service-ip 10.2.0.10 \
--docker-bridge-address 172.17.0.1/16 \
--attach-acr myacrregistry # Optional: if you have an ACR
Once the cluster is provisioned, you need to get the credentials. Since it’s private, you’ll likely be doing this from a machine that has network access to the private API server endpoint (e.g., within the same VNet, or via VPN/ExpressRoute).
az aks get-credentials --resource-group my-private-aks-rg --name my-private-aks --overwrite-existing
This command fetches the cluster’s configuration, including the private API server endpoint, and merges it into your ~/.kube/config file. kubectl will now use this private endpoint.
Next, let’s deploy a simple hello-world application and an Nginx ingress controller. We’ll assume you have Helm installed.
# Deploy a sample application
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-app
spec:
replicas: 1
selector:
matchLabels:
app: hello-app
template:
metadata:
labels:
app: hello-app
spec:
containers:
- name: hello-app
image: nginxdemos/hello:plain-text
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: hello-app-service
spec:
selector:
app: hello-app
ports:
- protocol: TCP
port: 80
targetPort: 80
EOF
# Add the ingress-nginx Helm repository
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
# Install the ingress-nginx controller.
# Crucially, we set `controller.service.loadBalancerIP` to an IP from our AKS subnet
# and `controller.service.annotations."service.beta.kubernetes.io/azure-load-balancer-internal"` to `true`.
# This ensures the LoadBalancer service created by the ingress controller is *internal* to your VNet.
# You'll need to assign a static IP within your AKS subnet for this.
# Let's pick 10.1.0.100 as an example for the internal load balancer.
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.loadBalancerIP="10.1.0.100" \
--set controller.service.annotations."service.beta.kubernetes.io/azure-load-balancer-internal"="true" \
--set controller.replicaCount=2 \
--set controller.nodeSelector."kubernetes\.io/os"=linux
Finally, create an Ingress resource to expose your hello-app-service via the internal load balancer.
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-app-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-app-service
port:
number: 80
EOF
Now, if you kubectl get svc -n ingress-nginx, you’ll see the ingress-nginx-controller service with TYPE=LoadBalancer and an IP address of 10.1.0.100. This IP is only reachable from within your VNet (or connected networks). You can test it by deploying a pod into your AKS cluster and curling that IP.
The core concept is that --enable-private-cluster locks down the Kubernetes API server to only be accessible via its private IP address within your Azure VNet. This is a significant security enhancement, as it prevents public exposure of your cluster’s control plane. The --private-dns-zone system setting is crucial for enabling nodes within the cluster to resolve the private API server endpoint using Azure’s private DNS infrastructure. Without it, nodes wouldn’t be able to communicate with the API server.
When you expose services using a LoadBalancer type service, as we did with the ingress controller, the --set controller.service.annotations."service.beta.kubernetes.io/azure-load-balancer-internal"="true" annotation is what tells Azure to provision a private load balancer. This load balancer gets an IP from your VNet’s address space and is only accessible from within that VNet. This is how you bring external traffic into your private cluster securely.
The one thing most people don’t immediately grasp is how DNS resolution works for the private API server. When you use --private-dns-zone system, AKS integrates with Azure’s private DNS zones. This means that within your VNet, DNS queries for your cluster’s FQDN (e.g., my-private-aks.h24.eastus.azmk8s.io) will resolve to the cluster’s private IP address, not a public one. If you were to try and access this from outside your VNet without proper network peering or VPN, it would simply not resolve.
The next step is typically managing egress traffic from your pods, which often involves configuring Network Security Groups (NSGs) or Azure Firewall.