Build an Azure Windows/Linux Lab
Build a private Azure lab for health scripts and SSH outage troubleshooting.
Introduction
30 Second Summary
When one computer cannot reach another, the hardest part is proving what blocked the connection. A convincing explanation needs evidence from both computers plus proof that the fix worked.
In this project, you will build a private Windows Server and Ubuntu Server operations lab in Microsoft Azure. You will use health reports to diagnose a controlled SSH outage before restoring the connection.
What You'll Build
You will use Azure Bastion Developer in Safari to show a Windows-to-Linux connection moving from healthy to blocked to restored.
By the end of this project, you'll have:
- A private two-server demo you can reach from your Mac without exposing either virtual machine through a public IP address.
- Rerunnable health reports that show Linux service health, Linux storage data, Windows disk capacity, and cross-server SSH reachability.
- A documented network incident you can reproduce, trace to a targeted network security group rule, remediate, and validate with before-and-after evidence.
- Secret Mission: Convert the raw connectivity Boolean into operator-friendly HEALTHY and DEGRADED states.
Are there any prerequisites?
You need a Mac, a phone number, a non-prepaid credit or debit card, and either a Microsoft account or a GitHub account for Azure signup.
An eligible new Azure account provides $200 of credit for the first 30 days. The virtual machines and managed disks consume that credit.
Before We Start
Before the hands-on work begins, take a moment to connect this private Windows/Linux operations lab to your current application. A clear purpose helps you explain how the lab demonstrates secure administration, scripting, and troubleshooting skills.
Set Up Azure and the Private Network
Your servers will eventually share a private network. The lab first needs one disposable resource boundary in the intended subscription.
In this step, you will create that boundary in Azure Portal. You will place the lab's private address space inside an Azure Virtual Network.
You will finish with Azure Bastion Developer. It provides the browser-based administration path before either server exists.
In this step, get ready to:
- Confirm Azure Portal uses the intended subscription.
- Create the resource group plus its private virtual network.
- Add the Developer tier Bastion resource.
Confirm Azure access and subscription
An Azure subscription controls billing for every resource. It also controls ownership.
Before payment verification
Payment verification can feel like a big commitment. Azure requires a phone number plus a non-prepaid credit or debit card when you create the free account.
Eligible new accounts receive $200 in credit for the first 30 days. This project expects no out-of-pocket cost while that credit remains.
The virtual machines plus managed disks created later consume credit. Pay-as-you-go charges can apply after an account upgrade or after the credit is exhausted.
Which Azure account state matches you?
✔️ I can sign in
Your existing Azure account is the shortest path into the lab.
- Return to the Azure Portal tab you verified earlier in Safari.
- Sign in with the Microsoft or GitHub account tied to your intended Azure subscription.
- Confirm the portal home page loads under that account.
ⓧ I need a free account
The free account flow establishes your subscription before you create any lab resources.
- Return to the Azure Portal tab you verified earlier in Safari.
- Start the Azure free account flow from the portal sign-in page.
- Complete the phone verification step.
- Complete the payment verification step with a non-prepaid credit or debit card.
- Sign in with the account used during registration.
- Confirm the portal home page loads under that account.
Having trouble signing in?
Confirm that you are using the same account that completed the Azure signup flow. Return to the portal after every verification step has finished.
Help me troubleshoot Azure Portal account access.
You are through the account gate. Azure Portal is now ready to hold the lab under your chosen subscription.
Create the resource group and virtual network
A resource group gives the whole lab one lifecycle. Deleting that group later removes the cloud resources inside it.
How the address ranges fit together
The virtual network owns the private range 10.20.0.0/16. A subnet reserves the smaller 10.20.1.0/24 segment for the two servers.
- Search for Resource groups in the Azure Portal search box.
- Select Resource groups from the search results.
You will see the page that manages resource boundaries for your subscription.
- Select Create.
- Choose your intended subscription in Subscription.
The form now points at the subscription that owns this lab.
- Enter rg-cross-platform-ops in Resource group.
- Select East US 2 in Region.
The review now identifies the lab boundary plus the region that stores its metadata.
- Select Review + create.
- Select Create after validation succeeds.
That first cloud resource is done. The lab now has a boundary that you can inspect or remove as one unit.
- Search for Virtual networks in the Azure Portal search box.
- Select Virtual networks from the search results.
The virtual network page is where you define the private address space for both servers.
- Select + Create.
- Choose your intended subscription on Basics.
The new network is now tied to the same billing boundary as the resource group.
- Select rg-cross-platform-ops in Resource group.
- Enter vnet-ops-lab in Name.
The network now has its final resource group plus its project-specific name.
- Select East US 2 in Region.
- Select the IP Addresses tab.
This tab controls which private addresses the virtual network can assign.
- Replace the address space with 10.20.0.0/16.
- Select the subnet row shown under the address space.
The subnet pane lets you reserve the exact segment used by the servers.
- Enter snet-workloads in Subnet name.
- Enter 10.20.1.0 in Starting address.
The subnet now starts at the first address in the required workload range.
- Select /24 (256 addresses) under Subnet size.
- Select Save.
The subnet list now shows snet-workloads with the range 10.20.1.0/24.
- Select Review + create.
- Select Create after validation succeeds.
Your private address space is live. Both servers can now be placed on the same workload subnet.
- Search for vnet-ops-lab in Azure Portal.
- Select vnet-ops-lab from the results.
The virtual network overview confirms that the new resource exists in East US 2.
- Select Subnets.
- Confirm snet-workloads shows the range 10.20.1.0/24.
Network values look different?
- Confirm the virtual network belongs to rg-cross-platform-ops.
- Return to IP Addresses if the virtual network range differs from 10.20.0.0/16.
- Edit snet-workloads if its range differs from 10.20.1.0/24.
Help me correct my Azure virtual network configuration.
Create the Bastion access path
Bastion Developer uses a shared Azure service to reach private virtual machines through the portal. The target servers do not need public IP addresses.
Why Developer fits this lab
The Developer tier has no hourly charge or outbound data transfer charge. It also requires no dedicated AzureBastionSubnet or public IP address.
Developer supports one virtual machine connection at a time. That limit fits this individual lab because you will administer the two servers sequentially.
- Search for Bastions in the Azure Portal search box.
- Select Bastions from the search results.
This page lists the browser-access resources in your subscription.
- Select + Create.
- Choose your intended subscription on Create a Bastion.
The Bastion resource is now tied to the subscription used by the rest of the lab.
- Select rg-cross-platform-ops in Resource group.
- Enter bastion-ops-lab in Name.
The access service now shares the same disposable boundary as the network.
- Select East US 2 in Region.
- Select Developer in Tier.
The Developer tier removes the dedicated subnet plus public IP requirements used by higher Bastion tiers.
- Select vnet-ops-lab in Virtual network.
- Select Review + Create.
The review now links the Bastion resource to the private network in East US 2.
- Select Create after validation succeeds.
- Wait until the deployment reports success.
Developer tier missing?
- Confirm Region is set to East US 2.
- Confirm vnet-ops-lab belongs to the same subscription.
- Confirm vnet-ops-lab is in East US 2.
Help me troubleshoot the Azure Bastion Developer configuration.
Before you check, which two top-level resources do you expect to see inside rg-cross-platform-ops?
- Search for Resource groups in Azure Portal.
- Select Resource groups from the results.
This final check proves that the private network plus its browser-access path share one resource boundary.
- Select rg-cross-platform-ops.
- Confirm the resource list includes vnet-ops-lab.
- Confirm the resource list includes bastion-ops-lab.
You should see both resources in the group. Their shared location should be East US 2.
- Select bastion-ops-lab from the resource list.
- Confirm its Tier is Developer.
Your private Azure foundation is working. The network plus browser-access service are ready for the two servers.
✔️ Awesome, I've got everything!
Great. Your Azure foundation now matches the required lab configuration.
ⓧ I'd like to double check the full setup
Compare your Azure resources with this configuration checkpoint.
- rg-cross-platform-ops: Resource group created in East US 2.
- vnet-ops-lab: Virtual network in rg-cross-platform-ops, East US 2, with address space 10.20.0.0/16.
- snet-workloads: Subnet in vnet-ops-lab with address range 10.20.1.0/24.
- bastion-ops-lab: Azure Bastion resource in rg-cross-platform-ops, East US 2, connected to vnet-ops-lab with tier Developer.
Your private network is ready. Next up, you will deploy both private servers. You will reach them from your Mac without public IP addresses.
Deploy and Reach Both Private Servers
Your private network in Azure Portal is ready. It still has no servers to administer.
You’ll deploy one Windows server plus one Linux server as Azure Virtual Machines. Each VM uses its own network security group.
Azure Bastion Developer gives your Mac browser-based access to both operating systems. The VMs remain reachable through private IP addresses without public exposure.
In this step, get ready to:
- Deploy a private Windows Server VM with its own NIC-level NSG.
- Deploy a private Ubuntu Server VM with SSH key authentication.
- Reach both operating systems through Azure Bastion Developer.
VMs Use Azure Credit
You are in control here. The two VMs plus their managed disks begin consuming Azure credit after deployment.
Azure Bastion Developer has no hourly charge or outbound data charge. VM usage can create pay-as-you-go charges after your credit is exhausted or your account is upgraded.
Deploy the private Windows server
The Windows VM becomes the source of your later connectivity checks. Its networking keeps administration on the private path you prepared earlier.
- Enter virtual machines in the Azure Portal search bar.
- Select Virtual machines under Services.
- Select Create from the top menu.
- Select Azure virtual machine.
- Select your intended subscription on the Basics tab.
- Select rg-cross-platform-ops for Resource group.
- Enter win-ops-01 for Virtual machine name.
- Select East US 2 for Region.
The Basics tab now places the Windows VM beside your existing network resources in East US 2.
- Select Standard for Security type.
- Select Windows Server 2022 Datacenter: Azure Edition - x64 Gen 2 for Image.
- Select Standard_B2ls_v2 for Size.
Protect Your Administrator Password
The local administrator credentials unlock the Windows desktop through Bastion. Keep the password private because you use it again when you connect.
- Enter your Windows administrator username in Username.
- Create a strong private password in Password.
- Select None for Public inbound ports.
- Select the Networking tab.
- Select vnet-ops-lab for Virtual network.
- Select snet-workloads for Subnet.
- Select None for Public IP.
- Select Advanced for NIC network security group.
- Select Create new under Configure network security group.
- Enter nsg-win-ops for the NSG name.
- Select OK to attach the new NSG configuration.
How Does This Keep Windows Private?
Selecting None for the public IP keeps the VM off the public internet. Azure assigns a private address from snet-workloads instead.
The NIC-level NSG applies traffic rules directly to the Windows network interface. Later checks can distinguish this server’s rules from the Linux server’s rules.
- Select Review + create.
Azure validates the configuration before provisioning anything. The summary should show the intended image plus the private networking choices.
- Confirm the summary lists win-ops-01 in rg-cross-platform-ops.
Provisioning can take several minutes while Azure creates the VM plus its operating system disk. A deployment screen that sits quietly for a while is expected.
- Select Create.
- Wait for the deployment to complete.
- Select Go to resource.
- Confirm the Overview shows the VM as running.
- Confirm the Overview shows an assigned private IP address.
- Confirm the Overview shows no public IP address.
That is the first server online. Windows now has a private address plus its own NIC-level NSG.
Windows Deployment Stuck or Rejected?
Check that the resource group plus region match the existing network. Confirm that vnet-ops-lab appears in the networking choices.
If Azure rejects the administrator details, update only the highlighted credential fields. Keep your password out of screenshots or support messages.
If the requested VM size is unavailable, refresh the size list before trying the deployment again.
Help me troubleshoot my private Windows VM deployment.
Deploy the private Linux server
The Linux VM becomes the target of the cross-platform SSH test. A generated private key gives you a credential that Bastion can use from your Mac.
- Return to the Virtual machines page.
- Select Create from the top menu.
- Select Azure virtual machine.
- Select your intended subscription on the Basics tab.
- Select rg-cross-platform-ops for Resource group.
- Enter linux-ops-01 for Virtual machine name.
- Select East US 2 for Region.
- Select Ubuntu Server 22.04 LTS - x64 Gen2 for Image.
- Select Standard_B2ts_v2 for Size.
- Select SSH public key for Authentication type.
- Enter azureuser for Username.
- Select Generate new key pair for SSH public key source.
- Enter linux-ops-01-key for Key pair name.
- Select None for Public inbound ports.
The generated key pair separates authentication from a reusable password. Azure keeps the public half while your Mac receives the private half.
- Select the Networking tab.
- Select vnet-ops-lab for Virtual network.
- Select snet-workloads for Subnet.
- Select None for Public IP.
- Select Advanced for NIC network security group.
- Select Create new under Configure network security group.
- Enter nsg-linux-ops for the NSG name.
- Select OK to attach the new NSG configuration.
Why Give Linux Its Own NSG?
The separate NSG lets you change Linux inbound traffic without changing Windows traffic. This narrow boundary supports the controlled SSH outage later in the lab.
- Select Review + create.
- Confirm the summary lists linux-ops-01 in rg-cross-platform-ops.
After you select Create, Azure opens the Generate new key pair window. The next choice downloads the private key before deployment continues.
Keep the Private Key Safe
This file authenticates access to the Linux account. Keep it private plus store it somewhere you can find from Safari’s file picker.
- Select Create.
- Select Download private key and create resource.
- Confirm Safari saved linux-ops-01-key.pem on your Mac.
- Wait for the deployment to complete.
- Select Go to resource.
- Confirm the Overview shows the VM as running.
- Confirm the Overview shows an assigned private IP address.
- Confirm the Overview shows no public IP address.
Both servers are now running inside snet-workloads. Each VM has a private address plus a dedicated NIC-level NSG.
Linux Deployment or Key Download Failing?
Confirm the image plus size match the values above. Check that snet-workloads remains selected before retrying validation.
If you cannot find the private key, check Safari’s download list. Avoid creating a second VM while the first deployment is still running.
Help me troubleshoot my private Linux VM deployment.
Connect to both VMs through Bastion
Deployment proves the resources exist. A successful Bastion session proves you can administer each private operating system from your Mac.
- Return to the Virtual machines page.
- Select win-ops-01.
- Select Connect from the Overview page.
- Select Connect via Bastion.
Bastion opens the Windows desktop in a browser session. Your local administrator credentials complete the private RDP connection.
- Enter your Windows administrator username in Username.
- Enter your private administrator password in Password.
- Select Connect.
- Press the Windows key inside the remote desktop.
- Type Windows PowerShell in the Windows search field.
- Press Enter to open Windows PowerShell.
Before you run this check, what computer name do you expect Windows to report? Hold your answer in mind.
- Display the Windows computer name by running this command:
$env:COMPUTERNAME
What Does This Check Do?
- The $env:COMPUTERNAME value identifies the current Windows host.
- The result proves your Bastion session reached the intended private VM.
You’ll see WIN-OPS-01. Your Mac is now administering the private Windows server through Bastion.
Windows Bastion Session Not Opening?
Confirm the VM is running before reconnecting. Check that you entered the local administrator username created during deployment.
If Safari blocks the session window, allow the Azure Portal window to open before selecting Connect again.
Help me troubleshoot my Windows Bastion connection.
Bastion Developer supports one VM connection at a time. Closing the Windows session frees the browser-based connection for Linux.
- Disconnect the Windows Bastion session from its session toolbar.
- Return to the Azure Portal browser tab from earlier.
- Return to the Virtual machines page.
- Select linux-ops-01.
- Select Connect from the Overview page.
- Select Connect via Bastion.
The Linux connection uses the private key saved on your Mac. Bastion reads that local file to authenticate the azureuser account.
- Select SSH Private Key from Local File for Authentication Type.
- Enter azureuser in Username.
- Select linux-ops-01-key.pem from your Mac in the private key file field.
- Select Connect.
Before you run the final check, what hostname do you expect this shell to return? Keep your prediction in mind.
- Display the Linux hostname by running this command:
hostname
What Does This Check Do?
- The hostname command prints the name assigned to the current Linux host.
- The result proves the SSH session reached the intended private Ubuntu VM.
You’ll see linux-ops-01. That completes the secure access path to both private servers.
Linux Bastion Session Not Connecting?
Confirm the Windows session is disconnected because the Developer tier supports one active VM connection. Check that the username is exactly azureuser.
Make sure you selected linux-ops-01-key.pem from your Mac. Never paste the private key into a support message.
Help me troubleshoot my Linux Bastion connection.
You’ve reached two real operating systems without assigning a public IP address to either VM. Your Mac can now administer the complete private lab through Bastion.
Both private servers are online plus reachable. Next, you’ll turn one-off system checks into repeatable health reports.
Build Cross-Platform Health Reports
You can now reach both private servers from your Mac through Azure Bastion Developer. A one-off terminal check is hard to repeat during an incident.
This step turns those checks into reusable evidence files. The Windows report also establishes a known-good connection to the Linux SSH service.
In this step, get ready to:
- Generate a reusable Linux health report.
- Create a Windows health script with a JSON output file.
- Confirm Windows can reach Linux over TCP port 22.
Create the Linux health report
A Bash script packages several Linux checks into one repeatable report. Its output appears in the terminal before the same evidence is saved to $HOME/ops-lab/linux-health.txt.
- Return to the linux-ops-01 Bastion session from earlier.
- Create the $HOME/ops-lab directory and write linux-health.sh by pasting this block into the Linux shell:
mkdir -p "$HOME/ops-lab"
cat <<'EOF' > "$HOME/ops-lab/linux-health.sh"
set -euo pipefail
{
printf 'timestamp=%s\n' "$(date --iso-8601=seconds)"
printf 'hostname=%s\n' "$HOSTNAME"
printf 'kernel_release=%s\n' "$(uname -r)"
printf 'ssh_service=%s\n' "$(systemctl is-active ssh.service)"
printf 'root_filesystem=\n'
df -h /
} | tee "$HOME/ops-lab/linux-health.txt"
EOF
What does this code do?
- The first command creates $HOME/ops-lab when the directory does not already exist.
- The quoted EOF delimiter preserves the dollar signs inside the script.
- Strict Bash settings stop the script when a command fails or an unset variable is used.
- The report captures a timestamp plus the hostname. It also captures the kernel release plus the SSH service state.
- The df command reports storage usage for the root filesystem.
- The tee command displays the report while saving an identical copy.
Before you run the script, which line do you expect to confirm that remote SSH is available?
- Run the Linux health report with this command:
bash "$HOME/ops-lab/linux-health.sh"
What does this command do?
The bash command executes every check in linux-health.sh. The script writes the displayed evidence to linux-health.txt during the same run.
You should see a timestamp plus hostname=linux-ops-01. You should also see the kernel release plus ssh_service=active.
Great, your Linux VM now produces a reusable health snapshot. The root filesystem table appears at the bottom of the report.
Linux report missing a value?
Check that the closing EOF starts at the beginning of its line. Extra spaces can leave the shell waiting for more input.
If the SSH state is missing, compare the service identifier with ssh.service.
Help me troubleshoot the Linux health report.
✔️ Awesome, I've got everything!
Your Linux report is running. Confirm that linux-health.txt contains the same evidence shown in the terminal.
ⓧ I'd like to double check the full code
Compare your $HOME/ops-lab/linux-health.sh file with this complete version.
set -euo pipefail
{
printf 'timestamp=%s\n' "$(date --iso-8601=seconds)"
printf 'hostname=%s\n' "$HOSTNAME"
printf 'kernel_release=%s\n' "$(uname -r)"
printf 'ssh_service=%s\n' "$(systemctl is-active ssh.service)"
printf 'root_filesystem=\n'
df -h /
} | tee "$HOME/ops-lab/linux-health.txt"
Create the Windows health report
Windows PowerShell 5.1 can collect system details plus a remote port test. The resulting JSON file preserves those results for later comparison.
The Developer tier supports one active VM session at a time. You need the Windows desktop for this part of the report.
- Disconnect the current Linux Bastion session.
- Return to the win-ops-01 overview in Azure Portal.
- Select Connect.
- Select Connect via Bastion.
- Enter the local administrator credentials from the previous step.
- Select Connect to start the Windows session.
Back on the Windows VM
You should see the Windows desktop for WIN-OPS-01 in the browser. This confirms Bastion has switched the active session from Linux to Windows.
- Select the Start button in the Windows desktop.
- Type Windows PowerShell into the search field.
- Select Windows PowerShell from the search results.
- Create C:\OpsLab by running this command:
New-Item -ItemType Directory -Path C:\OpsLab -Force
What does this command do?
The New-Item command creates a directory at C:\OpsLab. The -Force option also makes the command safe to rerun when the directory already exists.
Good progress. You should see a directory entry for C:\OpsLab in the PowerShell output.
- Create C:\OpsLab\windows-health.ps1 by pasting this single-quoted PowerShell here-string:
@'
param(
[Parameter(Mandatory = $true)]
[string]$LinuxPrivateIp
)
$disk = Get-PSDrive -Name C
$sshTest = Test-NetConnection -ComputerName $LinuxPrivateIp -Port 22 -InformationLevel Detailed
$report = [ordered]@{
ComputerName = $env:COMPUTERNAME
Timestamp = (Get-Date -Format o)
FreeSystemDriveBytes = $disk.Free
LinuxPrivateIp = $LinuxPrivateIp
LinuxSshReachable = $sshTest.TcpTestSucceeded
}
$report | ConvertTo-Json | Set-Content -Path C:\OpsLab\windows-health.json
$report
'@ | Set-Content -Path C:\OpsLab\windows-health.ps1
What does this code do?
- The mandatory LinuxPrivateIp parameter makes every run target a specific Linux address.
- Get-PSDrive reads the free space available on the Windows system drive.
- Test-NetConnection checks whether Windows can establish a TCP connection to port 22 on Linux.
- Get-Date adds a timestamp to the evidence.
- ConvertTo-Json formats the ordered report. Set-Content saves that result to C:\OpsLab\windows-health.json.
PowerShell waiting for more input?
Make sure @' begins on its own line. Make sure '@ begins at the start of the closing line.
If the prompt changes while you paste, press Ctrl+C once. Paste the complete block again from its first line.
Help me fix the PowerShell here-string.
✔️ Awesome, I've got everything!
Your Windows health script is stored in C:\OpsLab. Keep the PowerShell window ready for the connection check.
ⓧ I'd like to double check the full code
Compare C:\OpsLab\windows-health.ps1 with this complete version.
param(
[Parameter(Mandatory = $true)]
[string]$LinuxPrivateIp
)
$disk = Get-PSDrive -Name C
$sshTest = Test-NetConnection -ComputerName $LinuxPrivateIp -Port 22 -InformationLevel Detailed
$report = [ordered]@{
ComputerName = $env:COMPUTERNAME
Timestamp = (Get-Date -Format o)
FreeSystemDriveBytes = $disk.Free
LinuxPrivateIp = $LinuxPrivateIp
LinuxSshReachable = $sshTest.TcpTestSucceeded
}
$report | ConvertTo-Json | Set-Content -Path C:\OpsLab\windows-health.json
$report
Verify the Windows-to-Linux baseline
A successful TCP test proves that Windows can currently reach the Linux SSH service. This healthy baseline gives you evidence to compare with the controlled outage in the next step.
- Return to the Azure Portal tab in Safari.
- Select linux-ops-01 from rg-cross-platform-ops.
- Locate the assigned private IP on the VM overview.
- Record the value here: your-linux-private-ip.
- Return to the win-ops-01 Bastion session.
Before you run the Windows script, do you expect the TCP check to succeed on the private network?
- Run the Windows health report against the recorded Linux private IP with this command:
& C:\OpsLab\windows-health.ps1 -LinuxPrivateIp '[[LINUX_PRIVATE_IP="your-linux-private-ip"]]'
What happens during this check?
PowerShell passes the recorded address into LinuxPrivateIp. The script tests TCP port 22 before displaying the ordered report.
The same report is written to C:\OpsLab\windows-health.json. This file becomes the saved healthy baseline for the incident test.
You should see ComputerName set to WIN-OPS-01. You should also see LinuxSshReachable equal to True.
Before you inspect the saved file, do you expect its reachability value to match the console result?
- Display the saved JSON report by running this command:
Get-Content -Path C:\OpsLab\windows-health.json
What should the saved report contain?
The JSON output should contain ComputerName plus Timestamp. It should also contain FreeSystemDriveBytes plus LinuxPrivateIp.
The final field should show LinuxSshReachable as true. That value proves the private SSH path is healthy before the incident begins.
That's the baseline secured. Both servers now produce repeatable evidence that you can rerun during troubleshooting.
Reachability showing False?
Confirm that the value passed to LinuxPrivateIp matches the current private IP on the linux-ops-01 overview.
Return to the Linux health output if needed. The report should show ssh_service=active.
Help me diagnose the failed TCP baseline.
Your reports now prove the private SSH path is healthy. Next, you will introduce a controlled outage and trace it to the network rule responsible.
Trigger and Diagnose an SSH Outage
Your health reports established a clean baseline. Windows reached the Linux SSH service over the private network.
A failed TCP check exposes a symptom. You still need evidence that separates a network control from a healthy Linux service.
In this step, you create a narrowly scoped Network Security Group rule. You then connect the failed report to the matching effective rule.
In this step, get ready to:
- Record the assigned private IP addresses of both virtual machines.
- Add a targeted inbound deny rule to nsg-linux-ops.
- Prove the network rule caused the outage using independent service evidence.
Record both private IP addresses
The Azure Portal lists the assigned private IP for each virtual machine. These two values let the rule target one specific Windows-to-Linux path.
- In Safari, switch back to the Azure Portal tab.
- Return to rg-cross-platform-ops from earlier.
- Select win-ops-01 from the resource list.
- Find the assigned private IP on the Overview page.
- Record the Windows value here: assigned private IP of win-ops-01.
- Return to rg-cross-platform-ops.
- Select linux-ops-01 from the resource list.
- Find the assigned private IP on the Overview page.
- Record the Linux value here: assigned private IP of linux-ops-01.
Why do both addresses matter?
The Windows address defines the source of the test. The Linux address defines its destination.
This scope leaves other traffic untouched. Your incident stays limited to one private connection.
Create the targeted deny rule
Network security group rules use unique priorities between 100 and 4096. Lower numbers are evaluated first.
The new priority 100 rule takes precedence over the default AllowVNetInBound rule at priority 65000.
- Return to rg-cross-platform-ops.
- Select nsg-linux-ops from the resource list.
- Select Inbound security rules.
- Select the option to add an inbound security rule.
- Select IP Addresses for Source.
- Enter assigned private IP of win-ops-01 in the Source IP addresses/CIDR ranges field.
- Enter * in the Source port ranges field.
- Select IP Addresses for Destination.
These fields identify the originating virtual machine. The wildcard keeps every source port in scope.
- Enter assigned private IP of linux-ops-01 in the Destination IP addresses/CIDR ranges field.
- Select Custom for Service.
- Enter 22 in the Destination port ranges field.
- Select TCP for Protocol.
- Select Deny for Action.
- Enter 100 in the Priority field.
- Enter deny-win-to-linux-ssh in the Name field.
- Select the option that creates the inbound rule.
Good. The inbound list now shows deny-win-to-linux-ssh at priority 100.
Rule missing or rejected?
- Confirm that the resource name is nsg-linux-ops.
- Check whether an existing copy of deny-win-to-linux-ssh already uses priority 100.
- Review every required field before submitting the rule again.
Help me troubleshoot the Azure NSG rule.
Prove the rule caused the outage
The existing Windows PowerShell report tests the same Linux target as your healthy baseline. Its next result reveals whether the new rule changed that path.
The existing Bash report checks the Linux SSH service independently. Together, the reports separate network reachability from service health.
Azure Bastion Developer supports one virtual machine connection at a time. Close each remote session before connecting to the other system.
- Return to the win-ops-01 Overview page.
- Select Connect.
- Select Connect via Bastion.
- Enter the local administrator username from earlier.
- Enter the local administrator password from earlier.
- Select Connect.
Before you rerun the health check, do you expect its TCP result to match the healthy baseline?
- In Windows PowerShell, press the Up Arrow until the earlier windows-health.ps1 invocation appears.
- Confirm that its LinuxPrivateIp argument contains assigned private IP of linux-ops-01.
- Press Enter to rerun the script.
You will see LinuxSshReachable equal False. That controlled failure is the result you wanted.
The script also replaces C:\OpsLab\windows-health.json with the failed result. The original script remains unchanged.
Still seeing True?
- Confirm that the rule source uses assigned private IP of win-ops-01.
- Confirm that the rule destination uses assigned private IP of linux-ops-01.
- Confirm that Protocol is TCP.
- Confirm that Destination port ranges is 22.
- Confirm that Action is Deny.
- Confirm that Priority is 100.
Help me find why the TCP test still succeeds.
- Close the Windows Bastion browser tab.
- Return to the linux-ops-01 Overview page.
- Select Connect.
- Select Connect via Bastion.
- Select SSH Private Key from Local File as the authentication type.
- Enter azureuser as the username.
- Choose linux-ops-01-key.pem from your Mac.
- Select Connect.
- In the Linux shell, press the Up Arrow until the earlier bash invocation appears.
- Confirm that the command references $HOME/ops-lab/linux-health.sh.
- Press Enter to rerun the Linux report.
You will see ssh_service=active in the report. The Linux SSH service is still healthy during the failed Windows test.
Before you inspect the effective rules, which rule do you expect Azure to match for Windows-to-Linux TCP port 22 traffic?
- Close the Linux Bastion browser tab.
- Return to the linux-ops-01 Overview page.
- Select Networking.
- Select Network settings.
- Open the network interface attached to linux-ops-01.
- Expand Help.
- Select Effective security rules.
- Locate deny-win-to-linux-ssh in the effective inbound rules.
You will see deny-win-to-linux-ssh as an effective deny rule at priority 100. Its source address matches Windows while its destination address matches Linux.
How does the evidence isolate the cause?
- The Windows report records LinuxSshReachable as False.
- The Linux report records ssh_service=active.
- The effective rules show deny-win-to-linux-ssh matching the private flow.
- Priority 100 is evaluated before the default allow rule at priority 65000.
✔️ My script stayed unchanged
Your Windows script remains unchanged. Its latest JSON output now records the controlled failure.
ⓧ I'd like to double check the full code
Compare C:\OpsLab\windows-health.ps1 with this unchanged reference:
param(
[Parameter(Mandatory = $true)]
[string]$LinuxPrivateIp
)
$disk = Get-PSDrive -Name C
$sshTest = Test-NetConnection -ComputerName $LinuxPrivateIp -Port 22 -InformationLevel Detailed
$report = [ordered]@{
ComputerName = $env:COMPUTERNAME
Timestamp = (Get-Date -Format o)
FreeSystemDriveBytes = $disk.Free
LinuxPrivateIp = $LinuxPrivateIp
LinuxSshReachable = $sshTest.TcpTestSucceeded
}
$report | ConvertTo-Json | Set-Content -Path C:\OpsLab\windows-health.json
$report
You have pinned the outage to one effective deny rule. Next up, you will remove it to produce recovery evidence.
Restore Connectivity and Record the Incident
The failed health check gave you a confirmed root cause. A targeted rule in the Azure Network Security Group blocked Windows-to-Linux SSH traffic.
Closing the incident now requires a controlled remediation. You will prove recovery before preserving the evidence as a concise handoff.
In this step, get ready to:
- Delete the confirmed deny rule to restore the default network path.
- Rerun the unchanged Windows health script to prove SSH recovery.
- Preserve the incident timeline in a report with screenshots.
Delete the confirmed deny rule
NSG rules are processed from lower priority numbers to higher priority numbers. Deleting the custom priority 100 rule lets the default virtual-network allow rule handle this traffic again.
Deleting a security rule can feel risky. This action removes only the controlled lab fault. The NSG remains in place.
- Take a screenshot of the current Effective security rules view showing deny-win-to-linux-ssh as the matching deny rule.
- Return to nsg-linux-ops in Azure Portal.
- Select Inbound security rules from the left menu.
- Select deny-win-to-linux-ssh.
- Select Delete.
- Select Yes to confirm the deletion.
You should no longer see deny-win-to-linux-ssh in the inbound rule list.
Why does connectivity return?
The deleted rule had priority 100. It matched the Windows source address before Azure reached its default rules.
Removing that match leaves AllowVNetInBound at priority 65000 available for traffic inside vnet-ops-lab.
Prove the Windows-to-Linux recovery
Your saved JSON report still contains the failed result from the previous test. That preserved result gives you before-remediation evidence.
Notepad gives you a readable view of that evidence before the health script replaces the file.
- Disconnect the Linux Bastion session if it is still active.
- Reconnect to win-ops-01 through the Azure Bastion Developer flow from earlier.
- Press the Windows key to open the search bar.
- Type Notepad.
- Press Enter.
- Select File.
- Select Open.
- Navigate to C:\OpsLab.
- Double-click windows-health.json.
You should see LinuxSshReachable set to false. This is the saved failure state from before remediation.
- Take a screenshot of the failed windows-health.json result.
A controlled retest changes one variable. The deny rule is gone while the Windows PowerShell script remains unchanged.
Before you rerun the script, do you expect its TCP test to succeed or fail now?
- Return to Windows PowerShell from earlier.
- Use the assigned Linux private IP in place of your-linux-private-ip when you run this command:
& C:\OpsLab\windows-health.ps1 -LinuxPrivateIp 'your-linux-private-ip'
What does this script check?
- Test-NetConnection checks TCP port 22 on the assigned Linux private IP.
- TcpTestSucceeded records whether that connection completed.
- Set-Content replaces C:\OpsLab\windows-health.json with the latest result.
You should see LinuxSshReachable equal True in the PowerShell output. The rewritten JSON file now stores true.
Recovery is proven. The unchanged test now succeeds after removal of the confirmed network control.
- Take a screenshot of Windows PowerShell showing LinuxSshReachable as True.
Still seeing False?
- Return to the inbound rules for nsg-linux-ops to confirm deny-win-to-linux-ssh is gone.
- Check that the script command uses the assigned private IP for linux-ops-01.
- Rerun the script after the rule list confirms the deletion.
Help me diagnose why the Windows-to-Linux TCP test still fails.
Write the incident handoff
A concise Markdown report turns your troubleshooting sequence into a handoff another operator can follow. It connects the symptom to the evidence before recording the remediation.
- Close Notepad without changing windows-health.json.
- Press the Windows key to open the search bar.
- Type Notepad.
- Press Enter.
- Paste this incident report into the blank document:
# Windows-to-Linux SSH Connectivity Incident
## Symptom
`win-ops-01` could not establish a TCP connection to port 22 on `linux-ops-01`.
## Evidence Before Remediation
The Windows health report recorded `LinuxSshReachable` as `False`. The Linux SSH service remained active, and the Linux NIC effective rules included `deny-win-to-linux-ssh` at priority `100`.
## Root Cause
A custom inbound rule in `nsg-linux-ops` denied TCP port 22 from the private IP address of `win-ops-01` to the private IP address of `linux-ops-01`. Its priority was higher than the default `AllowVNetInBound` rule.
## Remediation
The custom rule `deny-win-to-linux-ssh` was deleted from `nsg-linux-ops`.
## Validation
A new Windows health check recorded `LinuxSshReachable` as `True`, confirming that the Windows server could again reach the Linux SSH service.
How does this report tell the story?
- Symptom records the failed connection from Windows to Linux.
- Evidence Before Remediation connects the failed test to the active Linux service and matching deny rule.
- Root Cause identifies the precise NSG rule that took priority over the default allow rule.
- Remediation records the rule deletion.
- Validation closes the incident with a successful retest.
- Select File in Notepad.
- Select Save as.
- Navigate to C:\OpsLab.
- Enter incident-report.md in the File name box.
- Select All files (*.*) from Save as type.
- Select Save.
The Notepad title bar should show incident-report.md. Your incident record now exists at C:\OpsLab\incident-report.md.
Seeing a .txt extension?
If the title bar shows incident-report.md.txt, save the file again with All files (*.*) selected. Enter incident-report.md in the file name box.
Help me save the report with the correct Markdown extension.
✔️ Awesome, I've got everything!
Great. Your saved incident report now matches the troubleshooting timeline.
ⓧ I'd like to double check the full code
- Compare C:\OpsLab\incident-report.md with this complete reference:
# Windows-to-Linux SSH Connectivity Incident
## Symptom
`win-ops-01` could not establish a TCP connection to port 22 on `linux-ops-01`.
## Evidence Before Remediation
The Windows health report recorded `LinuxSshReachable` as `False`. The Linux SSH service remained active, and the Linux NIC effective rules included `deny-win-to-linux-ssh` at priority `100`.
## Root Cause
A custom inbound rule in `nsg-linux-ops` denied TCP port 22 from the private IP address of `win-ops-01` to the private IP address of `linux-ops-01`. Its priority was higher than the default `AllowVNetInBound` rule.
## Remediation
The custom rule `deny-win-to-linux-ssh` was deleted from `nsg-linux-ops`.
## Validation
A new Windows health check recorded `LinuxSshReachable` as `True`, confirming that the Windows server could again reach the Linux SSH service.
- Return to rg-cross-platform-ops in Safari.
- Take a screenshot of the resource group list showing your lab resources.
Before your final review, do you expect the report and screenshots to tell one consistent story from failure to recovery?
- Review the saved report in Notepad.
- Confirm the report covers the symptom, evidence, root cause, remediation, and validation.
- Confirm your failure evidence includes the false result and the matching deny-win-to-linux-ssh rule.
- Confirm your restored PowerShell screenshot shows LinuxSshReachable as True.
You should now have the resource group screenshot, the failure evidence, and the restored result. The report should preserve the same sequence inside C:\OpsLab\incident-report.md.
That closes the incident. You reproduced the outage, identified the controlling rule, restored connectivity, and preserved evidence of the recovery.
Secret mission
Turn Connectivity into a Health Signal
Upgrade your Windows health report so its raw connectivity result becomes an operator-friendly health state. Test both branches against a real network control to prove the report shows DEGRADED during an outage and HEALTHY after recovery.
Clean Up Your Resources
Clean Up Your Resources
Your Azure lab can keep consuming credit while the virtual machines and disks remain provisioned. Decide whether to keep the lab running, pause the virtual machines, or delete the lab entirely.
Cost warning
Azure Bastion Developer has no hourly or outbound data charge. The two virtual machines and their managed disks consume Azure credit.
Pay-as-you-go charges can apply after an account upgrade or after the available credit is exhausted. The Pause and Delete options below reduce or end this exposure.
Resources you used:
- The Azure resource group rg-cross-platform-ops in East US 2.
- The virtual network vnet-ops-lab with its subnet snet-workloads.
- The Bastion resource bastion-ops-lab.
- The private virtual machines win-ops-01 and linux-ops-01.
- The attached network interfaces with their operating system disks and network security groups nsg-win-ops and nsg-linux-ops.
- The health reports under $HOME/ops-lab and C:\OpsLab inside the virtual machines.
- The incident report at C:\OpsLab\incident-report.md inside win-ops-01.
- The downloaded private key linux-ops-01-key.pem on your Mac.
Keep everything running
No action is needed. Choose this if you are still using the live lab or plan to demonstrate it soon.
- Leave win-ops-01 running for your Windows demonstrations.
- Leave linux-ops-01 running for your Linux demonstrations.
- Keep linux-ops-01-key.pem stored securely on your Mac.
- Monitor your remaining Azure credit while the virtual machines stay provisioned.
Pause - I'll come back to this later
Shut down the two virtual machines to stop compute billing while keeping the lab available. Deallocation may take a little time, so each status can take a moment to update.
- Return to Azure Portal in Safari.
- Enter Resource groups in the portal search bar.
- Select Resource groups from the results.
- Select rg-cross-platform-ops.
- Select win-ops-01.
- Select Stop on the Overview page.
- Confirm the stop request.
The Windows virtual machine begins deallocating while the rest of the lab remains intact.
- Return to rg-cross-platform-ops.
- Select linux-ops-01.
- Select Stop on the Overview page.
- Confirm the stop request.
- Return to the win-ops-01 Overview page to confirm its status is Stopped (deallocated).
- Return to the linux-ops-01 Overview page to confirm its status is Stopped (deallocated).
Once both virtual machines show Stopped (deallocated), Azure stops billing for their VM compute usage. Their disks and some networking resources can continue to consume credit.
Delete - I don't want to use this again
Remove all project resources and start fresh. Deleting the resource group permanently removes both virtual machines plus every report stored inside them.
Deleting an entire resource group feels drastic. Evidence stored outside Azure stays outside this cleanup.
- Return to Azure Portal in Safari.
- Enter Resource groups in the portal search bar.
- Select Resource groups from the results.
- Select rg-cross-platform-ops.
- Select Delete resource group.
- Enter rg-cross-platform-ops to confirm the resource-group name.
- Select Delete.
Azure may take a little time to remove every resource in the group. The group disappears from the resource list when cleanup is complete.
- Refresh the Resource groups list until rg-cross-platform-ops no longer appears.
The Azure deletion does not remove linux-ops-01-key.pem from your Mac.
- Select the Finder icon in the macOS Dock.
- Press Cmd+F to open a Finder search.
- Enter linux-ops-01-key.pem in the search field.
- Select linux-ops-01-key.pem in the results.
- Press Cmd+Delete to move the key to the Bin or Trash.
- Empty the macOS Bin or Trash to erase the private key permanently.
That closes the cost loop. The cloud resources can no longer consume Azure credit.
Nice Work!
Nice Work!
Mission complete! You built a private mixed-OS operations lab in Azure. You then used a controlled SSH outage to prove your incident response workflow.
You've learned how to:
- Administered a private Windows Server through Azure Bastion Developer. Repeated the same secure access pattern for Ubuntu Server. Neither server needed a public IP address.
- Generated repeatable Bash health reports on Linux. Generated Windows PowerShell reports that tested TCP port 22. Preserved each result as JSON or text evidence.
- Created a controlled SSH outage with a targeted network security group rule. Matched the failed TCP test to the Linux network interface's effective security rules. Removed the deny rule to restore connectivity. Captured the full incident in C:\OpsLab\incident-report.md.
- Secret Mission: Extended windows-health.ps1 with a Status field. The report now translates a failed TCP check into DEGRADED. A successful check becomes HEALTHY.
Ready to quiz yourself?