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).