Deploy a Hardened Linux VM with Bicep
Deploy a secure Ubuntu VM on Azure using Bicep with Trusted Launch and Key Vault.
Introduction
30 Second Summary
Every server connected to the internet is a target. Automated bots scan for open ports and weak passwords around the clock, ready to exploit a single misconfigured firewall rule.
In this project, you will deploy a hardened Linux virtual machine on Azure using Bicep, an infrastructure-as-code language. You will start with a deliberately insecure VM, then layer on security controls one at a time to see exactly what each one does.
What You'll Build
Picture running a single deployment command and watching a fully secured, encrypted Linux VM spin up from scratch, ready for you to SSH in within minutes.
By the end of this project, you'll have:
- A running Ubuntu VM you can SSH into, protected by Trusted Launch hardware verification that blocks boot-level tampering.
- All disks encrypted at the host level, with admin credentials stored in Azure Key Vault so no password ever touches your source code.
- A locked-down Network Security Group firewall that blocks every SSH connection except from your own machine.
- An optional Secret Mission to push your skills even further.
Are there any prerequisites?
You'll need an Azure account with an active subscription. You'll also need Azure CLI installed on macOS.
Before We Start
Before any hands-on work begins, this is your moment to commit to what you're building and why it matters.
Set Up Your Bicep Environment
Before writing any infrastructure code, you need a working environment that can author and deploy Bicep templates. Bicep is Azure's native infrastructure-as-code language that compiles down to ARM templates but reads like clean, declarative configuration.
This step gets your tools ready. You'll upgrade the Bicep CLI, add editor support in VS Code, and create the resource group where your VM resources will live.
In this step, get ready to:
- Verify your Azure CLI and upgrade the Bicep CLI to the latest version.
- Install the VS Code Bicep extension for syntax highlighting and IntelliSense.
- Create a dedicated resource group for your project.
Verify Azure CLI and upgrade Bicep CLI
The Azure CLI is the command-line tool you'll use to deploy Bicep templates and manage Azure resources. The Bicep CLI is bundled with it, but you need to make sure it's up to date.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Confirm Azure CLI is installed by running this command:
az --version
You should see a version number for azure-cli at the top of the output, followed by a list of installed extensions.
- Upgrade the Bicep CLI to the latest version by running:
az bicep upgrade
What does this command do?
The az bicep upgrade command downloads and installs the latest Bicep CLI version. If you already have the latest, the command finishes silently or confirms you're up to date.
- Verify the Bicep CLI version by running:
az bicep version
✔️ I see version 0.37 or later
Your Bicep CLI is ready to go. The output should look something like:
Bicep CLI version 0.37.x (abcdef0123)
Any version at 0.37 or higher is perfect.
ⓧ I see an older version
The upgrade may not have completed successfully. Try running the upgrade again:
az bicep upgrade
Then check the version again with az bicep version. If it still shows an older version, try a fresh install instead:
az bicep install
ⓧ Command not found
This usually means your Azure CLI installation needs updating. Run the following to update Azure CLI itself:
az upgrade
After the update completes, run az bicep version again to confirm the Bicep CLI is available.
Now you need to sign in so the Azure CLI can deploy resources to your subscription.
- Sign in to Azure by running:
az login
What happens when you run this?
Your browser opens an Azure sign-in page. Sign in with the account linked to your Azure subscription.
After signing in, your terminal displays a JSON list of your subscriptions. This confirms the CLI is authenticated and ready to deploy resources.
Browser didn't open?
If the browser fails to open automatically, copy the URL from the terminal message and paste it into your browser manually. If that doesn't work, try the device code flow instead:
az login --use-device-code
help me sign in to Azure CLI
Install the VS Code Bicep extension
VS Code doesn't understand .bicep files out of the box. The Bicep extension adds syntax highlighting, autocompletion, and error checking as you type.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running:
mkdir secure-vm-bicep
- Confirm the folder exists by running:
ls
You should see secure-vm-bicep listed in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the menu bar.
- Click Open Folder.
- Navigate to your Desktop.
- Select the secure-vm-bicep folder.
- Click Open.
The VS Code title bar now shows secure-vm-bicep as the active workspace.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Type Bicep in the search box.
- Find the extension published by Microsoft (its ID is ms-azuretools.vscode-bicep).
- Click Install.
Now create the Bicep file you'll build throughout this project.
- In the Explorer sidebar, hover over the secure-vm-bicep folder name.
- Click the New File icon that appears.
- Type main.bicep and press Enter.
Check the language mode indicator in the bottom-right corner of VS Code. It should say Bicep. This confirms the extension is recognizing your file.
Language mode doesn't show Bicep?
Make sure the file is named exactly main.bicep with the .bicep extension. If the language mode shows Plain Text, the Bicep extension may not have installed correctly. Try restarting VS Code.
help me get the Bicep extension working in VS Code
Create a resource group for the project
Every Azure resource needs to live inside a resource group. Think of it as a folder that holds all the related resources for a project. When you're done, you can delete the entire group to clean up everything at once.
- Switch back to your terminal.
- Create a resource group named secure-vm-rg in the eastus region by running:
az group create --name secure-vm-rg --location eastus
What does this command do?
- --name secure-vm-rg sets the name of your new resource group.
- --location eastus places it in the East US Azure region. All resources you deploy into this group will default to this location.
You should see a JSON response with "provisioningState": "Succeeded" in the output.
Before running the next command, what do you expect it to return?
- Confirm the resource group exists by running:
az group show --name secure-vm-rg --query name
You should see "secure-vm-rg" printed in the terminal. That confirms your resource group is ready.
Seeing an authorization error?
Make sure you completed the az login step earlier and signed in with the account that owns your Azure subscription. You can check which account is active by running az account show.
help me fix an authorization error with Azure CLI
Your environment is locked and loaded. You have a working Bicep CLI, editor support in VS Code, and a resource group waiting for resources. Next up, you'll write your first Bicep template and deploy a running VM.
Deploy a Basic (Insecure) VM
Security controls only make sense when you can see what they protect against. Before adding any hardening, you need a baseline VM that shows exactly what "insecure by default" looks like.
In this step, you will write a Bicep template that deploys an Ubuntu VM with a hardcoded password, no boot protection, and a firewall open to the entire internet. This deliberately insecure baseline is the foundation you will harden in the steps ahead.
In this step, get ready to:
- Write the networking infrastructure in main.bicep.
- Add the VM resource to main.bicep.
- Deploy the template and SSH into your running VM.
Define the parameters and variables
Every Bicep template starts with parameters (values the deployer can customize) and variables (values computed from parameters). These control names, locations, and credentials for every resource you create.
- In VS Code, open main.bicep and add the following parameters and variables:
@description('Location for all resources.')
param location string = resourceGroup().location
@description('Name of the virtual machine.')
param vmName string = 'secure-vm'
@description('Admin username for the VM.')
param adminUsername string
@description('Admin password for the VM.')
param adminPassword string = 'P@ssw0rd1234!'
// Derive resource names from the VM name
var vnetName = '${vmName}-vnet'
var subnetName = 'default'
var nsgName = '${vmName}-nsg'
var publicIPName = '${vmName}-pip'
var nicName = '${vmName}-nic'
What does this code do?
The param lines declare values a deployer can override. location defaults to whatever region the resource group lives in (eastus in your case).
Notice adminPassword has a default value of P@ssw0rd1234! sitting right in the source code. This is an intentional security gap you will fix in a later step.
The var lines build resource names by appending suffixes to vmName, so changing one parameter renames everything consistently.
- Save main.bicep.
- Check the bottom-left of VS Code for error indicators. The Bicep extension validates your syntax on save. You should see zero errors.
Add the Network Security Group
A Network Security Group acts as a firewall for your VM. It controls which traffic is allowed in and out.
- Below the variables in main.bicep, add the NSG resource:
// Firewall rules - currently allows SSH from ANY IP address
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = {
name: nsgName
location: location
properties: {
securityRules: [
{
name: 'AllowSSH'
properties: {
priority: 1000
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '22'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
What does this code do?
This NSG has one rule called AllowSSH that permits inbound TCP traffic on port 22 (the SSH port).
The sourceAddressPrefix: '*' means any IP address on the internet can attempt to connect. Automated bots scan for open SSH ports and try to brute-force passwords within minutes. This is the second intentional security gap.
- Save main.bicep and confirm no red squiggles appear in the code you just added.
Add the Virtual Network
A Virtual Network gives your VM a private address space. Inside that space, a subnet carves out the range your VM actually uses.
- Below the NSG resource in main.bicep, add the VNet:
// Virtual network with one subnet linked to the NSG
resource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: subnetName
properties: {
addressPrefix: '10.0.1.0/24'
networkSecurityGroup: {
id: nsg.id
}
}
}
]
}
}
What does this code do?
The VNet reserves the 10.0.0.0/16 address range (over 65,000 addresses). The subnet narrows that to 10.0.1.0/24 (256 addresses).
The networkSecurityGroup: { id: nsg.id } line attaches the NSG you just created to this subnet. Bicep resolves nsg.id automatically because it was declared earlier in the file.
- Save main.bicep and confirm no red squiggles appear.
Seeing red squiggles under nsg.id?
Make sure the NSG resource block appears above the VNet resource in your file. Bicep resolves references top-to-bottom, so the NSG must be declared before the VNet can reference it.
Help me fix Bicep reference errors in my VNet resource.
Add the public IP and network interface
Your VM needs a public IP address so you can reach it from the internet, and a network interface card (NIC) that wires the public IP, subnet, and NSG together.
- Below the VNet resource in main.bicep, add the public IP:
// Static public IP so the VM address doesn't change
resource publicIP 'Microsoft.Network/publicIPAddresses@2023-09-01' = {
name: publicIPName
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
}
}
- Directly below the public IP, add the network interface:
// NIC ties the subnet, public IP, and VM together
resource nic 'Microsoft.Network/networkInterfaces@2023-09-01' = {
name: nicName
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: publicIP.id
}
subnet: {
id: vnet.properties.subnets[0].id
}
}
}
]
}
}
What do these resources do?
The public IP uses Static allocation so the address stays the same across VM reboots. The Standard SKU is required for production workloads.
The NIC acts as the glue. It binds the public IP, the VNet subnet, and (later) the VM into one connected unit. vnet.properties.subnets[0].id grabs the first subnet you defined.
- Save main.bicep and confirm VS Code shows no errors. You now have all four networking resources defined.
Seeing errors on publicIP.id or vnet.properties?
Check that the publicIP resource appears above the nic resource, and the vnet resource appears above both. Bicep needs each resource declared before another resource can reference it.
Help me fix reference errors in my NIC resource.
Add the virtual machine resource
The networking is ready. Now you will define the VM itself, specifying the hardware size, operating system image, credentials, disk configuration, and which NIC to attach.
- Below the NIC resource in main.bicep, add the first part of the VM resource:
resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = {
name: vmName
location: location
properties: {
hardwareProfile: {
vmSize: 'Standard_D2s_v3'
}
osProfile: {
computerName: vmName
adminUsername: adminUsername
adminPassword: adminPassword
}
storageProfile: {
imageReference: {
publisher: 'Canonical'
offer: '0001-com-ubuntu-server-jammy'
sku: '22_04-lts-gen2'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
managedDisk: {
storageAccountType: 'Standard_LRS'
}
deleteOption: 'Delete'
}
}
What does this code do?
Standard_D2s_v3 is a general-purpose VM size with 2 vCPUs and 8 GB RAM. It supports both Trusted Launch and encryption at host, which you will enable later.
The osProfile passes the adminUsername and adminPassword parameters straight to the VM. Right now, that password is the hardcoded default.
The image reference points to Ubuntu 22.04 LTS Gen2, a long-term support release with confirmed Trusted Launch support.
- Still inside the VM resource, add the network profile right after the storageProfile closing brace, then close the resource and add two outputs:
// Connect the VM to the NIC
networkProfile: {
networkInterfaces: [
{
id: nic.id
properties: {
deleteOption: 'Delete'
}
}
]
}
}
}
// Outputs so you can SSH right after deployment
output vmPublicIP string = publicIP.properties.ipAddress
output sshCommand string = 'ssh ${adminUsername}@${publicIP.properties.ipAddress}'
What does this code do?
The networkProfile attaches the NIC to the VM. deleteOption: 'Delete' tells Azure to automatically delete the NIC when the VM is deleted, keeping your resource group clean.
The two output lines print the public IP and the full SSH command after deployment finishes. You will use these to connect to your VM.
- Save main.bicep and confirm VS Code shows no errors.
Seeing a red squiggle on the closing braces?
The most common issue is a mismatched brace. The VM resource needs exactly three closing braces at the end: one for networkProfile, one for properties, and one for the resource itself. Count your opening and closing braces to make sure they match.
Help me fix brace matching errors in my Bicep VM resource.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.bicep with zero errors before moving on to the deployment.
ⓧ I'd like to double check the full code
Scroll through your main.bicep and confirm each section is present in this order:
- Parameters: location, vmName, adminUsername, adminPassword (with the hardcoded default).
- Variables: vnetName, subnetName, nsgName, publicIPName, nicName.
- NSG resource with the AllowSSH rule (sourceAddressPrefix: '*').
- VNet resource with one subnet linked to the NSG.
- Public IP resource with Standard SKU and Static allocation.
- NIC resource wiring the subnet and public IP.
- VM resource with hardwareProfile, osProfile, storageProfile, and networkProfile.
- Two outputs: vmPublicIP and sshCommand.
Deploy the template and SSH into the VM
Your Bicep template is complete. Time to deploy it to Azure and see if you can reach your VM.
- Deploy your template to the secure-vm-rg resource group by running this command:
az deployment group create --resource-group secure-vm-rg --template-file main.bicep --parameters adminUsername=azureadmin
What does this command do?
This tells Azure CLI to deploy the Bicep template to your resource group. The --parameters adminUsername=azureadmin flag supplies the admin username because it has no default value in the template. The password uses the hardcoded default automatically.
Deployment takes 1-3 minutes. Azure provisions the VNet, NSG, public IP, NIC, and VM in the right order based on the dependencies Bicep detects.
Deployment failed with a validation error?
Read the error message carefully. The most common causes are a typo in a resource property name or a missing closing brace. The error usually names the exact line number or property.
If you see an error about adminPassword not meeting complexity requirements, make sure the default value in your template is exactly P@ssw0rd1234! (capital P, at sign, zero, exclamation mark at the end).
Help me debug my Bicep deployment failure.
When the deployment succeeds, the output includes your VM's public IP and SSH command. Look for the vmPublicIP and sshCommand values in the JSON output.
Before you run the SSH command, what do you think will happen? Will you be able to connect from your machine using the hardcoded password?
- Copy the SSH command from the deployment output and run it in your terminal:
ssh azureadmin@[[VM_PUBLIC_IP="your VM's public IP from the deployment output"]]
What should I see?
The first time you connect, SSH asks you to confirm the host's fingerprint. Type yes and press Enter.
When prompted for the password, type P@ssw0rd1234! and press Enter. The password will not appear on screen as you type. You should land in an Ubuntu shell prompt.
- Type exit and press Enter to disconnect from the VM.
You just SSHed into a running VM that you defined entirely in code. Now take a look at the security gap that makes this template dangerous.
- Open main.bicep in VS Code and scroll to the adminPassword parameter at the top of the file.
The password P@ssw0rd1234! is sitting in plain text. Anyone who reads this file gets root access to your VM. If you committed this to Git, the password would be in your repository history forever.
Why start with an insecure VM?
Three security gaps are baked into this template on purpose. First, the admin password is hardcoded in source code. Second, the NSG allows SSH from any IP on the internet. Third, there is no Trusted Launch or disk encryption.
In the next three steps, you will fix each gap one at a time so you can see exactly what each security control does and why it matters.
You have a running VM deployed entirely from Bicep. Next up, you will eliminate the most dangerous gap: that hardcoded password sitting in your source code.
Eliminate Hardcoded Credentials with Key Vault
Your VM is running and you can SSH into it. But there is a serious problem hiding in your main.bicep file.
Open main.bicep and look at the adminPassword parameter. The password is sitting right there in plain text. Anyone who reads this file gets root access to your VM. Azure Key Vault lets you store secrets separately and inject them at deploy time, so your source files never contain credentials.
In this step, get ready to:
- Create an Azure Key Vault and store the admin password as a secret.
- Add the @secure() decorator to the password parameter and remove the hardcoded default.
- Create a .bicepparam file that pulls the password from Key Vault at deploy time.
Create a Key Vault and store the admin password
Azure Key Vault is a cloud service for securely storing secrets, keys, and certificates. You will create a vault, then store the VM's admin password inside it as a named secret.
Why do Key Vault names need to be unique?
Key Vault names are globally unique across all of Azure. The name becomes part of the vault's public DNS address. Pick something memorable that includes your initials or a random suffix (3-24 characters, alphanumeric and hyphens only).
- Create a new Key Vault in your resource group by running this command:
az keyvault create --name [[KEYVAULT_NAME="your-unique-vault-name"]] --resource-group secure-vm-rg --location eastus --enabled-for-template-deployment true
What does this command do?
- --name sets the globally unique name for your vault.
- --enabled-for-template-deployment true allows Bicep to read secrets from this vault during deployments. Without this flag, your deploy would fail when it tries to pull the password.
- You should see a JSON response with "provisioningState": "Succeeded" in the output.
Seeing a naming error?
Key Vault names must be 3-24 characters, use only alphanumeric characters and hyphens, and be globally unique. If you see a conflict error, try a different name with a random suffix.
Help me fix my Key Vault naming error.
- Now store the VM admin password as a secret in the vault by running this command:
az keyvault secret set --vault-name [[KEYVAULT_NAME="your-unique-vault-name"]] --name vmAdminPassword --value 'your-strong-password-here'
What does this command do?
- --name vmAdminPassword is the name you will reference later in your Bicep parameters file.
- --value is the actual password. Choose a strong password that meets Azure's VM password requirements (12+ characters, uppercase, lowercase, number, and special character).
- You should see a JSON response showing the secret's name as vmAdminPassword and a value field containing the password you chose.
Secret set command failing?
Make sure the vault name matches exactly what you used in the create command. Check for typos. If you see a permissions error, confirm you are logged in with the same account that created the vault.
Help me troubleshoot my Key Vault secret set command.
Update main.bicep to use a secure parameter
The password is now safely stored in Key Vault. Next, you need to remove it from your Bicep file and mark the parameter as secure so Bicep never logs its value.
- In main.bicep, find the admin password parameter block near the top of the file:
@description('Admin password for the VM.')
param adminPassword string = 'P@ssw0rd1234!'
- Replace those two lines with the following three lines. You are adding the @secure() decorator and removing the default password value:
@secure()
@description('Admin password for the VM.')
param adminPassword string
What does @secure() do?
The @secure() decorator tells Azure to treat this parameter as sensitive. It will not appear in deployment logs, the Azure portal activity log, or any error messages. The password still reaches the VM, but it is never exposed in transit or in records.
- Save main.bicep.
- Confirm the password is gone from the file by running this command:
cat main.bicep | grep -i password
You should see lines referencing the word "password" in parameter names and the osProfile, but no actual password value like P@ssw0rd1234!. The hardcoded credential is gone.
Still seeing the password value?
Make sure you removed the entire = 'P@ssw0rd1234!' default value from the adminPassword parameter line. The line should end with just param adminPassword string and nothing else.
Help me remove the hardcoded password from my Bicep file.
Create a Bicep parameters file to pull from Key Vault
Your Bicep file no longer has a password, but the deployment still needs one. A .bicepparam file lets you supply parameter values separately from the template. It can use the az.getSecret() function to pull the password directly from Key Vault at deploy time.
First, you need your Azure subscription ID. The az.getSecret() function requires it to locate your Key Vault.
- Get your subscription ID by running this command:
az account show --query id --output tsv
- Copy the subscription ID that is printed. You will need it in the next step.
- Create a new file called main.bicepparam in the same folder as main.bicep.
- Paste the following code into main.bicepparam:
using './main.bicep'
param adminUsername = 'azureadmin'
param adminPassword = az.getSecret('[[SUBSCRIPTION_ID="your-subscription-id"]]', 'secure-vm-rg', '[[KEYVAULT_NAME="your-unique-vault-name"]]', 'vmAdminPassword')
What does this file do?
- using './main.bicep' links this parameters file to your Bicep template. Azure CLI uses this link automatically, so you do not need to pass --template-file separately.
- az.getSecret() fetches the secret from Key Vault during deployment. The four arguments are your subscription ID, resource group, vault name, and secret name.
- The password never appears in this file. It goes straight from Key Vault to the VM's osProfile at deploy time.
- Save main.bicepparam.
Before you redeploy, what do you think will happen? Will the deployment succeed on the first try, or will something go wrong?
- Redeploy the VM using the parameters file by running this command:
az deployment group create --resource-group secure-vm-rg --parameters main.bicepparam
Why no --template-file flag?
The using './main.bicep' directive inside main.bicepparam tells Azure CLI which template to use. If you add --template-file alongside a .bicepparam file, the CLI will reject it.
- Wait for the deployment to complete. You should see a JSON output with "provisioningState": "Succeeded".
Deployment failing?
- Check that your subscription ID is correct. Run az account show --query id --output tsv and compare it to the value in main.bicepparam.
- Make sure the vault name in main.bicepparam matches exactly what you used when creating the Key Vault.
- Verify the secret name is vmAdminPassword (case-sensitive) in both the az keyvault secret set command and the az.getSecret() call.
Help me debug my Key Vault Bicep deployment.
Now confirm that the VM still works with the Key Vault-stored password. Copy the public IP from the deployment output and SSH into the VM.
- SSH into the VM using the password you stored in Key Vault by running this command:
ssh azureadmin@[[VM_PUBLIC_IP="your VM's public IP address"]]
- When prompted, enter the password you stored in Key Vault.
- You should land in an Ubuntu shell. Type exit to disconnect from the VM.
SSH password rejected?
The password you enter must match exactly what you stored in Key Vault with the az keyvault secret set command. If you are not sure what you stored, you can check it by running:
az keyvault secret show --vault-name [[KEYVAULT_NAME="your-unique-vault-name"]] --name vmAdminPassword --query value --output tsv
Help me fix my SSH connection to the redeployed VM.
✔️ Awesome, I've got everything!
Double check you have saved both main.bicep and main.bicepparam.
ⓧ I'd like to double check the full code
Here is the full main.bicepparam file. Make sure yours matches:
using './main.bicep'
param adminUsername = 'azureadmin'
param adminPassword = az.getSecret('[[SUBSCRIPTION_ID="your-subscription-id"]]', 'secure-vm-rg', '[[KEYVAULT_NAME="your-unique-vault-name"]]', 'vmAdminPassword')
For main.bicep, the only change from the previous step is near the top of the file. Scroll up and confirm these three chunks are present in order:
- The @secure() decorator sits directly above @description('Admin password for the VM.').
- The adminPassword parameter line reads param adminPassword string with no default value.
- No password value appears anywhere in the file.
Your credentials are locked down. No password appears in any source file, and the Key Vault handles injection at deploy time. Next up, you will harden the VM itself with Trusted Launch, Secure Boot, and disk encryption.
Enable Trusted Launch and Disk Encryption
Your credentials are safely locked in Key Vault. But your VM still has no hardware-backed boot protection. Without Trusted Launch, a rootkit could silently compromise the boot chain before the OS even loads.
Disk data also sits unencrypted at rest. In this step, you'll add a security profile to your Bicep template that enables Trusted Launch with Secure Boot, a virtual TPM, and full disk encryption at the host level.
In this step, get ready to:
- Register the EncryptionAtHost feature on your Azure subscription.
- Add a securityProfile block with Trusted Launch and encryption at host to your VM resource.
- Deploy the hardened VM and verify the security controls are active.
Register the EncryptionAtHost feature and add the security profile
Before Azure allows encryption at host on a VM, you need to register the EncryptionAtHost feature on your subscription. This is a one-time prerequisite.
- Register the feature by running this command:
az feature register --namespace Microsoft.Compute --name EncryptionAtHost
- Check the registration state by running:
az feature show --namespace Microsoft.Compute --name EncryptionAtHost --query properties.state
You should see "Registered" in the output.
Registration can take a few minutes
If you see "Registering" instead, wait a minute or two, then re-run the az feature show command until it shows "Registered". If it was already registered on your subscription, the command is a no-op.
- Propagate the registration to the resource provider by running:
az provider register --namespace Microsoft.Compute
Now you can add encryption at host to your VM template.
- Switch back to main.bicep in VS Code.
- Scroll down to the vm resource and find the closing brace of the networkProfile block. It looks like this:
networkProfile: {
networkInterfaces: [
{
id: nic.id
properties: {
deleteOption: 'Delete'
}
}
]
}
- Directly below that closing brace, add the following securityProfile block (still inside the VM's properties):
// Hardware-backed boot security and disk encryption
securityProfile: {
securityType: 'TrustedLaunch'
uefiSettings: {
secureBootEnabled: true // Prevents unsigned bootloaders
vTpmEnabled: true // Virtual TPM for boot integrity
}
encryptionAtHost: true // Encrypts OS, temp, and cache disks
}
What does this code do?
- securityType: 'TrustedLaunch' tells Azure to create the VM with hardware-backed boot verification.
- secureBootEnabled: true blocks unsigned or tampered bootloaders from running.
- vTpmEnabled: true adds a virtual Trusted Platform Module that measures the boot chain for integrity.
- encryptionAtHost: true encrypts all disks (OS, temp, and cache) at the host level with zero in-VM configuration.
- Save main.bicep.
Seeing a red squiggly line in VS Code?
Make sure the securityProfile block sits inside the VM's properties object, at the same indentation level as networkProfile. A misplaced closing brace is the most common cause.
Help me fix a Bicep syntax error in my securityProfile block.
✔️ Awesome, I've got the securityProfile added!
Double check you've saved main.bicep.
ⓧ I'd like to double check the full code
Your vm resource should now end with the securityProfile block right after networkProfile. Scroll to the vm resource and confirm it looks like this (showing only the last two profile blocks):
networkProfile: {
networkInterfaces: [
{
id: nic.id
properties: {
deleteOption: 'Delete'
}
}
]
}
securityProfile: {
securityType: 'TrustedLaunch'
uefiSettings: {
secureBootEnabled: true
vTpmEnabled: true
}
encryptionAtHost: true
}
}
}
The rest of main.bicep (parameters, variables, networking resources, outputs) is unchanged from the previous step.
Attempt to redeploy and hit the immutability wall
Your template now includes Trusted Launch and encryption at host. Time to push these changes to your running VM.
- Before you run the next command, pause and think: do you expect Azure to update the existing VM with the new security profile, or will something go wrong?
- Redeploy by running:
az deployment group create --resource-group secure-vm-rg --parameters main.bicepparam
The deployment fails. You'll see an error indicating that the security type cannot be changed on an existing VM.
Why did this fail?
Some Azure resource properties are immutable. They can only be set at creation time. The securityType property is one of them. Once a VM is created without Trusted Launch, you cannot add it later.
This is exactly where infrastructure as code shines. Your entire VM is defined in main.bicep. You can delete the old VM and recreate it from the same template in a single command. No manual clicking, no guesswork.
Delete the VM and redeploy from scratch
Since you cannot modify the security type on a running VM, you need to tear it down and let Bicep rebuild it with the security profile baked in from the start.
- Delete the existing VM by running:
az vm delete --resource-group secure-vm-rg --name secure-vm --yes
What happens to the other resources?
This command deletes only the VM itself. Because the Bicep template set deleteOption: 'Delete' on the NIC and OS disk, those are removed along with the VM.
The VNet, NSG, and public IP remain untouched. When Bicep redeploys, it recreates just the NIC and VM while reusing the existing networking resources.
- Redeploy the updated template by running:
az deployment group create --resource-group secure-vm-rg --parameters main.bicepparam
This time the deployment succeeds. Bicep creates a fresh VM with Trusted Launch and encryption at host enabled from the start.
- What do you expect to see when you query the VM's security profile? Predict, then verify by running:
az vm show --resource-group secure-vm-rg --name secure-vm --query securityProfile
You should see output confirming all four security controls are active:
{
"encryptionAtHost": true,
"securityType": "TrustedLaunch",
"uefiSettings": {
"secureBootEnabled": true,
"vTpmEnabled": true
}
}
- Confirm SSH still works by copying the public IP from the deployment output and running:
ssh azureadmin@[[VM_PUBLIC_IP="your VM public IP address"]]
- Enter the password you stored in Key Vault. You should land in an Ubuntu shell.
- Type exit to close the SSH session.
Deployment still failing?
If the deployment fails with an error about EncryptionAtHost, the feature registration may not have completed yet. Run az feature show --namespace Microsoft.Compute --name EncryptionAtHost --query properties.state and wait until it shows "Registered" before redeploying.
If you see an error about the old VM still existing, wait a minute for the deletion to finish, then try the deployment again.
Help me debug my Trusted Launch deployment failure.
Your VM now has hardware-backed boot security and encrypted disks. Next up, you'll lock down network access so only your IP address can reach it over SSH.
Lock Down Network Access
Your VM now has Trusted Launch, Secure Boot, and encryption at host protecting its boot chain and disk data.
There is still one glaring gap. The Network Security Group currently allows SSH from any IP address on the internet. Automated bots scan for open SSH ports and will attempt to brute-force your VM within minutes of it going live. In this step, you will restrict that NSG rule so only your machine can connect.
In this step, get ready to:
- Find your public IP address.
- Update main.bicep and main.bicepparam to restrict SSH to your IP only.
- Redeploy and verify the lockdown.
Find your public IP address
The NSG needs your exact public IP address so it can replace the * wildcard with a single allowed source.
- Get your current public IP by running this command:
curl -s ifconfig.me
You will see a single IP address printed, something like 203.0.113.50. Copy this value. You will need it in a moment.
Why /32?
When you use this IP in the NSG rule, you will append /32 to it. In CIDR notation, /32 means "this single IP address and nothing else." It is the most restrictive mask possible.
Add the allowedSourceIP parameter and update the NSG
You need two changes in main.bicep: a new parameter to accept the IP, and an update to the NSG rule that uses it.
- In main.bicep, scroll to the parameters section at the top of the file.
- Find this line:
param adminPassword string
- Add the following new parameter directly below it:
@description('Your public IP address for SSH access (CIDR notation, e.g. 203.0.113.50/32).')
param allowedSourceIP string
What does this parameter do?
This new allowedSourceIP parameter accepts a string value at deploy time. By keeping it as a parameter instead of hardcoding an IP, the template stays reusable for any team member or environment.
- Now scroll down to the NSG resource and find this line inside the AllowSSH rule:
sourceAddressPrefix: '*'
- Replace '*' with allowedSourceIP, so the line now looks like this:
sourceAddressPrefix: allowedSourceIP
What changed?
Before, the NSG allowed SSH from every IP on the internet. Now it only allows SSH from the single IP address you pass in through the allowedSourceIP parameter.
- Save main.bicep.
- Now switch to main.bicepparam and add this line at the end of the file:
param allowedSourceIP = '[[ALLOWED_SOURCE_IP="your-public-ip-here/32"]]'
What value do I put here?
Replace the placeholder with the IP address you copied from curl -s ifconfig.me, followed by /32. For example, if your IP is 203.0.113.50, enter 203.0.113.50/32.
- Save main.bicepparam.
✔️ Awesome, I've got everything!
Great. Double check both files are saved before moving on.
ⓧ I'd like to double check the full code
Your main.bicepparam should now look exactly like this:
using './main.bicep'
param adminUsername = 'azureadmin'
param adminPassword = az.getSecret('your-subscription-id-here', 'secure-vm-rg', 'your-keyvault-name-here', 'vmAdminPassword')
param allowedSourceIP = 'your-public-ip-here/32'
For main.bicep, confirm these two changes:
- The allowedSourceIP parameter block appears directly below the adminPassword parameter, with its @description decorator.
- Inside the AllowSSH security rule, sourceAddressPrefix is set to allowedSourceIP (no quotes around it, since it is a parameter reference).
Redeploy and verify the lockdown
- Redeploy with the updated template by running:
az deployment group create --resource-group secure-vm-rg --parameters main.bicepparam
Deployment failed?
- If you see a missing parameter error, make sure you added the param allowedSourceIP line in main.bicepparam and saved the file.
- If you see an invalid CIDR error, check that your IP value ends with /32 and the full value is wrapped in single quotes.
Still stuck? Help me debug a failed Bicep deployment for my NSG update.
Once the deployment succeeds, copy the public IP from the deployment output.
- Confirm SSH still works from your machine by running:
ssh azureadmin@[[VM_PUBLIC_IP="your VM public IP address"]]
You should land in the Ubuntu shell just like before. Type exit to disconnect.
Can't SSH in anymore?
- Your public IP may have changed since you ran curl -s ifconfig.me. Run it again and compare the result to the value in main.bicepparam. If it differs, update the file, redeploy, and try again.
- Make sure the value in main.bicepparam includes /32 after the IP address.
Still stuck? Help me figure out why SSH is being blocked after I updated my NSG.
Now for the final check. Before you run the next command, think about what you expect the sourceAddressPrefix value to be. Will it show *, or your IP address?
- Verify the NSG rule by running:
az network nsg rule show --resource-group secure-vm-rg --nsg-name secure-vm-nsg --name AllowSSH --query sourceAddressPrefix
You should see your IP address in CIDR notation (for example "203.0.113.50/32"), not "*". That confirms the NSG is locked down to only your machine.
Your VM is now fully hardened. Credentials live in Key Vault, the boot chain is hardware-verified, disks are encrypted, and the firewall only lets you in. Next up, there is an optional Secret Mission to push your security posture even further.
Secret mission
Install the Guest Attestation Extension
Your VM has Secure Boot and vTPM protecting its boot chain. But who is actually checking whether that boot chain stays clean after deployment? In this extension, you will add cloud-based boot integrity monitoring that reports any deviations to Microsoft Defender for Cloud.
Clean Up Your Resources
Clean Up Your Resources
This project created Azure cloud resources that incur charges while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
The Standard_D2s_v3 VM costs approximately $0.10 per hour while running. Even when stopped (deallocated), the managed OS disk incurs a small storage charge of approximately $0.05 per day.
Delete the resource group to stop all charges.
Resources you used:
- Resource group secure-vm-rg containing the VM, Key Vault, VNet, NSG, Public IP, and NIC.
- Local project files main.bicep and main.bicepparam.
Keep everything running
No action needed. Choose this if you plan to continue experimenting with your hardened VM within the next few hours.
- Your VM continues running and is accessible via SSH.
- All Azure resources in secure-vm-rg remain active and continue incurring charges.
- The VS Code Bicep extension remains installed on your machine.
Pause - I'll come back to this later
Deallocate the VM to stop compute billing while keeping your resource group and files intact. Disk storage charges (~$0.05/day) continue.
- Deallocate the VM by running this command:
az vm deallocate --resource-group secure-vm-rg --name secure-vm
- When you are ready to continue, start the VM again by running this command:
az vm start --resource-group secure-vm-rg --name secure-vm
- Your main.bicep and main.bicepparam files stay on your local machine.
Delete - I don't want to use this again
Remove all cloud resources and local project files to stop all charges and start fresh.
- Delete the entire resource group (and everything inside it) by running this command:
az group delete --name secure-vm-rg --yes --no-wait
What does --no-wait do?
The --no-wait flag starts the deletion in the background so your terminal is free immediately. Azure continues deleting the resources behind the scenes.
The Key Vault enters soft-delete mode and is automatically purged after 90 days. To purge it immediately, run the following command:
az keyvault purge --name [[VAULT_NAME="your Key Vault name"]]
- Delete the local project files by running this command:
rm main.bicep main.bicepparam
On Windows?
Use this command instead to delete the local files:
del main.bicep, main.bicepparam
Nice Work!
Nice Work!
Nice work! You just deployed a production-grade, hardened Linux VM on Azure entirely through Bicep infrastructure-as-code. Every security control, from boot integrity to disk encryption to firewall rules, lives in a template you can tear down and rebuild in a single command.
You've learned how to:
- Deployed a hardened Linux VM through Bicep with Trusted Launch (Secure Boot + vTPM), encryption at host, and an NSG locked to your IP address.
- Eliminated hardcoded credentials by storing secrets in Azure Key Vault and injecting them at deploy time through a .bicepparam file using az.getSecret().
- Experienced infrastructure immutability firsthand when securityType could not be changed on a running VM, then used IaC to tear down and rebuild the entire stack in one command.
- Completed an optional Secret Mission to push your skills further.
Ready to quiz yourself?