# Azure CLI Cheat Sheet — Compute: VM & AKS

> **Tool:** Azure CLI (az)
> **Category:** Cloud CLIs
> **Verified against:** Azure CLI 2.87.0, flags verified via `az vm create --help`, `az aks create --help`,
> `az aks nodepool add --help`, `az aks upgrade --help`, `az disk --help`, `az snapshot --help`, and `az
> vmss --help` run locally, plus Microsoft Learn (AKS node pool / workload identity guidance), 2026-08-29
> **Official docs:** https://learn.microsoft.com/cli/azure/vm and https://learn.microsoft.com/cli/azure/aks

## What it is and where it fits 🎯

This page covers the two compute surfaces most `az` users touch daily: virtual machines (Azure's IaaS
compute primitive, the equivalent of an EC2 instance or a GCE instance) and Azure Kubernetes Service — AKS
— Azure's managed Kubernetes control plane. Both share the same resource-group/subscription scoping and
identity model covered on page `01`; this page assumes you're already authenticated and have a resource
group to work in.

## VM lifecycle states

```mermaid
stateDiagram-v2
    [*] --> Running: az vm create
    Running --> Stopped: az vm stop
    Stopped --> Running: az vm start
    Running --> Deallocated: az vm deallocate
    Stopped --> Deallocated: az vm deallocate
    Deallocated --> Running: az vm start
    Deallocated --> [*]: az vm delete
    Running --> [*]: az vm delete

    note right of Stopped
        OS powered off, hardware
        allocation still held — still billed
    end note
    note right of Deallocated
        Hardware allocation released —
        billing stops, dynamic public IP
        may change on next start
    end note

    classDef ok fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef warn fill:#fbeee0,stroke:#b8650f,color:#10161c
    classDef muted fill:#eaeef1,stroke:#c3ccd4,color:#10161c
    class Running ok
    class Stopped warn
    class Deallocated muted
```

`stop` and `deallocate` are **not the same operation**, and this is the single most common Azure-specific
billing surprise: `stop` powers the guest OS off but Azure still holds the underlying hardware allocation
(you're still billed for compute); `deallocate` releases that allocation entirely (billing for compute
stops, but a dynamically-assigned public IP can change the next time the VM starts). This is the Azure
equivalent of AWS's stop-vs-terminate distinction, except both Azure states look identical from inside the
Portal's power-state column unless you know to check closely.

## Creating a VM

```bash
az vm create \
  --resource-group my-rg --name my-vm \
  --image Ubuntu2204 --size Standard_DS2_v2 \
  --admin-username deploy --generate-ssh-keys \
  --public-ip-sku Standard --nsg my-nsg --vnet-name my-vnet --subnet my-subnet
```

`--generate-ssh-keys` creates a new SSH key pair if one doesn't already exist at the default path and
reuses it if it does — convenient for one-off VMs, but for anything scripted/repeatable, pass an explicit
`--ssh-key-values` pointing at a managed key instead, so a fresh CI runner doesn't silently generate (and
discard) a brand-new key pair on every run.

```bash
az vm list-sizes --location eastus --output table          # every VM size available in that region
az vm list-vm-resize-options --resource-group my-rg --name my-vm --output table   # sizes this specific
                                                                                   # VM can resize into
```

`--size` (SKU) availability varies by region and, for some SKUs, by specific availability zone — `az vm
list-sizes` against the *target* region before scripting a create avoids a failed deployment discovering
this the hard way.

## Listing and inspecting VMs

```bash
az vm list --resource-group my-rg --output table
az vm list --show-details --output table               # + public IP, FQDN, power state (slower call)
az vm show --resource-group my-rg --name my-vm
az vm get-instance-view --resource-group my-rg --name my-vm    # power/provisioning state only, faster
                                                                 # than a full `show` when that's all you need
```

## Starting, stopping, deallocating, and deleting VMs

```bash
az vm start --resource-group my-rg --name my-vm
az vm stop --resource-group my-rg --name my-vm            # power off, still billed for allocated compute
az vm deallocate --resource-group my-rg --name my-vm       # power off AND release the compute allocation
az vm resize --resource-group my-rg --name my-vm --size Standard_DS3_v2   # must be deallocated or the
                                                                            # target size unavailable on
                                                                            # the current host, per Azure
az vm delete --resource-group my-rg --name my-vm --yes
```

> [!WARNING]
> **Deleting a VM does not delete its managed disks or NICs by default** — they become orphaned, unattached
> resources that keep costing money silently. Use `az vm delete --yes` together with a follow-up `az disk
> list --query "[?diskState=='Unattached']"` sweep, or pass `--force-deletion` (where the resource type
> supports it) if you specifically intend to remove the disk too. A team that ran repeated VM
> create/delete cycles for a load test without checking for orphaned disks is a common way to discover a
> surprise bill weeks later.

## Managed disks and snapshots

```bash
az disk create --resource-group my-rg --name my-data-disk --size-gb 128 --sku Premium_LRS
az vm disk attach --resource-group my-rg --vm-name my-vm --name my-data-disk
az disk list --resource-group my-rg --output table
az disk list --query "[?diskState=='Unattached']" --output table   # find orphaned, still-billed disks

az snapshot create --resource-group my-rg --name my-disk-snap --source my-data-disk
az disk create --resource-group my-rg --name restored-disk --source my-disk-snap   # restore from snapshot
```

A snapshot is a point-in-time, read-only copy of a managed disk — the standard pattern for a pre-change
backup (before an OS upgrade, a risky migration) or for seeding a new disk with known-good data without
touching the original.

## Virtual Machine Scale Sets (VMSS)

```bash
az vmss create \
  --resource-group my-rg --name my-vmss \
  --image Ubuntu2204 --instance-count 3 \
  --vm-sku Standard_DS2_v2 --generate-ssh-keys \
  --vnet-name my-vnet --subnet my-subnet
az vmss scale --resource-group my-rg --name my-vmss --new-capacity 5
az vmss update --resource-group my-rg --name my-vmss --set virtualMachineProfile.priority=Spot   # not
                                                                                                    # supported on every SKU/region — see docs
```

VMSS is Azure's autoscaling VM group primitive — the equivalent of an AWS Auto Scaling Group or a GCP
Managed Instance Group. Prefer AKS over a hand-rolled VMSS for anything containerized; reach for VMSS
directly when the workload genuinely needs bare VMs (a licensing constraint, a legacy app that can't be
containerized yet).

## Creating an AKS cluster

```bash
az aks create \
  --resource-group my-rg --name my-cluster \
  --node-count 3 --generate-ssh-keys \
  --enable-managed-identity \
  --network-plugin azure --network-policy azure \
  --enable-oidc-issuer --enable-workload-identity \
  --tier standard --zones 1 2 3 \
  --attach-acr myregistry
```

Each flag above earns its place in a production cluster:

- **`--enable-managed-identity`** — the cluster manages its own Azure resources (load balancers, disks)
  using a system-assigned managed identity instead of an older, harder-to-rotate service principal. This is
  the current default and recommended path.
- **`--network-plugin azure`** (vs. `kubenet`) — assigns pods real, routable VNet IPs, which is required for
  several features (Azure Network Policy, some private-cluster configurations) and is Microsoft's current
  general recommendation over `kubenet` for anything beyond a small test cluster.
- **`--enable-oidc-issuer` + `--enable-workload-identity`** — together, these let Kubernetes pods
  authenticate to Azure using **AKS workload identity federation**: a pod's Kubernetes service account
  token is federated to an Entra ID app registration, exactly the same mechanism page `01` describes for
  GitHub Actions, just with a pod's service account as the external identity instead of a GitHub workflow.
  This replaces the older `aad-pod-identity` project (deprecated) as the recommended way for pods to get
  Azure credentials without a stored secret.
- **`--tier standard`** — enables a financially-backed SLA on the control plane; the free tier has no SLA
  and is not recommended for production.
- **`--zones 1 2 3`** — spreads nodes across availability zones for zonal-failure resilience.
- **`--attach-acr`** — grants the cluster's identity `AcrPull` on the named Azure Container Registry in one
  step, instead of a separate manual role assignment.

## Getting kubectl credentials

```bash
az aks get-credentials --resource-group my-rg --name my-cluster
az aks get-credentials --resource-group my-rg --name my-cluster --admin   # bypass Azure RBAC/AAD auth,
                                                                            # local admin credentials —
                                                                            # break-glass only
```

Same role as `aws eks update-kubeconfig` / `gcloud container clusters get-credentials` — merges a context
into your local kubeconfig so `kubectl` can talk to the cluster; it does not itself create or change
anything cluster-side. `--admin` fetches the cluster's local admin credentials, bypassing whatever Entra
ID/Azure RBAC authorization is configured — reserve it for break-glass access, not routine use, since it
defeats the point of having Azure RBAC on the cluster at all.

## Listing clusters and node pools

```bash
az aks list --resource-group my-rg --output table
az aks nodepool list --resource-group my-rg --cluster-name my-cluster --output table
az aks show --resource-group my-rg --name my-cluster --query "kubernetesVersion"
```

## Scaling a node pool

```bash
az aks scale --resource-group my-rg --name my-cluster --node-count 5
az aks scale --resource-group my-rg --name my-cluster --nodepool-name userpool --node-count 5
```

## Adding, updating, and removing node pools

```bash
az aks nodepool add \
  --resource-group my-rg --cluster-name my-cluster \
  --name userpool --node-count 3 --node-vm-size Standard_DS2_v2 \
  --mode User --node-taints "workload=batch:NoSchedule" --zones 1 2 3

az aks nodepool add \
  --resource-group my-rg --cluster-name my-cluster \
  --name spotpool --priority Spot --eviction-policy Delete \
  --node-vm-size Standard_DS2_v2 --node-count 2 \
  --node-taints "kubernetes.azure.com/scalesetpriority=spot:NoSchedule"

az aks nodepool show --resource-group my-rg --cluster-name my-cluster --name userpool
az aks nodepool delete --resource-group my-rg --cluster-name my-cluster --name userpool
```

A cluster always keeps at least one **system** node pool (`--mode System`, the default for the pool created
by `az aks create`) for core cluster components like CoreDNS and metrics-server — application workloads
belong on a separate **user** node pool (`--mode User`) so it can be scaled, upgraded, or deleted
independently without touching system components. `--node-taints` on a system pool
(`CriticalAddonsOnly=true:NoSchedule` is the standard convention) plus a matching toleration on application
pods keeps workloads off the system pool entirely — see the scenario below.

## Enabling and configuring the cluster autoscaler on a node pool

```bash
az aks nodepool update \
  --resource-group my-rg --cluster-name my-cluster --name userpool \
  --enable-cluster-autoscaler --min-count 2 --max-count 8
az aks nodepool update \
  --resource-group my-rg --cluster-name my-cluster --name userpool \
  --disable-cluster-autoscaler
```

`--min-count`/`--max-count` only take effect with `--enable-cluster-autoscaler` — the fixed `--node-count`
from `az aks scale` is ignored once autoscaling is on, since the autoscaler owns the node count from that
point on. A manual `az aks scale` against an autoscaled pool can be immediately reverted by the autoscaler
on its next evaluation unless you also adjust `--min-count`/`--max-count`.

## Upgrading clusters and node pools

```bash
az aks get-upgrades --resource-group my-rg --name my-cluster --output table   # available Kubernetes
                                                                                 # versions for this cluster
az aks upgrade --resource-group my-rg --name my-cluster --kubernetes-version 1.31.1   # control plane +
                                                                                         # all node pools
az aks nodepool upgrade --resource-group my-rg --cluster-name my-cluster \
  --name userpool --kubernetes-version 1.31.1                                  # one node pool only
az aks update --resource-group my-rg --name my-cluster --auto-upgrade-channel stable
```

`az aks upgrade` without a targeted node pool upgrades the control plane *and* every node pool by default —
for a cluster where you want to validate the new control-plane version against production traffic before
touching worker nodes, upgrade node pools individually with `az aks nodepool upgrade` instead of the
all-in-one command. `--auto-upgrade-channel` (`patch`, `stable`, `rapid`, `node-image`) hands ongoing
version management to Azure on a schedule — `stable` is the common production default, avoiding both
falling behind on security patches and being first onto a brand-new minor version.

## Real-world scenario: isolating system pods from application workloads

A team's application pods were repeatedly getting scheduled onto the system node pool, competing with
CoreDNS for CPU during traffic spikes and causing intermittent DNS resolution failures cluster-wide.

```bash
az aks nodepool update --resource-group my-rg --cluster-name my-cluster \
  --name nodepool1 --node-taints "CriticalAddonsOnly=true:NoSchedule"
```

Paired with a toleration only on the handful of pods that legitimately need to run on the system pool (or
none — most application workloads need no toleration at all and will simply schedule onto the untainted
user pool). This is the standard AKS pattern: taint the system pool, leave user pools untainted, let the
scheduler naturally keep application workloads off critical infrastructure.

## Real-world scenario: a cost-optimized batch node pool with Spot VMs

A nightly batch job tolerates interruption but runs at real scale — a dedicated Spot-priced node pool with
a matching taint keeps it off normal on-demand nodes and off the on-demand billing rate:

```bash
az aks nodepool add \
  --resource-group my-rg --cluster-name my-cluster \
  --name batchspot --priority Spot --eviction-policy Delete \
  --node-vm-size Standard_D4s_v5 --enable-cluster-autoscaler --min-count 0 --max-count 20 \
  --node-taints "kubernetes.azure.com/scalesetpriority=spot:NoSchedule"
```

`--min-count 0` matters here — a Spot pool that can scale to zero costs nothing when the nightly job isn't
running, which is the point of using Spot at all. `--eviction-policy Delete` (vs. `Deallocate`) means an
evicted Spot node is removed rather than stopped-but-billed-for-disk, appropriate for genuinely stateless
batch workers.

## CI/CD integration recipe: GitHub Actions deploy to AKS

```yaml
# .github/workflows/deploy-aks.yml
name: Deploy to AKS
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: az aks get-credentials --resource-group my-rg --name my-cluster --overwrite-existing
      - run: kubectl apply -f k8s/
      - run: kubectl rollout status deployment/my-app --timeout=180s
```

`--overwrite-existing` matters on a shared CI runner image — without it, `get-credentials` fails instead of
replacing a stale kubeconfig entry left over from a previous job.

## Common pitfalls

- **Confusing `stop` with `deallocate`** — see the stateDiagram note above; `stop` keeps billing compute.
- **Deleting a VM and assuming its disks went with it** — they don't by default; see the WARNING above.
- **Scaling an autoscaled node pool with `az aks scale`** — the autoscaler can immediately revert it; adjust
  `--min-count`/`--max-count` instead.
- **Running `az aks upgrade` expecting it to touch only the control plane** — it upgrades every node pool
  too unless you use `az aks nodepool upgrade` per pool.
- **Using `--admin` credentials for routine kubectl access** — it bypasses Azure RBAC entirely; reserve it
  for break-glass scenarios.

## Exit codes

`0` success · non-zero on any API/validation error, including a node pool operation that times out waiting
for provisioning — add `--no-wait` on long-running create/scale/upgrade operations if the script shouldn't
block, then poll with `az aks show --query provisioningState` separately.

## When to reach for something else

Use `kubectl`/Helm/GitOps tooling (Argo CD, Flux) for anything happening *inside* the cluster once it
exists — `az aks` commands provision and manage the cluster and node pool shape, not workloads running on
it. For declarative, reviewable cluster provisioning across environments, prefer Bicep/Terraform's
`azurerm_kubernetes_cluster` over a growing shell script of `az aks` calls, the same tradeoff described on
page `01` for general resource provisioning.
