Skip to main content

Ensure Clusters Are Created With Private Nodes

More Info:​

Create clusters with private nodes so node instances have no public IP addresses and communicate with the control plane over the private network.

Risk Level​

High

Address​

Security

Compliance Standards​

  • CIS AKS

Triage and Remediation​

Remediation​

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

    • Run on: any machine with az and network access to Azure.
    • Command (replace with your cluster details):
      az aks show \
      --resource-group MY_RESOURCE_GROUP \
      --name MY_AKS_CLUSTER \
      --query "{privateFqdn:privateFqdn, fqdn:fqdn, apiServerAccessProfile:apiServerAccessProfile}" \
      --output json
    • Review:
      • If privateFqdn is non-null and apiServerAccessProfile.enablePrivateCluster is true, the cluster is configured as a private cluster.
      • If privateFqdn is null or enablePrivateCluster is false/missing, the cluster is not private.
  2. Confirm whether nodes have public IP addresses

    • Run on: any machine with az.
    • Get the node resource group name:
      az aks show \
      --resource-group MY_RESOURCE_GROUP \
      --name MY_AKS_CLUSTER \
      --query nodeResourceGroup \
      --output tsv
    • List NICs and check IP configs:
      NODE_RG=$(az aks show --resource-group MY_RESOURCE_GROUP --name MY_AKS_CLUSTER --query nodeResourceGroup --output tsv)

      az network nic list \
      --resource-group "$NODE_RG" \
      --query "[].{name:name, publicIp:ipConfigurations[0].publicIpAddress.id}" \
      --output table
    • Review: if any node NIC has a non-empty publicIp value, those nodes are exposed via public IP.
  3. Decide remediation approach (recreate vs. accept risk)

    • If the cluster is not private and/or nodes have public IPs, decide:
      • Security-focused option: plan to create a new private cluster and migrate workloads.
      • Exception option: document and accept the risk if public nodes are required (for example, specific ingress or legacy networking constraints), and record the justification and compensating controls (NSGs, firewalls, WAF, etc.).
  4. Plan the private cluster configuration (if remediation is chosen)

    • Determine/allocate networking values:
      • Existing VNet subnet ID for nodes:
        az network vnet subnet show \
        --resource-group MY_NETWORK_RG \
        --vnet-name MY_VNET \
        --name MY_SUBNET \
        --query id \
        --output tsv
      • Choose --service-cidr, --dns-service-ip, and --docker-bridge-address ranges that do not overlap with your VNet address space and conform to your network standards.
    • Decide on cluster name, resource group, location, and ensure --load-balancer-sku standard is acceptable in your environment.
  5. Create a new private cluster (if remediation is chosen)

    • Run on: any machine with az.
    • Command (fill in your values):
      az aks create \
      --resource-group MY_PRIVATE_CLUSTER_RG \
      --name MY_PRIVATE_AKS_CLUSTER \
      --load-balancer-sku standard \
      --enable-private-cluster \
      --network-plugin azure \
      --vnet-subnet-id "/subscriptions/MY_SUB_ID/resourceGroups/MY_NETWORK_RG/providers/Microsoft.Network/virtualNetworks/MY_VNET/subnets/MY_SUBNET" \
      --docker-bridge-address 172.17.0.1/16 \
      --dns-service-ip 10.0.0.10 \
      --service-cidr 10.0.0.0/16
    • Adjust node count, VM size, and other options as needed, but ensure --enable-private-cluster remains present.
  6. Verify the new cluster is private and nodes lack public IPs

    • Run on: any machine with az.
    • Check private cluster properties:
      az aks show \
      --resource-group MY_PRIVATE_CLUSTER_RG \
      --name MY_PRIVATE_AKS_CLUSTER \
      --query "{privateFqdn:privateFqdn, fqdn:fqdn, apiServerAccessProfile:apiServerAccessProfile}" \
      --output json
      • Confirm privateFqdn is set and apiServerAccessProfile.enablePrivateCluster is true.
    • Check node NICs for public IPs:
      NODE_RG=$(az aks show --resource-group MY_PRIVATE_CLUSTER_RG --name MY_PRIVATE_AKS_CLUSTER --query nodeResourceGroup --output tsv)

      az network nic list \
      --resource-group "$NODE_RG" \
      --query "[].{name:name, publicIp:ipConfigurations[0].publicIpAddress.id}" \
      --output table
      • Confirm publicIp is empty for all node NICs.
Using kubectl

kubectl cannot be used to enable private nodes or convert an existing AKS cluster into a private cluster; this configuration is managed at the Azure AKS control-plane / cluster creation level via the Azure portal, CLI, or IaC. Refer to the Manual Steps section for how to review and, if needed, recreate the cluster with --enable-private-cluster.

Automation
#!/usr/bin/env bash
#
# Check AKS clusters for private node configuration (CIS AKS 5.4.3)
# Requirements:
# - Azure CLI (`az`) logged in with sufficient permissions
# - `jq` for JSON parsing
#
# Run location:
# - Any machine with Azure CLI access to the subscriptions you want to check.
#
# Usage:
# ./check-aks-private-nodes.sh # current subscription only
# AZ_SUBSCRIPTIONS="sub1 sub2" ./check-aks-private-nodes.sh # specific subs

set -euo pipefail

if ! command -v az >/dev/null 2>&1; then
echo "ERROR: az CLI not found in PATH" >&2
exit 1
fi

if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq not found in PATH" >&2
exit 1
fi

# Get list of subscriptions to check
if [[ -n "${AZ_SUBSCRIPTIONS:-}" ]]; then
read -r -a SUBS <<< "${AZ_SUBSCRIPTIONS}"
else
# current subscription only
CURRENT_SUB_ID="$(az account show --query id -o tsv)"
SUBS=("${CURRENT_SUB_ID}")
fi

echo "Checking AKS clusters for private nodes (CIS AKS 5.4.3)"
echo "Subscriptions: ${SUBS[*]}"
echo

for SUB in "${SUBS[@]}"; do
echo "=== Subscription: ${SUB} ==="
az account set --subscription "${SUB}"

# List all AKS clusters in this subscription
CLUSTERS_JSON="$(az aks list -o json)"
CLUSTER_COUNT="$(echo "${CLUSTERS_JSON}" | jq 'length')"

if [[ "${CLUSTER_COUNT}" -eq 0 ]]; then
echo "No AKS clusters found in this subscription."
echo
continue
fi

echo "${CLUSTERS_JSON}" | jq -r '
.[] |
{
name: .name,
resourceGroup: .resourceGroup,
location: .location,
apiServerAccessProfile: .apiServerAccessProfile,
networkProfile: .networkProfile
} |
[
.name,
.resourceGroup,
.location,
(
if (.apiServerAccessProfile.enablePrivateCluster // false) == true
then "true"
else "false"
end
),
(
if (.networkProfile.outboundType // "") == "userDefinedRouting"
then "UDR"
else (.networkProfile.outboundType // "managedNAT")
end
),
(
if (.apiServerAccessProfile.enablePrivateCluster // false) == true
then
(.apiServerAccessProfile.privateDNSZone // "N/A")
else
"N/A"
end
)
] | @tsv
' | while IFS=$'\t' read -r NAME RG LOCATION PRIVATE_CLUSTER OUTBOUND_TYPE PRIVATE_DNS_ZONE; do
# PRIVATE_CLUSTER: "true" means --enable-private-cluster was used.
if [[ "${PRIVATE_CLUSTER}" == "true" ]]; then
STATUS="COMPLIANT"
STATUS_DETAIL="private cluster enabled"
else
STATUS="NON-COMPLIANT"
STATUS_DETAIL="private cluster NOT enabled (public API / public nodes possible)"
fi

printf "Cluster: %s | RG: %s | Location: %s\n" "${NAME}" "${RG}" "${LOCATION}"
printf " - enablePrivateCluster: %s\n" "${PRIVATE_CLUSTER}"
printf " - outboundType: %s\n" "${OUTBOUND_TYPE}"
printf " - privateDNSZone: %s\n" "${PRIVATE_DNS_ZONE}"
printf " => STATUS: %s - %s\n\n" "${STATUS}" "${STATUS_DETAIL}"
done

echo
done

Explanation of output indicating a problem:

  • For each cluster, focus on the STATUS line:
    • STATUS: COMPLIANT - private cluster enabled
      • Indicates enablePrivateCluster is true and the cluster was created with private nodes.
    • STATUS: NON-CPLIANT - private cluster NOT enabled (public API / public nodes possible)
      • Indicates enablePrivateCluster is false or missing; this is the condition that requires manual review and usually remediation (recreate or migrate to a cluster created with --enable-private-cluster).