You close the module with the tool that will save you the most time during the rest of the course and the rest of your career in Azure: the command line. In the previous lesson it became clear that the portal, the CLI, PowerShell and templates all talk to the same Azure Resource Manager API. What changes is speed, precision and — above all — the ability to repeat exactly the same thing tomorrow.

Here you will learn to install and use Azure CLI, to query its output with --query, to work across several subscriptions without getting the environment wrong, when Azure PowerShell makes more sense, and how Cloud Shell works (and why it creates a storage account for you). And you will finish by writing a complete, commented, parameterized and idempotent script that deploys the base of Contoso Airlines' platform and knows how to delete it afterwards so you do not overspend.

Contents

  1. Why automate from the command line
  2. Installing Azure CLI and checking the version
  3. Signing in and working with several subscriptions
  4. Anatomy of an az command and built-in help
  5. Output formats and queries with --query
  6. Azure PowerShell and when to choose each tool
  7. Cloud Shell
  8. Complete script: the Contoso Airlines base
  9. Cleanup: deleting without leaving a trace of spend
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. Why automate from the command line

The portal is excellent for exploring. For working, it has three problems:

Problem with the portal What the command line brings
It is not repeatable: 40 clicks today, 40 different clicks tomorrow A script always produces the same result
It is not auditable: nobody knows which options you ticked The script lives in Git, is reviewed and is diffed
It does not scale: creating 20 identical resources is a wasted afternoon A for loop does it in a minute
It is slow for queries: "give me every VM without a propietario tag" has no button A --query expression solves it in one line

On top of that, the CLI is the natural bridge to what comes later: the deployment pipelines in module 5 run az commands, and the diagnostics in module 7 lean on command-line queries.

The golden rule, deliberately repeated: what you do once, do it in the portal; what you will do twice, write it down.

  1. Installing Azure CLI and checking the version

Azure CLI is cross-platform and written in Python, but it installs as a native package on each system.

Windows

# Recommended option: the Windows package manager.
winget install --exact --id Microsoft.AzureCLI

# Alternative with a silent MSI, useful in corporate rollouts.
# Download the official installer and run it with /quiet.

After installing, close and reopen the terminal so that the PATH variable is refreshed.

macOS

# With Homebrew, the most common package manager on macOS.
brew update && brew install azure-cli

Linux (Debian/Ubuntu)

# Official installation script: it adds the Microsoft repository and installs the package.
# Always review a script before running it with privileges (good general practice).
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

For RHEL, Fedora or CentOS you use dnf; for openSUSE, zypper. The official documentation covers each case.

Checking the installation

# Shows the version of the CLI, of its extensions and of Python.
az version

Sample output:

{
  "azure-cli": "2.64.0",
  "azure-cli-core": "2.64.0",
  "azure-cli-telemetry": "1.1.0",
  "extensions": {}
}

Keeping it up to date

# Upgrades the CLI to the latest stable version (works on most platforms).
az upgrade

# Turns off the telemetry prompt and interactive confirmations in scripts.
az config set core.collect_telemetry=false

Azure CLI is updated every few weeks. Working with a very old version causes "unrecognized arguments" errors when you follow recent documentation: if a parameter does not exist in your installation, the first thing to try is upgrading.

  1. Signing in and working with several subscriptions

# Opens the browser to authenticate interactively.
# After signing in, the CLI stores a local token and stops asking on every command.
az login

Useful variants:

# Without a browser (servers, SSH): it shows a code to enter at microsoft.com/devicelogin.
az login --use-device-code

# Specify the tenant when your identity belongs to several directories.
az login --tenant contoso-airlines.onmicrosoft.com

For unattended automation (CI/CD pipelines) you do not use interactive az login, but a service principal or a managed identity. They are covered in lessons 04-02 and 05-03. Never put user credentials in a script.

Several subscriptions: the most expensive mistake of all

If your identity has access to Contoso Airlines - Producción and to Contoso Airlines - Desarrollo, every az command runs against the active subscription. Getting this wrong is how things get deleted in production.

# See every accessible subscription. IsDefault marks the active one.
az account list --output table
Name                              CloudName    SubscriptionId                        State    IsDefault
--------------------------------  -----------  ------------------------------------  -------  ---------
Contoso Airlines - Producción     AzureCloud   8f4c2b7a-1d3e-4a55-9c11-0a7b6e2d4f90  Enabled  True
Contoso Airlines - Desarrollo     AzureCloud   3b91e5d0-7c42-4f88-a2e6-5d9c1a8b3e77  Enabled  False
# Change the active subscription (it accepts a name or an identifier).
az account set --subscription "Contoso Airlines - Desarrollo"

# Confirm where you are before touching anything. Make it a habit.
az account show --query "{Subscription:name, Id:id, User:user.name}" --output table

Two practical defenses:

  1. Start all your scripts by setting the subscription explicitly with az account set. Do not trust whichever one happens to be active.
  2. Use the --subscription parameter on critical commands, so you do not depend on global state:
az group delete --name rg-contoso-reservas-dev \
  --subscription "Contoso Airlines - Desarrollo" --yes

  1. Anatomy of an az command and built-in help

Every Azure CLI command follows the same structure:

az <group> [<subgroup>] <command> [--parameters]

A worked example:

az storage account create --name sttarjetascontosodev --resource-group rg-contoso-reservas-dev --location westeurope --sku Standard_LRS
Part Value What it is
az — The program
storage group The service family
account subgroup The specific object within that family
create command The action (create, list, show, update, delete)
--name, --resource-group… parameters The data for the operation

The verbs repeat throughout the CLI, and that makes it predictable: if you know az vm list, you can guess az storage account list, az webapp list and az sql db list.

Common abbreviations: -n for --name, -g for --resource-group, -l for --location, -o for --output.

Built-in help

# Help for a group: shows the available subgroups and commands.
az storage --help

# Help for a specific command: lists ALL its parameters, which ones are required, and examples.
az storage account create --help

# Smart natural-language search, with real usage examples.
az find "az storage account"
az find "create a storage account"

And interactive mode, very useful while you are learning (it requires installing the extension):

# Autocompletion, parameter descriptions and examples as you type.
az interactive

  1. Output formats and queries with --query

By default, the CLI returns JSON, which is complete but awkward to read. The --output (or -o) parameter changes the format:

Value Result When to use it
json (default) Formatted JSON Seeing every available field
jsonc Colorized JSON Reading in a terminal
table A readable table Daily use and quick reports
tsv Tab-separated values, with no headers or quotes Scripts: feeding variables
yaml YAML Readable configurations
none Nothing When all that matters is that the operation worked

JMESPath: the language of --query

--query uses JMESPath, a query language for JSON. Let us work through progressive examples over Contoso's resources.

Step 1 — select a property of an object:

# Returns only a resource group's location, without quotes (tsv), ready for a variable.
az group show --name rg-contoso-reservas-dev --query location --output tsv

Step 2 — walk a list and keep one field:

# Names of every resource group. [] walks the list.
az group list --query "[].name" --output tsv

Step 3 — build an object with your own names:

# Renames the output fields. The keys on the left are labels of your own.
az group list \
  --query "[].{Name:name, Region:location, State:properties.provisioningState}" \
  --output table
Name                       Region      State
-------------------------  ----------  ---------
rg-contoso-reservas-dev    westeurope  Succeeded
rg-contoso-reservas-pro    westeurope  Succeeded

Step 4 — filter with conditions (?):

# Only the groups tagged as production.
az group list \
  --query "[?tags.entorno=='produccion'].{Name:name, Owner:tags.propietario}" \
  --output table

# Only the storage accounts in West Europe.
az storage account list \
  --query "[?location=='westeurope'].{Account:name, Group:resourceGroup, Sku:sku.name}" \
  --output table

Step 5 — spot what is missing (governance audit):

# Resources WITHOUT the mandatory "propietario" tag: the report Marta Ríos asks for.
# == `null` compares against null; the backticks mark a JSON literal.
az resource list \
  --query "[?tags.propietario == \`null\`].{Resource:name, Type:type, Group:resourceGroup}" \
  --output table

Step 6 — combine with functions and sorting:

# Count how many resources there are in total.
az resource list --query "length(@)" --output tsv

# The resources sorted by name, showing only type and group.
az resource list \
  --query "sort_by([].{Name:name, Type:type, Group:resourceGroup}, &Name)" \
  --output table

Step 7 — use the result in a script:

# Store a value in a shell variable. --output tsv avoids the JSON quotes.
GROUP_REGION=$(az group show --name rg-contoso-reservas-dev --query location --output tsv)
echo "The group is in: $GROUP_REGION"

Learning trick: run the command without --query and with --output json first to see the full structure, and only then write the query. It is far quicker than guessing field names.

  1. Azure PowerShell and when to choose each tool

Azure PowerShell is the other official client, based on the Az module. It does the same as the CLI, but with the PowerShell philosophy: instead of text, it returns objects with properties that you can operate on directly.

Installation and first commands

# Installs the Az module for the current user (no administrator permissions required).
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force

# Sign in (opens the browser).
Connect-AzAccount

# See the available subscriptions and select one.
Get-AzSubscription
Set-AzContext -Subscription "Contoso Airlines - Desarrollo"

# Create Contoso's resource group with its tags.
New-AzResourceGroup -Name "rg-contoso-reservas-dev" -Location "westeurope" -Tag @{
    entorno       = "desarrollo"
    proyecto      = "contoso-reservas"
    "centro-coste" = "CC-1042"
    propietario   = "[email protected]"
}

# List resources and filter by tag, taking advantage of PowerShell objects.
Get-AzResource -TagName "proyecto" -TagValue "contoso-reservas" |
    Select-Object Name, ResourceType, ResourceGroupName |
    Format-Table

Note the difference in philosophy: in the CLI you filter with --query (JMESPath over JSON); in PowerShell you filter with Where-Object and select with Select-Object, because you already have objects.

Comparison

Aspect Azure CLI (az) Azure PowerShell (Az)
Syntax az group subgroup command Verb-AzNoun (New-AzResourceGroup)
Output JSON (text) .NET objects
Filtering --query with JMESPath Where-Object, Select-Object
Native shell Bash, Zsh; works in any shell PowerShell (Windows, Linux and macOS)
Entry curve Gentler if you come from Linux Gentler if you come from Windows
Integration Excellent in cross-platform pipelines and containers Excellent with Windows Server, Microsoft 365 and Exchange
Microsoft documentation Examples in both Examples in both

How to choose, without dogma:

  • If your team is made up of Windows systems administrators who already automate with PowerShell, use PowerShell.
  • If your team comes from Linux, Docker or cross-platform development, use the CLI.
  • If you write scripts for CI/CD pipelines in Linux containers, the CLI is usually lighter and more direct.
  • What matters is being consistent within a single project. At Contoso, Diego and Marta agree to use Azure CLI, and that is the course's choice from here on.

  1. Cloud Shell

Azure Cloud Shell is a terminal hosted in Azure, reachable from the >_ icon in the portal's top bar (or at shell.azure.com). Immediate advantages:

  • It is already authenticated with your identity: there is no need to run az login.
  • It comes with the tools installed and up to date: Azure CLI, Azure PowerShell, Bicep, Terraform, kubectl, Git, Python, Node.js, and editors such as vim, nano and the built-in graphical editor (code .).
  • It works from any browser, including a phone.

Bash or PowerShell

You can choose the interpreter in the dropdown at the top and switch at any time. The az commands work in both; PowerShell's Az* cmdlets, only in PowerShell mode.

The storage account it creates

The first time you open Cloud Shell, it asks you to create (or attach) a storage account. This is not a whim:

  • Cloud Shell runs in an ephemeral container: when the session ends, the container is destroyed.
  • So that your files survive, it mounts an Azure Files share on your $HOME directory, inside a disk image usually called acc_<user>.img.
  • What you save in $HOME (~/clouddrive) persists between sessions; what you install outside it does not.

Cost warning: that storage account is a resource of yours and it is billed, even though the amount is very small (a few cents a month for a few GB). Cloud Shell itself costs nothing; its storage does. If you stop using it, you can delete the resource group that gets created automatically (it is usually called cloud-shell-storage-<region>).

Other features and limits

Aspect Detail
Idle timeout The session closes after about 20 minutes without interaction
Persistence Only $HOME / clouddrive; the rest of the file system is lost
Uploading and downloading files The Upload/Download button in the Cloud Shell bar
Built-in editor code . opens a graphical editor inside the browser
Simultaneous sessions Limited; one Bash session and one PowerShell session at a time
Storage region Chosen when you create it; it is worth putting it close to you

When to use Cloud Shell: for learning (you install nothing), for one-off tasks from somebody else's computer, for emergencies from a phone. When not to: for intensive daily work or long scripts, where a local CLI with your editor and your Git repository is more comfortable.

  1. Complete script: the Contoso Airlines base

Now for the module's central exercise: a script that creates the whole base of Contoso Airlines' platform. It is parameterized (everything in variables at the top), idempotent (it can be run several times without breaking anything or duplicating resources) and commented line by line.

Save it as desplegar-base-contoso.sh.

#!/usr/bin/env bash
# =============================================================================
# desplegar-base-contoso.sh
# Creates the base of the Contoso Airlines platform:
#   - Resource group with the mandatory tags
#   - Storage account for the boarding passes in PDF
#   - Private container "tarjetas-embarque"
#
# It is IDEMPOTENT: running it twice leaves the same result as running it once.
# Usage:  ./desplegar-base-contoso.sh [dev|pro]
# =============================================================================

# 'set -e' aborts the script on the first error instead of carrying on blindly.
# 'set -u' aborts if an undefined variable is used (it catches dangerous typos).
# 'set -o pipefail' propagates the error of any command inside a pipeline.
set -euo pipefail

# ------------------------------------------------------------------
# 1. PARAMETERS. Everything configurable, in one place and at the top.
# ------------------------------------------------------------------
ENVIRONMENT="${1:-dev}"                   # First argument; "dev" if not given
SUBSCRIPTION="Contoso Airlines - Desarrollo"
REGION="westeurope"                       # Primary region decided in lesson 01-02
PROJECT="contoso-reservas"
COST_CENTER="CC-1042"
OWNER="[email protected]"

# Derived names, following Contoso's naming convention.
GROUP="rg-${PROJECT}-${ENVIRONMENT}"       # e.g. rg-contoso-reservas-dev
# Storage accounts only accept lowercase letters and numbers (3-24 characters)
# and their name is UNIQUE ACROSS THE WHOLE OF AZURE: we add a stable random suffix just in case.
SUFFIX="$(echo -n "${GROUP}" | cksum | cut -c1-4)"
STORAGE_ACCOUNT="sttarjetascontoso${ENVIRONMENT}${SUFFIX}"
CONTAINER="tarjetas-embarque"

# Translation of the short environment into the tag value Contoso's scheme requires.
if [ "${ENVIRONMENT}" = "pro" ]; then
  ENV_TAG="produccion"
else
  ENV_TAG="desarrollo"
fi

echo "=== Deploying the Contoso Airlines base (${ENV_TAG}) ==="

# ------------------------------------------------------------------
# 2. CONTEXT. Never trust whichever subscription happened to be active.
# ------------------------------------------------------------------
az account set --subscription "${SUBSCRIPTION}"
echo "Active subscription: $(az account show --query name --output tsv)"

# ------------------------------------------------------------------
# 3. PROVIDERS. Needed in new subscriptions; repeating it is harmless.
# ------------------------------------------------------------------
az provider register --namespace Microsoft.Storage --wait

# ------------------------------------------------------------------
# 4. RESOURCE GROUP.
#    'az group create' is already idempotent: if it exists, it updates its tags.
# ------------------------------------------------------------------
echo "--> Resource group: ${GROUP}"
az group create \
  --name "${GROUP}" \
  --location "${REGION}" \
  --tags entorno="${ENV_TAG}" \
         proyecto="${PROJECT}" \
         centro-coste="${COST_CENTER}" \
         propietario="${OWNER}" \
  --output none

# ------------------------------------------------------------------
# 5. STORAGE ACCOUNT.
#    We check first whether it exists, so that the script is idempotent
#    and so that it does not fail if the global name is already taken by somebody else.
# ------------------------------------------------------------------
echo "--> Storage account: ${STORAGE_ACCOUNT}"
if az storage account show --name "${STORAGE_ACCOUNT}" --resource-group "${GROUP}" --output none 2>/dev/null; then
  echo "    Already exists: it is not created again."
else
  # --sku Standard_LRS  : the cheapest option (local redundancy). Module 2.
  # --kind StorageV2    : the current account type, supporting blobs, queues, tables and files.
  # --min-tls-version   : enforces TLS 1.2 as a minimum.
  # --allow-blob-public-access false : NOBODY can read the PDFs anonymously.
  # --https-only true   : rejects unencrypted traffic.
  az storage account create \
    --name "${STORAGE_ACCOUNT}" \
    --resource-group "${GROUP}" \
    --location "${REGION}" \
    --sku Standard_LRS \
    --kind StorageV2 \
    --min-tls-version TLS1_2 \
    --https-only true \
    --allow-blob-public-access false \
    --tags entorno="${ENV_TAG}" \
           proyecto="${PROJECT}" \
           centro-coste="${COST_CENTER}" \
           propietario="${OWNER}" \
    --output none
  echo "    Created."
fi

# ------------------------------------------------------------------
# 6. BLOB CONTAINER.
#    --auth-mode login uses YOUR Entra ID identity instead of the account
#    key: it is the recommended approach and it exposes no secrets in the script.
#    (It requires a data role, e.g. "Storage Blob Data Contributor".)
# ------------------------------------------------------------------
echo "--> Container: ${CONTAINER}"
az storage container create \
  --name "${CONTAINER}" \
  --account-name "${STORAGE_ACCOUNT}" \
  --auth-mode login \
  --public-access off \
  --output none

# ------------------------------------------------------------------
# 7. SUMMARY. A final readable check of what was deployed.
# ------------------------------------------------------------------
echo ""
echo "=== Deployment complete ==="
az resource list \
  --resource-group "${GROUP}" \
  --query "[].{Resource:name, Type:type, Region:location}" \
  --output table

echo ""
echo "To delete EVERYTHING created and stop paying, run:"
echo "  az group delete --name ${GROUP} --yes --no-wait"

How to run it

# Grant execute permissions (only the first time).
chmod +x desplegar-base-contoso.sh

# Deploy the development environment.
./desplegar-base-contoso.sh dev

What makes this script idempotent

Technique used Why it matters
az group create on an existing group It does not fail: it updates its tags
A prior check with az storage account show It avoids the "name already in use" error on a re-run
Names derived from variables, not hard-coded The same script serves both dev and pro
set -euo pipefail It stops the script on the first failure instead of leaving a half-finished deployment
--output none Clean output; only what we decide gets printed

If you look closely, this script does something very similar to an infrastructure-as-code template, but imperatively (step by step). The next level is doing it declaratively with Bicep, and that is exactly lesson 05-06.

  1. Cleanup: deleting without leaving a trace of spend

This section is mandatory, not optional. The cost of what you have created in this module is a matter of cents, but the habit is worth real money once you are working with virtual machines and databases.

# Deletes the group and ABSOLUTELY EVERYTHING it contains. It is irreversible.
# --yes     : does not ask for confirmation (use it only when you are sure).
# --no-wait : returns control immediately; the deletion continues in the background.
az group delete --name rg-contoso-reservas-dev --yes --no-wait

Checks before and after:

# Before: see exactly what is going to be destroyed.
az resource list --resource-group rg-contoso-reservas-dev --output table

# After: confirm that the group no longer exists (it returns "false").
az group exists --name rg-contoso-reservas-dev

And a hygiene check worth running periodically across the whole subscription:

# Lists ALL resource groups with their tags: hunting for orphans and forgotten items.
az group list --query "[].{Group:name, Region:location, Environment:tags.entorno, Owner:tags.propietario}" --output table

# Look for disks that are no longer attached to any virtual machine: pure spend.
az disk list --query "[?diskState=='Unattached'].{Disk:name, Group:resourceGroup, GB:diskSizeGb}" --output table

# Look for unassociated public IP addresses: they are billed too.
az network public-ip list --query "[?ipConfiguration==null].{IP:name, Group:resourceGroup}" --output table

Those last three commands are, literally, three of the most common sources of wasted spend in real Azure accounts. Keep them.

Remember as well: if you created a CanNotDelete lock in the previous lesson, the deletion will fail until you remove it with az lock delete. It is designed to work that way.

Common Mistakes and Tips

  • Running commands in the wrong subscription. Always start with az account set and confirm with az account show. It is the mistake that does the most damage.
  • Working with an out-of-date CLI. If a parameter "does not exist", run az upgrade before searching the forums.
  • Using uppercase letters or hyphens in the storage account name. Lowercase letters and numbers only, 3-24 characters, and unique across the whole of Azure.
  • Using --output json inside scripts. To feed variables use --output tsv: it avoids quotes and unexpected line breaks.
  • Writing access keys or passwords inside the script. Use --auth-mode login, managed identities (lesson 04-02) or Key Vault (lesson 04-03).
  • Forgetting that Cloud Shell closes after 20 minutes of inactivity and that only $HOME persists. Save your work in ~/clouddrive or, better still, in a Git repository.
  • Forgetting that Cloud Shell storage is billed. It is little money, but if you stop using it, delete it.
  • Writing --query expressions blind. Run the command in JSON first, look at the structure and then query.
  • Deleting with --yes without looking first. Run az resource list --resource-group ... to see what will disappear.
  • Final cost tip: get into the habit of ending every practice session with az group delete. One resource group per exercise, deleted at the end: it is the most profitable discipline you can pick up in this course.

Exercises

Exercise 1: Governance queries

Write the Azure CLI commands that fulfill these requests from Marta Ríos:

  1. Show in a table the name and region of every resource group in the active subscription.
  2. List every resource tagged with proyecto=contoso-reservas, showing name, type and group.
  3. Detect the resources that do not have the centro-coste tag.
  4. Store in a shell variable called PROD_REGION the region of the rg-contoso-reservas-pro group.

Exercise 2: Adapting the script

Starting from the desplegar-base-contoso.sh script in section 8:

  1. Add a CRITICALITY variable and apply it as a tag on both the group and the storage account, with the value alta if the environment is pro and baja otherwise.
  2. Add a step that, only if the environment is pro, applies a CanNotDelete lock to the group.
  3. Explain why step 3 of the script (az provider register) is safe even though it runs every time.

Exercise 3: Comparing tools and cleaning up

  1. Write the Azure PowerShell equivalent of these two CLI commands:
az group create --name rg-contoso-millas-dev --location westeurope --tags entorno=desarrollo proyecto=contoso-millas
az group delete --name rg-contoso-millas-dev --yes
  1. Justify in three lines which tool you would choose for a CI/CD pipeline running in a Linux container, and why.
  2. Write the commands that audit a subscription looking for unattached disks and unassociated public IP addresses, and explain why those two resources are classic sources of wasted spend.

Solutions

Solution 1:

# 1. Resource groups with name and region.
az group list --query "[].{Group:name, Region:location}" --output table

# 2. Resources belonging to the project.
az resource list --tag proyecto=contoso-reservas \
  --query "[].{Resource:name, Type:type, Group:resourceGroup}" --output table

# 3. Resources WITHOUT the centro-coste tag.
az resource list \
  --query "[?tags.\"centro-coste\" == \`null\`].{Resource:name, Type:type, Group:resourceGroup}" \
  --output table

# 4. The production group's region in a variable.
PROD_REGION=$(az group show --name rg-contoso-reservas-pro --query location --output tsv)
echo "$PROD_REGION"

A note on point 3: because the tag name contains a hyphen, it has to be quoted inside the JMESPath expression.

Solution 2:

  1. Criticality variable and tag:
# Alongside the rest of the parameters:
if [ "${ENVIRONMENT}" = "pro" ]; then
  CRITICALITY="alta"
else
  CRITICALITY="baja"
fi

# And it gets added to the --tags block of az group create and az storage account create:
#   criticidad="${CRITICALITY}"
  1. Conditional lock in production:
if [ "${ENVIRONMENT}" = "pro" ]; then
  echo "--> Applying a CanNotDelete lock to the production group"
  # 'az lock create' fails if a lock with that name already exists;
  # we check first to keep the script idempotent.
  if ! az lock show --name "no-borrar-produccion" --resource-group "${GROUP}" --output none 2>/dev/null; then
    az lock create \
      --name "no-borrar-produccion" \
      --lock-type CanNotDelete \
      --resource-group "${GROUP}" \
      --notes "Sales platform in production. Remove only with authorization." \
      --output none
  fi
fi
  1. az provider register is idempotent: if the provider is already registered, the operation changes nothing and completes successfully. Registering it costs no money and has no side effects, so including it guarantees that the script also works in a freshly created subscription.

Solution 3:

  1. Azure PowerShell equivalents:
New-AzResourceGroup -Name "rg-contoso-millas-dev" -Location "westeurope" -Tag @{
    entorno  = "desarrollo"
    proyecto = "contoso-millas"
}

Remove-AzResourceGroup -Name "rg-contoso-millas-dev" -Force
  1. For a pipeline in a Linux container I would choose Azure CLI: the image is lighter than installing PowerShell's Az module, the syntax fits naturally with Bash, most pipeline tasks are one-liners, and practically every Azure DevOps and GitHub Actions pipeline example is written with az.

  2. Wasted-spend audit:

az disk list --query "[?diskState=='Unattached'].{Disk:name, Group:resourceGroup, GB:diskSizeGb}" --output table
az network public-ip list --query "[?ipConfiguration==null].{IP:name, Group:resourceGroup}" --output table

They are classic sources of spend because they are billed for existing, not for being used. When you delete a virtual machine, its managed disks and its public IP are not removed automatically unless the corresponding option was ticked: they are left orphaned, invisible in day-to-day work and charging every month. A monthly review with these two commands is usually the most profitable and quickest optimization to apply.

Conclusion

With this lesson you close module 1 and your toolbox is complete. You know why to automate (repeatable, auditable, scalable), you have installed Azure CLI and checked its version, and you know how to sign in and — very importantly — how to switch and confirm the active subscription before touching anything. You know the anatomy of an az command and its built-in help (--help, az find), and you can use --output table to read and --query with JMESPath to filter, rename fields, spot untagged resources and feed variables in scripts. You know what Azure PowerShell brings and on what basis to choose between the two, and how Cloud Shell works: its two interpreters, the storage account it creates and bills for, persistence limited to $HOME and the idle timeout. And you have written a real, parameterized, commented and idempotent script that creates the rg-contoso-reservas-dev group, the boarding pass storage account and its private container, with Contoso Airlines' mandatory tags, plus the cleanup routine that prevents surprise bills.

Recapping the whole module: you have understood what the cloud is and what Azure is and met the Contoso Airlines case; you have distinguished IaaS, PaaS, SaaS and serverless, the shared responsibility model and Azure's geography, settling on West Europe as the primary region and North Europe as its pair; you have created and protected your account with MFA and a budget with alerts; you can find your way confidently around the portal; you have a command of Azure Resource Manager, the subscription, resource group and resource hierarchy, the locks and Contoso's tagging scheme; and now you automate from the command line.

You have the foundations: the account, the governance, the naming, the resource groups and the tool to deploy with. What is missing is the platform itself. In module 2, Core Azure Services, we start building it for real: we will deploy the virtual machines for the legacy availability engine, we will learn to scale and provide high availability for compute, we will publish Contoso Bookings and the Availability API on App Service, we will store boarding passes in Azure Storage with the right redundancy, and we will build the virtual network with its subnets and security groups, along with hybrid connectivity to the Barcelona and Palma offices. See you there.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved