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
Remediation​
Manual Steps
-
Identify whether the cluster is private or public
- Run on: any machine with
azand 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
privateFqdnis non-null andapiServerAccessProfile.enablePrivateClusteristrue, the cluster is configured as a private cluster. - If
privateFqdnis null orenablePrivateClusterisfalse/missing, the cluster is not private.
- If
- Run on: any machine with
-
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
publicIpvalue, those nodes are exposed via public IP.
- Run on: any machine with
-
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.).
- If the cluster is not private and/or nodes have public IPs, decide:
-
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-addressranges that do not overlap with your VNet address space and conform to your network standards.
- Existing VNet subnet ID for nodes:
- Decide on cluster name, resource group, location, and ensure
--load-balancer-sku standardis acceptable in your environment.
- Determine/allocate networking values:
-
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-clusterremains present.
- Run on: any machine with
-
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
privateFqdnis set andapiServerAccessProfile.enablePrivateClusteristrue.
- Confirm
- 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
publicIpis empty for all node NICs.
- Confirm
- Run on: any machine with
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
STATUSline:STATUS: COMPLIANT - private cluster enabled- Indicates
enablePrivateClusteristrueand the cluster was created with private nodes.
- Indicates
STATUS: NON-CPLIANT - private cluster NOT enabled (public API / public nodes possible)- Indicates
enablePrivateClusterisfalseor missing; this is the condition that requires manual review and usually remediation (recreate or migrate to a cluster created with--enable-private-cluster).
- Indicates