Skip to main content

Ensure Clusters Are Created With Private Nodes

More Info:

Disable public IP addresses for cluster nodes, so that they only have private IP addresses. Private Nodes are nodes with no public IP addresses.

Risk Level

Low

Address

Security

Compliance Standards

  • APRA CPS 234 (Australia)
  • BSI C5 (Germany)
  • Brazil LGPD
  • CCPA / CPRA (California)
  • CIS AKS
  • CIS Critical Security Controls v8
  • CMMC 2.0
  • CSA Cloud Controls Matrix v4
  • DPDPA
  • Digital Operational Resilience Act (EU)
  • ISO/IEC 27017
  • ISO/IEC 27018
  • ISO/IEC 27701
  • KSA PDPL
  • MAS Technology Risk Management (Singapore)
  • MITRE ATT&CK (Cloud)
  • NIS2 Directive
  • NIST SP 800-171
  • NYDFS 23 NYCRR 500
  • SWIFT Customer Security Controls Framework
  • Sarbanes-Oxley IT General Controls
  • UK NCSC Cyber Assessment Framework

Triage and Remediation

Remediation

Manual Steps
  1. Identify whether the AKS cluster is private or public

    • Run on any machine with Azure CLI access:
      az aks show \
      --resource-group <resource-group-name> \
      --name <cluster-name> \
      --query "apiServerAccessProfile.enablePrivateCluster" \
      --output tsv
    • If the output is true, the cluster is already configured as a private cluster (control plane is private). If false or empty, it is public.
  2. Check node pool VM IP addressing model

    • Still on any machine with Azure CLI access, list the node resource group and node pools:
      az aks show \
      --resource-group <resource-group-name> \
      --name <cluster-name> \
      --query "nodeResourceGroup" \
      --output tsv
      az aks nodepool list \
      --resource-group <resource-group-name> \
      --cluster-name <cluster-name> \
      --output table
    • Note the node resource group and node pool names; you will use them to inspect the NICs of the worker nodes.
  3. Determine if any nodes currently have public IP addresses

    • List NICs in the node resource group and check for associated public IPs:
      NODE_RG="<node-resource-group-from-previous-step>"

      az network nic list \
      --resource-group "$NODE_RG" \
      --query "[].{name:name,ipConfigs:ipConfigurations[].{privateIP:privateIpAddress,publicIP:publicIpAddress}}" \
      --output table
    • Alternatively, list public IP resources in that RG:
      az network public-ip list \
      --resource-group "$NODE_RG" \
      --query "[].{name:name,ip:ipAddress}" \
      --output table
    • Any public IPs directly attached to node NICs indicate non‑private nodes.
  4. Decide on remediation approach (recreate vs. migrate)

    • If the cluster is not private (enablePrivateCluster is not true) or nodes have public IPs, decide whether to:
      • Recreate the cluster as a private cluster (recommended, aligns with benchmark example), or
      • Provision a new private cluster and migrate workloads gradually, then decommission the old cluster.
    • Review dependencies: outbound internet access, Azure Firewall/NAT Gateway, DNS, and any workflows that currently reach nodes or the API server directly from the internet.
  5. Create a new AKS private cluster with private nodes

    • On any machine with Azure CLI access, provision a new cluster following the benchmark remediation (adapt exact values to your environment):
      az aks create \
      --resource-group <private-cluster-resource-group> \
      --name <private-cluster-name> \
      --load-balancer-sku standard \
      --enable-private-cluster \
      --network-plugin azure \
      --vnet-subnet-id <subnet-id> \
      --docker-bridge-address <docker-bridge-address> \
      --dns-service-ip <dns-service-ip> \
      --service-cidr <service-cidr>
    • Ensure the subnet does not assign public IP addresses to node NICs and that outbound internet (if needed) is provided via private egress (e.g., Azure Firewall/NAT Gateway).
  6. Verify the new private cluster and decommission old public nodes/cluster

    • Confirm the new cluster’s private configuration:
      az aks show \
      --resource-group <private-cluster-resource-group> \
      --name <private-cluster-name> \
      --query "apiServerAccessProfile.enablePrivateCluster" \
      --output tsv
    • Re-run the NIC/public IP checks (Step 3) against the new cluster’s node resource group to ensure no public IPs are attached.
    • After migrating workloads and validating functionality, delete any old node pools or the entire non‑private cluster:
      az aks delete \
      --resource-group <old-cluster-resource-group> \
      --name <old-cluster-name> \
      --yes \
      --no-wait
Using kubectl

kubectl cannot change whether an AKS cluster uses private nodes, because this setting is defined in the managed control-plane / cluster configuration in Azure (CLI/portal/IaC), not via Kubernetes API objects. To address this finding, use the Azure tools described in the Manual Steps section.

Automation
#!/usr/bin/env bash
#
# Check AKS clusters for private node configuration (no public IPs)
# Requirements:
# - Azure CLI logged in with access to subscriptions
# - jq installed
#
# Usage:
# ./check-aks-private-nodes.sh # all subscriptions
# AZ_SUBSCRIPTION_ID=<id> ./check-aks-private-nodes.sh # single subscription

set -euo pipefail

# Helper to log to stderr
log() { printf '%s\n' "$*" >&2; }

# Determine which subscriptions to scan
if [[ -n "${AZ_SUBSCRIPTION_ID:-}" ]]; then
subs=("$AZ_SUBSCRIPTION_ID")
else
mapfile -t subs < <(az account list --query '[].id' -o tsv)
fi

if [[ ${#subs[@]} -eq 0 ]]; then
log "No subscriptions found. Ensure 'az login' has been run."
exit 1
fi

printf 'subscriptionId\tresourceGroup\tclusterName\tprivateCluster\tapiServerAccessProfile\t'
printf 'nodePoolsWithPublicIP\tpublicIPNodePools(nodePoolName:vmPublicIP)\n'

for sub in "${subs[@]}"; do
az account set --subscription "$sub" >/dev/null

# List clusters in this subscription
mapfile -t clusters < <(az aks list -o tsv --query '[].{rg:resourceGroup,name:name}')
if [[ ${#clusters[@]} -eq 0 ]]; then
continue
fi

for c in "${clusters[@]}"; do
rg="${c%%$'\t'*}"
name="${c#*$'\t'}"

# Get core cluster properties
cluster_json=$(az aks show -g "$rg" -n "$name" -o json)

private_cluster=$(jq -r '.apiServerAccessProfile.enablePrivateCluster // false' <<<"$cluster_json")
api_access_profile=$(jq -c '.apiServerAccessProfile // {}' <<<"$cluster_json")

# List node pools and detect public IPs on agent VMs
mapfile -t nodepools < <(az aks nodepool list -g "$rg" --cluster-name "$name" -o tsv --query '[].name')
has_public_ip_pools="false"
public_ip_details=""

for np in "${nodepools[@]}"; do
# Get underlying VM scale set name
vmss_name=$(az aks nodepool show -g "$rg" --cluster-name "$name" -n "$np" \
--query 'properties.orchestratorVersion && properties.scaleSetEvictionPolicy || properties.name' -o tsv 2>/dev/null || true)

# If that heuristic fails, fall back to listing VMSS by tags
if [[ -z "$vmss_name" || "$vmss_name" == "null" ]]; then
vmss_name=$(az vmss list -g "$rg" \
--query "[?tags.\"aks-managed-cluster-name\"=='$name' && tags.\"aks-managed-nodepool-name\"=='$np'].name | [0]" \
-o tsv 2>/dev/null || true)
fi

if [[ -z "$vmss_name" || "$vmss_name" == "null" ]]; then
continue
fi

# Check NICs in the VMSS for public IPs
nic_ids=$(az vmss nic list -g "$rg" --vmss-name "$vmss_name" --query '[].id' -o tsv 2>/dev/null || true)
if [[ -z "$nic_ids" ]]; then
continue
fi

while IFS= read -r nic_id; do
if [[ -z "$nic_id" ]]; then
continue
fi
nic_json=$(az network nic show --ids "$nic_id" -o json 2>/dev/null || true)
# For each IP config, check if publicIPAddress is attached
has_pip=$(jq -r '[.ipConfigurations[].publicIPAddress // empty] | length > 0' <<<"$nic_json" 2>/dev/null || echo "false")
if [[ "$has_pip" == "true" ]]; then
has_public_ip_pools="true"
vm_name=$(jq -r '.virtualMachine.id // ""' <<<"$nic_json" | awk -F/ '{print $NF}')
pip_ids=$(jq -r '.ipConfigurations[].publicIPAddress.id // empty' <<<"$nic_json" | paste -sd',' -)
detail="${np}:${vm_name}(${pip_ids})"
if [[ -z "$public_ip_details" ]]; then
public_ip_details="$detail"
else
public_ip_details="$public_ip_details;$detail"
fi
fi
done <<<"$nic_ids"
done

printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$sub" "$rg" "$name" "$private_cluster" "$api_access_profile" \
"$has_public_ip_pools" "$public_ip_details"
done
done

How to run (any machine with Azure CLI access):

  1. Save as check-aks-private-nodes.sh and make executable:
    chmod +x ./check-aks-private-nodes.sh
  2. Run:
    ./check-aks-private-nodes.sh

How to interpret the output (what indicates a problem):

Each line describes one AKS cluster:

  • privateCluster:
    • false → cluster is not configured as a private cluster (--enable-private-cluster missing). This is a finding.
  • nodePoolsWithPublicIP:
    • true → at least one node pool has VMs with public IPs attached. This is a finding (nodes are not private).
  • publicIPNodePools(nodePoolName:vmPublicIP):
    • Lists which node pools/VMs have public IPs; these require review and potential remediation.

A cluster passes this control when:

  • privateCluster is true, and
  • nodePoolsWithPublicIP is false (no node VMs have public IPs).