Deploy Secure AlmaLinux with Storage

Deploy a secure AlmaLinux VM, data disk, and private blob storage with Bicep.

Introduction

30 Second Summary

Manual server setup is difficult to repeat. One rushed click can leave the wrong door open to the internet.

In this project, you will use Bicep to deploy AlmaLinux OS 9 on Microsoft Azure. Your finished server accepts SSH only from your current public IPv4 address.

What You'll Build

Picture a Windows PowerShell window showing AlmaLinux OS 9 after your secure connection succeeds.

By the end of this project, you'll have:

  • A running AlmaLinux OS 9 server whose running state you can inspect in Azure.
  • A key-based SSH connection from Windows PowerShell. Port 22 accepts traffic only from your trusted IPv4 /32 address.
  • A repeatable Bicep deployment that previews changes with What-If. Reapplying the template shows no intended resource creates or deletes.
  • Secret Mission: Add a validated environment parameter with consistent resource tags. Preview the exact tag changes without creating a second virtual machine.

Are there any prerequisites?

Your machine needs Windows 10 build 1809 or later or Windows 11. You also need permission to install optional features.

Azure signup requires a phone number plus a supported payment card. You can register with a Microsoft or GitHub account.

Before We Start

Before any hands-on work, take a moment to define what you are building. Your sentence connects a securely reachable AlmaLinux OS 9 server built with Bicep to your reason for valuing repeatable infrastructure.

Set Up Azure and Windows SSH

A repeatable server deployment needs a verified Microsoft Azure identity. It also needs a private SSH key that never leaves your Windows machine.

Azure Cloud Shell provides authenticated Azure CLI and Bicep CLI tools. Windows OpenSSH keeps the private key on your computer while Azure receives only the public key.

In this step, get ready to:
  • Create your Azure account and verify the authenticated Cloud Shell session.
  • Install Windows OpenSSH Client and confirm the SSH command works.
  • Generate an ED25519 key pair and upload only its public key to Cloud Shell.
Create and verify your Azure session

The signup flow runs in Microsoft Edge. Your Cloud Shell session then gives you authenticated access to the subscription you create.

Azure asks for a phone number and a supported payment card during signup. This step creates your account without deploying infrastructure that can accrue usage charges.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge and press Enter to open it.
  • Go to the Azure free account page.
  • Start the Azure account signup from that page.
  • Complete the phone number verification when the signup flow requests it.
  • Complete the payment card verification when the signup flow requests it.
  • Finish account creation with your Microsoft or GitHub account.

Your Azure identity and subscription are now ready. The next check confirms which subscription Cloud Shell uses.

  • Open the Azure portal in Microsoft Edge.
  • Sign in with the identity you used during signup.
  • Select Cloud Shell in the portal header.
  • Choose Bash as the shell environment.
  • Select No storage account required for this short ephemeral session.

Why use an ephemeral Cloud Shell session?

Cloud Shell automatically authenticates Azure CLI with your Azure account. The ephemeral option avoids creating a storage account for files you only need during this project.

Ephemeral files disappear when the session ends. You will download the finished Bicep template before cleanup.

  • Confirm the active subscription and inspect both preinstalled tool versions by running these commands:
az account show --output table
az --version
az bicep version

What do these checks prove?

  • The first command displays the current default subscription in table format.
  • The second command prints the installed Azure CLI version.
  • The third command prints the Bicep CLI version managed through Azure CLI.

Choose the tab that matches the output in your Cloud Shell session.

✔️ I see the expected versions

Your Cloud Shell should report Azure CLI 2.90.0 and Bicep CLI 0.46.1. Your subscription table should identify the subscription that receives this project's resources.

Good progress. Your Azure session is authenticated and has the deployment tools this project uses.

ⓧ I see older versions

  • Close the current Cloud Shell panel.
  • Select Cloud Shell again in the portal header.
  • Choose Bash.
  • Select No storage account required.
  • Check the fresh session by running:
az account show --output table
az --version
az bicep version

Why start a fresh session?

A fresh ephemeral session loads the current Cloud Shell image. The checks confirm its authenticated subscription and installed tool versions.

Continue when the output reports Azure CLI 2.90.0 and Bicep CLI 0.46.1.

Still seeing older versions?

Confirm that the portal opened a Bash Cloud Shell session. A stale session can preserve an older environment.

Still stuck? Help me diagnose why Azure Cloud Shell is reporting unexpected Azure CLI or Bicep CLI versions.

ⓧ A command is unavailable

  • Close the current Cloud Shell panel.
  • Select Cloud Shell again in the portal header.
  • Choose Bash.
  • Select No storage account required.
  • Retry all three checks by running:
az account show --output table
az --version
az bicep version

What should the new session provide?

Azure Cloud Shell includes Azure CLI and Bicep CLI. These commands confirm that the tools are available inside the Bash session.

All three commands should now return subscription or version information.

Command still unavailable?

Check that the Cloud Shell selector shows Bash. Close the session once more if it opened a different shell environment.

Need another pair of eyes? Help me troubleshoot a missing command in Azure Cloud Shell.

Install and verify Windows OpenSSH

Windows PowerShell manages the optional OpenSSH Client capability. The capability check shows whether your Windows machine can create keys and connect to the server later.

  • Press the Windows key to open Windows search.
  • Type Windows PowerShell.
  • Choose the option that opens Windows PowerShell with administrator privileges.
  • Approve the Windows permission request if it appears.
  • Check the OpenSSH capabilities by running:
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

What does this check show?

The command lists the OpenSSH capabilities available to Windows. The client entry determines whether the ssh and ssh-keygen commands are ready.

Choose the tab that matches the OpenSSH Client entry.

✔️ OpenSSH Client is present

The OpenSSH Client capability is already available. Continue to the version check below.

ⓧ OpenSSH Client is NotPresent

  • Install the OpenSSH Client capability by running:
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0

What does this command install?

The command adds the Windows OpenSSH Client capability. It provides the local SSH connection and key-generation commands used in this project.

Wait until PowerShell finishes the capability installation before continuing.

Before you check the client, do you expect Windows to recognize the SSH command now?

  • Verify the Windows OpenSSH Client by running:
ssh -V

What does this check prove?

The command asks the Windows OpenSSH Client to print its version information. A version response confirms that PowerShell can find the client.

You should see OpenSSH version information in the PowerShell window. That confirms your Windows machine can use SSH commands.

No version information?

  • Confirm that the OpenSSH Client entry was present or installed in the capability check.
  • Close Windows PowerShell after installation if the current session does not recognize the new command.
  • Open a fresh Windows PowerShell session before retrying the version check.

Still stuck? Help me troubleshoot why ssh -V does not work after installing Windows OpenSSH Client.

Generate and upload the public key

ED25519 gives you a public key for Azure and a private key for Windows. The private file proves your identity when you connect to the server.

  • Close the elevated Windows PowerShell window.
  • Press the Windows key to open Windows search.
  • Type Windows PowerShell and press Enter to open a normal session.
  • Create the local SSH key directory by running:
New-Item -ItemType Directory -Path "$env:USERPROFILE\.ssh" -Force

What does this command do?

The command creates the .ssh directory inside your Windows user profile. The force option safely returns the directory if it already exists.

The key generator asks for a passphrase twice. Enter a private passphrase you can remember each time.

  • Generate the ED25519 key pair by running:
ssh-keygen -m PEM -t ed25519 -C "azureuser@alma-bicep-vm" -f "$env:USERPROFILE\.ssh\alma-azure"

What does this command create?

  • The ED25519 option selects the key algorithm supported by Azure.
  • The PEM option stores the private key in PEM format.
  • The comment identifies the future Linux account and virtual machine as azureuser@alma-bicep-vm.
  • The output path creates alma-azure as the private key and alma-azure.pub as the public key.

The two filenames differ only by .pub, so this upload is easy to misread. Your private key has no .pub suffix and must remain on Windows.

  • Press the Windows key to open Windows search.
  • Type File Explorer and press Enter to open it.
  • Select the File Explorer address field.
  • Enter %USERPROFILE%\.ssh to display the key directory.
  • Return to the Bash Cloud Shell session from earlier.
  • Select Manage files in the Cloud Shell toolbar.
  • Select Upload.
  • Choose only alma-azure.pub from the Windows .ssh directory.

Why upload only the public key?

Azure copies the public key into the virtual machine during deployment. Your private key stays on Windows and proves that you own the matching public key.

Cloud Shell places the uploaded public file in its home directory as ~/alma-azure.pub.

Before you run the final checks, do you expect both terminals to return version information without any additional setup?

  • Return to normal Windows PowerShell and verify the local SSH client again by running:
ssh -V

What does the Windows result confirm?

A version response confirms that Windows OpenSSH Client remains available in your normal PowerShell session. This is the session you use for the later server connection.

You should see OpenSSH version information printed in Windows PowerShell.

  • Switch back to Bash Cloud Shell and verify the Bicep CLI again by running:
az bicep version

What does the Cloud Shell result confirm?

The command confirms that Bicep CLI is available in the authenticated Cloud Shell session. You use this tool to process the template in the next step.

You should see Bicep CLI version 0.46.1.

  • Confirm that the Cloud Shell upload notification identifies alma-azure.pub in the home directory.

That is the security-critical setup complete. Azure can receive your public key while your private key remains on your Windows machine.

✔️ Awesome, I've got everything!

Your Azure session and Windows SSH setup are ready. Keep the normal Windows PowerShell session and Bash Cloud Shell available for the next step.

ⓧ I'd like to double check the full code

There is no application code to compare in this setup step. Confirm these three artifacts before continuing.

  • Confirm that %USERPROFILE%\.ssh\alma-azure is the locally held ED25519 PEM private key generated with comment azureuser@alma-bicep-vm. Keep this file on the Windows machine.
  • Confirm that %USERPROFILE%\.ssh\alma-azure.pub is the ED25519 public key corresponding to the locally held alma-azure private key.
  • Confirm that ~/alma-azure.pub is the uploaded copy of the Windows alma-azure.pub public key for the future adminPublicKey Bicep deployment parameter.

Your Azure identity is verified and your public key is ready for deployment. Next up, you will use Bicep to create a private AlmaLinux OS 9 virtual machine.

Deploy the Private AlmaLinux VM

Your Azure identity is confirmed. Your public SSH key is waiting in Azure Cloud Shell.

This first Bicep deployment proves that Azure can build your AlmaLinux OS 9 server inside a private network. Once it is running, you will test what that network boundary means from your Windows machine.

In this step, get ready to:
  • Check whether the preferred VM size is available for your subscription.
  • Create a private network template for an AlmaLinux OS 9 virtual machine.
  • Preview the infrastructure before deploying it into Azure.
Check the VM size

Azure VM availability depends on the subscription plus the selected region. You will check the preferred size before using it in the template.

  • Switch back to the Bash Cloud Shell session from earlier.
  • Set the resource group name plus deployment location plus preferred VM size by running these commands:
RG=rg-alma-bicep
LOCATION=eastus
VM_SIZE=Standard_B2ats_v2

What do these variables store?

  • The RG variable stores the resource group name used by every deployment command.
  • The LOCATION variable keeps the resources in eastus.
  • The VM_SIZE variable selects the preferred x86-64 size for the AlmaLinux image.
  • Check the preferred VM size against your subscription by running:
az vm list-skus --location "$LOCATION" --resource-type virtualMachines --size "$VM_SIZE" --all --output table

What does this command check?

The command lists regional availability plus subscription restrictions for the size stored in VM_SIZE. The Restrictions column determines which path you take next.

✔️ Restrictions is None

The preferred Standard_B2ats_v2 size is available. Eligible new Azure free accounts include this size in the applicable monthly VM allowance.

ⓧ The size is restricted

The fallback uses paid compute. You stay in control because the cleanup section shows how to delete the entire resource group when you finish.

  • Select the x86-64 fallback size by running these commands:
VM_SIZE=Standard_B2s_v2
az vm list-skus --location "$LOCATION" --resource-type virtualMachines --size "$VM_SIZE" --all --output table

What changes with the fallback?

The first line changes the selected size to Standard_B2s_v2. The second line repeats the availability check for that size.

Continue when the fallback result shows None in the Restrictions column.

Is the size check failing?

  • Confirm that your active Cloud Shell subscription is the one you verified earlier.
  • Check that LOCATION contains eastus.
  • Repeat the command after confirming that each variable name uses uppercase letters.

Still stuck? Help me diagnose why my Azure VM size check is failing.

Build the private Bicep template

A virtual network gives the VM an isolated address space. The first template connects the VM to a dynamic private address through its network interface.

  • Open main.bicep in the Cloud Shell editor by running:
code main.bicep

What does this command do?

The code command opens main.bicep in the integrated Cloud Shell editor. The file lives in your Cloud Shell home directory.

  • Use the full-code tab below to place the private deployment template into main.bicep.

✔️ Awesome, I've got everything!

Your private template should contain the parameters plus naming variables plus VNet plus private NIC plus VM plus two outputs.

ⓧ I'd like to double check the full code

  • Replace the contents of main.bicep with this private deployment template:
@description('Azure region for all resources.')
param location string = resourceGroup().location

@description('Virtual machine name.')
param vmName string = 'alma-bicep-vm'

@description('Linux administrator username.')
param adminUsername string = 'azureuser'

@description('SSH public key copied to the Linux authorized_keys file.')
param adminPublicKey string

@description('Single trusted IPv4 CIDR such as your public address followed by /32.')
param trustedCidr string

@description('Azure VM size. Use the free-eligible size when available to the subscription.')
@allowed([
  'Standard_B2ats_v2'
  'Standard_B2s_v2'
])
param vmSize string = 'Standard_B2ats_v2'

var vnetName = '${vmName}-vnet'
var subnetName = 'server-subnet'
var nsgName = '${vmName}-nsg'
var publicIpName = '${vmName}-pip'
var nicName = '${vmName}-nic'
var imageId = '/CommunityGalleries/almalinux-56b45196-f8a2-4e53-858a-bc4d0ebca440/Images/almalinux-9-gen2/Versions/latest'

resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.20.0.0/16'
      ]
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: '10.20.1.0/24'
        }
      }
    ]
  }
}

resource nic 'Microsoft.Network/networkInterfaces@2025-09-01' = {
  name: nicName
  location: location
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          primary: true
          privateIPAllocationMethod: 'Dynamic'
          subnet: {
            id: vnet.properties.subnets[0].id
          }
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2026-04-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: {
      vmSize: vmSize
    }
    storageProfile: {
      imageReference: {
        communityGalleryImageId: imageId
      }
      osDisk: {
        name: '${vmName}-osdisk'
        createOption: 'FromImage'
        caching: 'ReadWrite'
        deleteOption: 'Delete'
        managedDisk: {
          storageAccountType: 'StandardSSD_LRS'
        }
      }
    }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      linuxConfiguration: {
        disablePasswordAuthentication: true
        provisionVMAgent: true
        ssh: {
          publicKeys: [
            {
              path: '/home/${adminUsername}/.ssh/authorized_keys'
              keyData: adminPublicKey
            }
          ]
        }
      }
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
          properties: {
            primary: true
            deleteOption: 'Delete'
          }
        }
      ]
    }
    diagnosticsProfile: {
      bootDiagnostics: {
        enabled: true
      }
    }
  }
}

output vmName string = vm.name
output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress

How is the private deployment structured?

  • The parameters keep the region plus VM name plus administrator plus public key plus size configurable.
  • The VNet reserves 10.20.0.0/16 for the network. Its server-subnet subnet uses 10.20.1.0/24.
  • The NIC requests a dynamic private address. It has no public IP or Network Security Group association.
  • The VM uses the official AlmaLinux OS 9 Gen2 Community Gallery image. Password authentication stays disabled.
  • Save main.bicep in the Cloud Shell editor.
  • Check the editor for red error markers. You should see none.

Does the editor show an error?

  • Check that every opening brace has a matching closing brace.
  • Confirm that the NIC contains neither a publicIPAddress block nor a networkSecurityGroup block.
  • Compare the resource API versions against the full-file reference.

Need another pair of eyes? Help me find the Bicep syntax problem in my private VM template.

Preview and deploy the private VM

Bicep describes the desired state. Azure Resource Manager compares that state with the resource group before making changes.

  • Create the rg-alma-bicep resource group by running:
az group create --name "$RG" --location "$LOCATION"

What does this command create?

The command creates the resource group named by RG in the region stored by LOCATION. This resource group becomes the lifecycle boundary for the VM plus its network resources.

Before you preview the template, which three Azure resources do you expect the private deployment to propose?

  • Preview the private deployment by running:
az deployment group what-if --name alma-private-preview --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub vmSize="$VM_SIZE"

What does What-If prove?

What-If calculates the proposed resource changes without applying them. The file syntax passes through the deployment engine during this preview.

You should see proposed creations for the virtual network plus network interface plus virtual machine. The preview should not include a public IP or Network Security Group.

Before you deploy

The next command creates resources that can incur charges. Eligible new free accounts include up to 750 monthly hours of Standard_B2ats_v2 compute for 12 months.

The cleanup section shows how to remove the entire resource group when you finish. The paid fallback starts billing compute after deployment.

Azure deployments can take several minutes because some resources take longer to provision. Keep Cloud Shell open until the command returns.

  • Deploy the private AlmaLinux OS 9 server by running:
az deployment group create --name alma-private --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub vmSize="$VM_SIZE"

What does this deployment create?

  • The named deployment records this application of main.bicep as alma-private.
  • The @alma-azure.pub syntax passes the public key file contents into adminPublicKey.
  • The VM receives the selected size through vmSize.

You should see Succeeded as the deployment provisioning state. Azure has now created the private AlmaLinux OS 9 server plus its supporting network.

Did the deployment fail?

  • Check the first reported template error against the matching section in main.bicep.
  • Confirm that alma-azure.pub still appears in your Cloud Shell home directory.
  • Repeat the SKU check if the failure mentions regional capacity or subscription restrictions.

Still blocked? Help me troubleshoot my private AlmaLinux Bicep deployment.

Before you run the final check, do you think the VM details will include a public IP address?

  • Inspect the VM power state plus public IP field by running:
az vm show -d -g "$RG" -n alma-bicep-vm --query "{powerState:powerState,publicIps:publicIps}" -o json

What does this query inspect?

The query reduces the VM details to powerState plus publicIps. This makes the network boundary visible in a small JSON result.

You should see VM running for the power state. The publicIps value is empty.

That is the intended shortfall. Your server is running inside Azure, but your Windows machine has no internet-facing address it can use for SSH.

Is the VM missing from the result?

  • Confirm that RG still contains rg-alma-bicep.
  • Check the deployment output for a successful provisioning state.
  • Confirm that the VM name in main.bicep is alma-bicep-vm.

Need help reading the result? Help me find why az vm show cannot return my alma-bicep-vm state.

Your private AlmaLinux OS 9 VM is running with key-based authentication plus no public route. Next up, you will add a static public address plus an SSH rule restricted to your trusted IPv4 address.

Add a Secure SSH Route

The VM from the last step is running inside Azure. Its private-only network interface leaves your Windows machine with no internet route to the server.

Your Bicep template will add a static public IPv4 address. A Network Security Group will accept SSH traffic only from your current public IPv4 address.

In this step, get ready to:
  • Record your trusted public IPv4 address as a single-address CIDR.
  • Extend main.bicep with the secure SSH route.
  • Preview the network changes before deploying them.
Record your trusted IPv4 address

A CIDR ending in /32 represents one IPv4 address. Using your current address gives the SSH rule one trusted source.

  • Switch back to Microsoft Edge from earlier.
  • Visit https://api.ipify.org to display your Windows machine's public IPv4 address.
  • Record the displayed address with a /32 suffix here: your-ipv4-address/32.
  • Return to Cloud Shell Bash from earlier.
  • Store your trusted CIDR in the current Cloud Shell session by running:
TRUSTED_CIDR='[[TRUSTED_CIDR="your-ipv4-address/32"]]'

Why Use a /32 CIDR?

The TRUSTED_CIDR variable holds the only source address that your SSH rule accepts. The /32 suffix narrows the rule to one IPv4 address.

Cloud Shell returns to the prompt without printing a value. Your trusted CIDR is now ready for the deployment commands in this step.

Does the Address Look Wrong?

Use the address shown in Microsoft Edge on your Windows machine. The address seen by Cloud Shell belongs to a different network path.

Make sure the recorded value contains four IPv4 number groups followed by /32.

Still unsure? Help me check whether my trusted IPv4 CIDR is formatted correctly.

Add the public IP and network rule

The secure route needs two new resources. The NSG defines who may reach port 22. The static public IP gives your Windows SSH client a stable destination.

  • Switch back to main.bicep in the Cloud Shell editor from earlier.
  • Find the resource variables near the top of main.bicep.
  • Add this NSG resource below the variables:
resource nsg 'Microsoft.Network/networkSecurityGroups@2025-09-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'Allow-SSH-From-Trusted-CIDR'
        properties: {
          access: 'Allow'
          direction: 'Inbound'
          protocol: 'Tcp'
          priority: 1000
          sourceAddressPrefix: trustedCidr
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '22'
        }
      }
    ]
  }
}

What Does This Resource Do?

  • The rule permits inbound TCP traffic on destination port 22.
  • The sourceAddressPrefix property uses the trusted CIDR supplied during deployment.
  • The remaining source addresses receive no matching allow rule for SSH.
  • Save main.bicep.

Before you preview this change, what new resource do you expect Bicep to propose?

  • Preview the NSG addition by running this command in Cloud Shell Bash:
az deployment group what-if --name alma-secure-preview --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What Does This Preview Do?

The What-If operation compares main.bicep with the resources already deployed in rg-alma-bicep. It reports planned changes without applying them.

You should see a planned addition for alma-bicep-vm-nsg. That preview confirms the allowlist rule is valid before Azure creates it.

Does the NSG Preview Fail?

  • Check that the NSG resource sits outside every other resource block in main.bicep.
  • Check that TRUSTED_CIDR contains an IPv4 address followed by /32.
  • Check that alma-azure.pub remains in the Cloud Shell home directory.

Need another pair of eyes? Help me troubleshoot my NSG What-If preview.

  • Find the nic resource in main.bicep.
  • Add this public IP resource immediately above the nic resource:
resource publicIpResource 'Microsoft.Network/publicIPAddresses@2024-07-01' = {
  name: publicIpName
  location: location
  sku: {
    name: 'Standard'
    tier: 'Regional'
  }
  properties: {
    publicIPAddressVersion: 'IPv4'
    publicIPAllocationMethod: 'Static'
    idleTimeoutInMinutes: 4
    dnsSettings: {
      domainNameLabel: toLower('${vmName}-${uniqueString(resourceGroup().id)}')
    }
  }
}

What Does This Resource Do?

  • The Standard SKU creates the current public IP design for this server.
  • The Static allocation method keeps the same address while the resource exists.
  • The DNS label combines the VM name with a deterministic resource-group suffix.
  • Save main.bicep.
  • Preview both new resources by running the What-If command again:
az deployment group what-if --name alma-secure-preview --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What Changed in the Preview?

The preview now evaluates the NSG plus the Standard static public IP. Azure still makes no changes during this command.

You should see planned additions for alma-bicep-vm-nsg plus alma-bicep-vm-pip.

  • Find the existing nic resource in main.bicep.
  • Replace the complete nic resource with this version:
resource nic 'Microsoft.Network/networkInterfaces@2025-09-01' = {
  name: nicName
  location: location
  properties: {
    networkSecurityGroup: {
      id: nsg.id
    }
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          primary: true
          privateIPAllocationMethod: 'Dynamic'
          subnet: {
            id: vnet.properties.subnets[0].id
          }
          publicIPAddress: {
            id: publicIpResource.id
          }
        }
      }
    ]
  }
}

How Is the Route Connected?

  • The networkSecurityGroup reference attaches the SSH allowlist to the NIC.
  • The publicIPAddress reference attaches the static public IP to ipconfig1.
  • The NIC keeps its dynamic private address inside server-subnet.
  • Save main.bicep.
  • Preview the connected network path by running the What-If command again:
az deployment group what-if --name alma-secure-preview --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What Does the Connected Preview Prove?

The preview now includes the NIC associations alongside the two new resources. This is the complete network path from the static public IP through the NSG to the VM's NIC.

  • Scroll to the outputs at the bottom of main.bicep.
  • Find these existing output lines:
output vmName string = vm.name
output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress

What Do the Existing Outputs Expose?

These outputs return the VM name plus its private address. They do not yet give your Windows machine a copyable SSH destination.

  • Replace the two existing output lines with these four lines:
output vmName string = vm.name
output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress
output publicIp string = publicIpResource.properties.ipAddress
output sshTarget string = '${adminUsername}@${publicIpResource.properties.ipAddress}'

What Do the New Outputs Add?

The publicIp output returns the assigned IPv4 address. The sshTarget output combines that address with azureuser.

  • Save main.bicep.

✔️ Awesome, I've got everything!

Your saved main.bicep now declares the NSG plus the Standard static public IP. It also connects both resources to the NIC.

ⓧ I'd like to double check the full code

Compare your saved main.bicep with this complete secure deployment template.

@description('Azure region for all resources.')
param location string = resourceGroup().location

@description('Virtual machine name.')
param vmName string = 'alma-bicep-vm'

@description('Linux administrator username.')
param adminUsername string = 'azureuser'

@description('SSH public key copied to the Linux authorized_keys file.')
param adminPublicKey string

@description('Single trusted IPv4 CIDR such as your public address followed by /32.')
param trustedCidr string

@description('Azure VM size. Use the free-eligible size when available to the subscription.')
@allowed([
  'Standard_B2ats_v2'
  'Standard_B2s_v2'
])
param vmSize string = 'Standard_B2ats_v2'

var vnetName = '${vmName}-vnet'
var subnetName = 'server-subnet'
var nsgName = '${vmName}-nsg'
var publicIpName = '${vmName}-pip'
var nicName = '${vmName}-nic'
var imageId = '/CommunityGalleries/almalinux-56b45196-f8a2-4e53-858a-bc4d0ebca440/Images/almalinux-9-gen2/Versions/latest'

resource nsg 'Microsoft.Network/networkSecurityGroups@2025-09-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'Allow-SSH-From-Trusted-CIDR'
        properties: {
          access: 'Allow'
          direction: 'Inbound'
          protocol: 'Tcp'
          priority: 1000
          sourceAddressPrefix: trustedCidr
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '22'
        }
      }
    ]
  }
}

resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.20.0.0/16'
      ]
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: '10.20.1.0/24'
        }
      }
    ]
  }
}

resource publicIpResource 'Microsoft.Network/publicIPAddresses@2024-07-01' = {
  name: publicIpName
  location: location
  sku: {
    name: 'Standard'
    tier: 'Regional'
  }
  properties: {
    publicIPAddressVersion: 'IPv4'
    publicIPAllocationMethod: 'Static'
    idleTimeoutInMinutes: 4
    dnsSettings: {
      domainNameLabel: toLower('${vmName}-${uniqueString(resourceGroup().id)}')
    }
  }
}

resource nic 'Microsoft.Network/networkInterfaces@2025-09-01' = {
  name: nicName
  location: location
  properties: {
    networkSecurityGroup: {
      id: nsg.id
    }
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          primary: true
          privateIPAllocationMethod: 'Dynamic'
          subnet: {
            id: vnet.properties.subnets[0].id
          }
          publicIPAddress: {
            id: publicIpResource.id
          }
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2026-04-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: {
      vmSize: vmSize
    }
    storageProfile: {
      imageReference: {
        communityGalleryImageId: imageId
      }
      osDisk: {
        name: '${vmName}-osdisk'
        createOption: 'FromImage'
        caching: 'ReadWrite'
        deleteOption: 'Delete'
        managedDisk: {
          storageAccountType: 'StandardSSD_LRS'
        }
      }
    }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      linuxConfiguration: {
        disablePasswordAuthentication: true
        provisionVMAgent: true
        ssh: {
          publicKeys: [
            {
              path: '/home/${adminUsername}/.ssh/authorized_keys'
              keyData: adminPublicKey
            }
          ]
        }
      }
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
          properties: {
            primary: true
            deleteOption: 'Delete'
          }
        }
      ]
    }
    diagnosticsProfile: {
      bootDiagnostics: {
        enabled: true
      }
    }
  }
}

output vmName string = vm.name
output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress
output publicIp string = publicIpResource.properties.ipAddress
output sshTarget string = '${adminUsername}@${publicIpResource.properties.ipAddress}'

How to Use This Reference

Compare the resource order plus every property with your saved file. Pay close attention to the NSG references inside the NIC.

Preview and deploy the secure route

What-If gives you one final review before Azure changes the running environment. This check should show the new public IP plus the NSG without proposing a second VM.

Before you run the final preview, do you expect Azure to create another VM or update the network around the existing VM?

  • Run the final secure-route preview in Cloud Shell Bash:
az deployment group what-if --name alma-secure-preview --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What Should the Final Preview Show?

The preview should include the public IP plus NSG additions. It should also show the NIC changes that connect those resources.

The existing alma-bicep-vm remains the server in the desired state. The template does not declare a second VM.

This deployment adds a static public IP that can incur charges. The cleanup choices later in the project let you remove every resource when testing is complete.

  • Deploy the reviewed secure route by running:
az deployment group create --name alma-secure --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What Does This Deployment Apply?

Azure applies the desired state from main.bicep to the existing resource group. The deployment keeps the current VM while adding its controlled public route.

Cloud Shell can remain busy while Azure updates the network resources. A quiet terminal during this process does not mean the deployment has stopped.

You should see provisioningState reported as Succeeded. Your server now has a public route guarded by the trusted CIDR.

Did the Secure Deployment Fail?

  • Review the final What-If output for the first resource that could not be evaluated.
  • Confirm that the trustedCidr parameter receives the current TRUSTED_CIDR value.
  • Confirm that the NIC references nsg.id plus publicIpResource.id.

Still blocked? Help me diagnose my alma-secure Bicep deployment.

Before the final check, what two values do you expect the deployment outputs to return?

  • Display the public IP plus SSH target from the successful deployment by running:
az deployment group show -g "$RG" -n alma-secure --query "properties.outputs.{publicIp:publicIp.value,sshTarget:sshTarget.value}" -o table

What Does This Query Check?

The query reads the outputs stored with the alma-secure deployment. It selects only the public IP plus the ready-to-use SSH target.

You should see a public IPv4 address plus an SSH target beginning with azureuser@. This proves that the secure deployment produced the destination needed for the Windows connection.

Your existing AlmaLinux server now has a stable public destination with SSH restricted to your trusted IPv4 address. Next up, you will connect from Windows PowerShell and verify the operating system from inside the VM.

Connect and Verify AlmaLinux

Your secure SSH route is live. The Azure portal reports a successful deployment, but that status cannot prove the full login path from your Windows machine.

A real login tests the private key, trusted network route, VM agent, and guest operating system as one path. This step proves that every part works together.

You'll connect from Windows PowerShell after validating the server's identity. Inside the server, you'll confirm AlmaLinux OS 9 plus the state of firewalld.

In this step, get ready to:
  • Retrieve the public IPv4 address from the secure deployment output.
  • Validate the VM host fingerprint before accepting the first SSH connection.
  • Verify the guest operating system plus its host firewall inside the SSH session.
Use the deployment output

The secure deployment exposes its assigned public address through the publicIp output. Azure Cloud Shell can read that value directly from the completed alma-secure deployment.

  • Switch back to the Bash session in Azure Cloud Shell from earlier.
  • Store the deployment output in PUBLIC_IP by running these commands:
PUBLIC_IP=$(az deployment group show -g "$RG" -n alma-secure --query "properties.outputs.publicIp.value" -o tsv)
echo "$PUBLIC_IP"

What do these commands do?

The first command reads the publicIp output from the completed deployment. It stores the address in PUBLIC_IP for reuse inside Cloud Shell.

The second command prints the address without extra deployment data. This gives you the endpoint for the Windows connection.

You'll see one public IPv4 address in Cloud Shell. Good, you now have the exact endpoint your Windows machine will test.

  • Record the printed address here: your VM public IPv4 address.

No Public IP in the Output?

  • Confirm that you switched back to the Bash Cloud Shell session used for the deployment.
  • Confirm that the deployment name is alma-secure.
  • Confirm that RG still holds the resource group name from the earlier deployment step.

Still missing the address? Help me inspect the alma-secure deployment output.

Prove the server's identity

A host fingerprint identifies the SSH server that answered your connection. Comparing it through Azure Run Command protects you from approving an unexpected server.

Your private key stays in %USERPROFILE%\.ssh\alma-azure on Windows throughout this process. Only the corresponding public key exists on the VM.

  • Press the Windows key to open Windows search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open Windows PowerShell.

Before you connect, do you think Windows already knows this server's identity? Keep your prediction in mind.

  • Replace <public-ip> in the command below with your VM public IPv4 address.
  • Start the SSH connection from Windows PowerShell by running the edited command:
ssh -i "$env:USERPROFILE\.ssh\alma-azure" azureuser@<public-ip>

What does this command do?

The command asks SSH to authenticate as azureuser with the private key stored in alma-azure. The public IP directs the request through the restricted internet route you created.

Password authentication remains disabled on the VM. Possession of the matching private key is what proves your identity.

You'll see a first-connection prompt containing the server's fingerprint. Leave that prompt open without approving it.

  • Return to the Azure portal from earlier.
  • Enter alma-bicep-vm in the portal search bar.
  • Select alma-bicep-vm from the search results.
  • Select Run command under Operations on the VM page.
  • Select RunShellScript.
  • Paste the documented host-fingerprint command into the command box:
ssh-keygen -lf /etc/ssh/ssh_host_ecdsa_key.pub | awk '{print $2}'

What does this command do?

The command calculates the fingerprint of the VM's ECDSA public host key. The final filter returns the fingerprint value needed for your comparison.

Run Command executes through the VM agent. This gives you an Azure-controlled path to the server's identity before you trust the SSH connection.

  • Select Run to execute the script.
  • Switch back to the waiting Windows PowerShell prompt.
  • Compare its fingerprint with the value returned by Run Command.
  • Approve the PowerShell prompt only when the fingerprint values match.

You'll see a remote shell prompt for azureuser on alma-bicep-vm. That's the trust check complete. Your Windows machine has authenticated to the intended VM with the locally held private key.

SSH Connection Not Opening?

  • Stop without approving the connection if the two fingerprint values differ.
  • Confirm that your current Windows public IPv4 address still matches the address stored in TRUSTED_CIDR if the connection never reaches the fingerprint prompt.
  • Confirm that Windows PowerShell points to %USERPROFILE%\.ssh\alma-azure if key authentication fails.

Need another pair of eyes? Help me troubleshoot the SSH path to my AlmaLinux VM.

Inspect the guest operating system

The successful login proves that the key plus network path work. Two checks inside the session now confirm the guest identity plus its host firewall state.

Before you check, which operating system do you expect the release file to name? Keep your prediction in mind.

  • Read the operating system release information by running this command inside the SSH session:
cat /etc/os-release

What does this command do?

The /etc/os-release file contains the Linux distribution's release metadata. Reading it verifies the image that booted inside the VM.

You'll see release fields that identify AlmaLinux 9. This confirms that the Community Gallery image produced the intended guest operating system.

  • Check the host firewall state by running:
sudo firewall-cmd --state

What does this command do?

The command asks firewalld for its current state with administrator privileges. This checks the firewall service from inside the guest operating system.

You should see running in the SSH session. Excellent, your AlmaLinux OS 9 guest is reachable with key authentication while its host firewall remains active.

  • Close the remote session by running:
exit

What does this command do?

The exit command closes the remote shell without stopping the VM. Your Azure resources continue running after the connection ends.

You'll return to your local Windows PowerShell prompt. The connection test is complete without changing any Azure resources or the main.bicep template.

Your private key, restricted SSH route, VM agent, and AlmaLinux guest have all passed a live connection test. Next up, you'll use Bicep What-If to prove that applying the same desired state remains controlled and predictable.

Prove Repeatable Deployment

The SSH session proved that your AlmaLinux OS 9 server is reachable through its restricted network route. The template now needs to prove that it can preserve this working state.

Repeatable infrastructure as code lets you review the same desired state without accidentally duplicating or destroying the server. In this step, Azure What-If reveals the proposed changes before you save the completed template outside the ephemeral Cloud Shell session.

In this step, get ready to:
  • Preview the secure deployment again to check for unintended creates or deletes.
  • Download the completed main.bicep template to Windows.
  • Review the keep, pause, and delete choices for your Azure resources.
Preview the same desired state

What-If compares the resources declared in main.bicep with the resources already inside rg-alma-bicep. This comparison reveals whether another deployment would introduce an unexpected change.

Before you run the preview, do you think the unchanged template will propose another VM or a destructive resource change?

  • Preview the existing deployment from Cloud Shell by running this command:
az deployment group what-if --name alma-idempotency --resource-group "$RG" --template-file main.bicep --parameters adminPublicKey=@alma-azure.pub trustedCidr="$TRUSTED_CIDR" vmSize="$VM_SIZE"

What does this command check?

  • The alma-idempotency name identifies this preview in the resource group deployment history.
  • The command supplies the same public key, trusted CIDR, and VM size used by the secure deployment.
  • What-If calculates the proposed changes without modifying the running resources.

You should see no intended resource creates or deletes. Azure may still report provider-default noise that does not represent a deliberate change in main.bicep.

Seeing an Intended Create or Delete?

  • Check that RG still refers to rg-alma-bicep.
  • Check that TRUSTED_CIDR still contains the trusted IPv4 address with its /32 suffix.
  • Check that VM_SIZE still contains the size used for the existing VM.

Still seeing an unintended resource change? Help me compare my What-If result with the existing secure deployment.

Download the completed template

The current Cloud Shell session is ephemeral. Downloading main.bicep keeps the finished template on Windows after the session ends.

  • Reveal the fully qualified path to main.bicep by running this command:
realpath main.bicep

What does this command do?

The realpath command prints the absolute path to main.bicep. The Cloud Shell download form requires this complete path.

  • Record the printed path here: fully qualified path to main.bicep.
  • Select Manage files in the Cloud Shell toolbar.
  • Select Download.
  • Enter fully qualified path to main.bicep in the path field.
  • Complete the browser save prompt so main.bicep is stored in your Windows Downloads folder.
  • Press the Windows key to open Windows search.
  • Type File Explorer and press Enter.
  • Select Downloads in the File Explorer sidebar.

You should see main.bicep in the Downloads folder. Good work. Your completed template now survives outside the ephemeral Cloud Shell session.

Can't Find the Downloaded File?

  • Run realpath again if the download form rejected the path.
  • Check the Downloads folder selected by your browser if the file is absent from the Windows Downloads folder.
  • Confirm that the downloaded filename is main.bicep.

Need help locating the file? Help me download main.bicep from my ephemeral Azure Cloud Shell session to Windows.

✔️ Awesome, I've got everything!

Your completed main.bicep is saved in Windows. Keep this copy as the reusable definition of your secure AlmaLinux deployment.

ⓧ I'd like to double check the full code

Compare the downloaded main.bicep with the complete secure-deployment template below.

@description('Azure region for all resources.')
param location string = resourceGroup().location

@description('Virtual machine name.')
param vmName string = 'alma-bicep-vm'

@description('Linux administrator username.')
param adminUsername string = 'azureuser'

@description('SSH public key copied to the Linux authorized_keys file.')
param adminPublicKey string

@description('Single trusted IPv4 CIDR such as your public address followed by /32.')
param trustedCidr string

@description('Azure VM size. Use the free-eligible size when available to the subscription.')
@allowed([
  'Standard_B2ats_v2'
  'Standard_B2s_v2'
])
param vmSize string = 'Standard_B2ats_v2'

var vnetName = '${vmName}-vnet'
var subnetName = 'server-subnet'
var nsgName = '${vmName}-nsg'
var publicIpName = '${vmName}-pip'
var nicName = '${vmName}-nic'
var imageId = '/CommunityGalleries/almalinux-56b45196-f8a2-4e53-858a-bc4d0ebca440/Images/almalinux-9-gen2/Versions/latest'

resource nsg 'Microsoft.Network/networkSecurityGroups@2025-09-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'Allow-SSH-From-Trusted-CIDR'
        properties: {
          access: 'Allow'
          direction: 'Inbound'
          protocol: 'Tcp'
          priority: 1000
          sourceAddressPrefix: trustedCidr
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '22'
        }
      }
    ]
  }
}

resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.20.0.0/16'
      ]
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: '10.20.1.0/24'
        }
      }
    ]
  }
}

resource publicIpResource 'Microsoft.Network/publicIPAddresses@2024-07-01' = {
  name: publicIpName
  location: location
  sku: {
    name: 'Standard'
    tier: 'Regional'
  }
  properties: {
    publicIPAddressVersion: 'IPv4'
    publicIPAllocationMethod: 'Static'
    idleTimeoutInMinutes: 4
    dnsSettings: {
      domainNameLabel: toLower('${vmName}-${uniqueString(resourceGroup().id)}')
    }
  }
}

resource nic 'Microsoft.Network/networkInterfaces@2025-09-01' = {
  name: nicName
  location: location
  properties: {
    networkSecurityGroup: {
      id: nsg.id
    }
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          primary: true
          privateIPAllocationMethod: 'Dynamic'
          subnet: {
            id: vnet.properties.subnets[0].id
          }
          publicIPAddress: {
            id: publicIpResource.id
          }
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2026-04-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: {
      vmSize: vmSize
    }
    storageProfile: {
      imageReference: {
        communityGalleryImageId: imageId
      }
      osDisk: {
        name: '${vmName}-osdisk'
        createOption: 'FromImage'
        caching: 'ReadWrite'
        deleteOption: 'Delete'
        managedDisk: {
          storageAccountType: 'StandardSSD_LRS'
        }
      }
    }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      linuxConfiguration: {
        disablePasswordAuthentication: true
        provisionVMAgent: true
        ssh: {
          publicKeys: [
            {
              path: '/home/${adminUsername}/.ssh/authorized_keys'
              keyData: adminPublicKey
            }
          ]
        }
      }
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
          properties: {
            primary: true
            deleteOption: 'Delete'
          }
        }
      ]
    }
    diagnosticsProfile: {
      bootDiagnostics: {
        enabled: true
      }
    }
  }
}

output vmName string = vm.name
output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress
output publicIp string = publicIpResource.properties.ipAddress
output sshTarget string = '${adminUsername}@${publicIpResource.properties.ipAddress}'
Review the resource lifecycle choices

The repeatability proof is complete while the Azure resources remain running. Your next decision controls availability and ongoing charges.

What Are the Three Choices?

  • Keeping everything running preserves the SSH demo. Applicable compute, disk, and networking charges continue.
  • Pausing compute deallocates the VM. Disk and networking charges can continue.
  • Deleting the resource group permanently removes every Azure resource created for this project.

The Pause option uses this command:

az vm deallocate --resource-group rg-alma-bicep --name alma-bicep-vm

What Does Deallocation Change?

This command releases the VM's compute allocation. Azure stops billing for VM instance usage after the VM reaches the Deallocated state.

The managed disk and networking resources remain in the resource group. Those resources can continue to incur charges.

Deleting the resource group is irreversible. Your downloaded main.bicep template and locally held SSH key remain on Windows because they sit outside Azure.

The Delete option uses this command:

az group delete --name rg-alma-bicep --yes --no-wait

What Does Deletion Remove?

This command permanently deletes rg-alma-bicep with its VM, managed disk, virtual network, network interface, network security group, and static public IP.

Do not run it during this step. The Before You Go section helps you choose whether to keep, pause, or delete the deployment.

Before the final check, do you expect the unchanged template to require another VM after the successful secure deployment?

  • Return to the What-If output in Cloud Shell.
  • Confirm that it contains no intended resource creates or deletes.

That result proves the same desired state can be reviewed without proposing a duplicate server or a destructive change. Your downloaded template preserves that repeatable definition on Windows.

You have now tested the deployed server and reviewed its desired state a second time. The completed template is safely stored on Windows for future deployments.

Secret mission

Add Environment Guardrails and Tags

Turn your reusable template into a governed template with controlled environment values. Preview consistent tags across every existing resource without creating another virtual machine.

Clean Up Your Resources

Clean Up Your Resources

Your Azure deployment can keep accruing charges while its resources exist. Decide whether to keep everything running, pause the VM to reduce compute costs, or delete the entire project.

Cost warning

VM compute is billed while the server runs unless an eligible free-account allowance covers it. Deallocating the VM stops its compute charges.

The managed disk can continue incurring charges after deallocation. The Standard static public IPv4 address can also continue incurring charges until you delete it.

Resources you used:

  • Azure resource group rg-alma-bicep containing the AlmaLinux OS 9 VM, managed OS disk, virtual network, subnet, network interface, Network Security Group, Standard static public IPv4 address, and deployment history records.
  • Ephemeral Azure Cloud Shell files ~/main.bicep and ~/alma-azure.pub.
  • Windows SSH key files %USERPROFILE%\.ssh\alma-azure and %USERPROFILE%\.ssh\alma-azure.pub.
  • Downloaded Bicep template main.bicep on your Windows machine.

Keep everything running

No action is needed. Choose this option if you want the SSH demo to remain available while you continue experimenting.

  • The alma-bicep-vm virtual machine stays running.
  • The managed disk and Standard static public IPv4 address remain provisioned.
  • Your SSH key pair remains in the Windows .ssh folder.
  • Your downloaded main.bicep template remains available outside the ephemeral Cloud Shell session.
  • All applicable Azure charges continue until you deallocate or delete the resources.

Pause - I'll come back to this later

Deallocate the VM to stop its compute charges while preserving the rest of the deployment. The disk and networking resources can continue incurring charges.

Deallocation can take a few minutes, so a short wait is normal.

  • Deallocate alma-bicep-vm from your Bash Cloud Shell session by running this command:
az vm deallocate --resource-group rg-alma-bicep --name alma-bicep-vm

What does this command do?

This command releases the VM's compute allocation without deleting its disk or network resources. Your template and SSH key remain available for a later return.

  • Confirm the VM's power state by running this command:
az vm show -d -g "$RG" -n alma-bicep-vm --query "{powerState:powerState,publicIps:publicIps}" -o json

What should you see?

This query reads the current power state without modifying the VM. You should see a deallocated power state while the public IP remains assigned.

Delete - I don't want to use this again

Remove the complete deployment when you have finished testing. Deleting the Azure resource group is permanent, while your downloaded template remains available until you remove it below.

  • Delete rg-alma-bicep and every Azure resource inside it by running this command:
az group delete --name rg-alma-bicep --yes --no-wait

What does this command remove?

This command permanently deletes the VM, managed disk, virtual network, subnet, network interface, Network Security Group, static public IP, and deployment history records. The command returns before Azure finishes processing the deletion.

  • Verify the deletion after Azure finishes processing it by running this command:
az group list --output table

What should you see?

This command lists the resource groups that remain in your subscription. The deletion is complete when rg-alma-bicep no longer appears.

  • Remove the Cloud Shell copies of the template and public key by running this command:
rm ~/alma-azure.pub ~/main.bicep

What does this command remove?

This removes only the Cloud Shell copies of the public key and template. It does not touch the files stored on your Windows machine.

  • Navigate to %USERPROFILE%\.ssh with the Windows file browser.
  • Delete the project-specific private key named alma-azure.
  • Delete the matching public key named alma-azure.pub.
  • Navigate to the Windows folder where you downloaded main.bicep.
  • Delete main.bicep if you do not want to keep the reusable deployment template.

That completes the cleanup. Your Azure resources can no longer generate project charges once the resource group deletion finishes.

Nice Work!

Nice Work!

You did it! You deployed a secure AlmaLinux OS 9 server on Azure with Bicep. You proved that one template can deliver controlled network access through repeatable infrastructure.

You've learned how to:

  • Use declarative infrastructure to deploy a running AlmaLinux OS 9 VM from an official Community Gallery image. Capture its complete network path in one reusable Bicep file. Expose connection details through deployment outputs.
  • Observe the private-only VM's network boundary from Windows. Add a Standard static public IPv4 address. Protect the public route with a Network Security Group. Restrict SSH to one trusted IPv4 /32 address. Authenticate with a locally held private key.
  • Use What-If to preview infrastructure changes before deployment. Confirm idempotency through a repeated preview with no intended resource creation or deletion. Retain a local copy of main.bicep for future deployments.
  • Complete the Secret Mission by adding a validated environment parameter restricted to dev or test. Apply consistent resource tags to the existing deployment. Preview tag-only modifications without creating a second VM.

Ready to quiz yourself?