Azure VM Configuration with cloud-init and Key-Based SSH
You will deploy an Azure VM into your VNet, bootstrap it with cloud-init, and access it via key-based SSH only. By the end you will have a secured, reproducible cloud server configured at first boot.
This lab is the natural follow-on to the Virtual Network Deployment lab: instead of stopping at an empty network, you place a real workload inside one of its subnets. The theme is reproducible and secure by default, nothing is configured by hand after the VM exists. All software installation and service setup is declared once in a cloud-init file, applied automatically on first boot, and verified from your terminal. Access is locked down to a single public key and a single source IP, mirroring how you would treat any internet-facing cloud server in the real world.
Two habits run through every step below and are worth carrying into real work. First, every claim gets verified with a command rather than assumed, the portal saying "Running" is not evidence that your service works. Second, every exposure is scoped as tightly as possible the moment it is created, so there is never a window where the box sits open to the internet with default settings.
Everything here is done from the command line on purpose. The Azure portal can perform every action in this lab through wizards, but the CLI version is repeatable, reviewable in a git diff, and identical the tenth time you run it, which is exactly the property you want when you eventually rebuild this as infrastructure as code.
Learning Objectives
- Deploy a VM with SSH-key authentication.
- Bootstrap packages and config with cloud-init.
- Restrict inbound access to your IP only.
- Verify the bootstrap from logs and live service state, not just the portal.
- Time: ~2 hours · Difficulty: Intermediate · Prereqs: a VNet and the Azure CLI.
Architecture Overview
The deployment has four moving parts. The Azure CLI sends a single declarative az vm create request; Azure provisions the VM into your existing web subnet; the cloud-init file is passed in as custom data and executed by the cloud-init service on the very first boot; and the auto-created network security group (NSG) is tightened so only your public IP can reach port 22. There is no password path at any point, the only credential that ever works is your private SSH key.
Understanding the order of events matters when something goes wrong, because each stage has its own failure mode and its own logs:
az vm createsubmits the deployment to Azure Resource Manager, which creates five resources in your resource group: the VM itself, a NIC, a public IP, a managed OS disk, and an NSG.- The VM boots for the first time. The Azure fabric injects your public key into the
takiaccount'sauthorized_keysand, because a key was supplied, leaves password authentication disabled insshd. - cloud-init detects the custom data you passed with
-custom-data, refreshes APT metadata, installs nginx, and runs yourruncmdentries. - Only then is the server actually "done", typically one to three minutes after the CLI reports success.
Two defaults are worth knowing about even though this lab does not change them. The OS disk is a managed disk, Azure handles replication of it within the region, and it survives the VM being stopped and even deleted unless you say otherwise. And the VM lands in no specific availability zone, which is fine for a lab; production workloads would add zone placement or a scale set, but that is a different conversation and a different price tier.
Environment Setup
Prerequisites
- An Azure subscription with Contributor access, and the Azure CLI configured (see the Azure CLI Setup lab).
- An SSH keypair (
ssh-keygen -t ed25519); a terminal with an SSH client. - Assumed skills: basic Linux commands and YAML indentation (cloud-init is whitespace-sensitive).
You will need: a VNet/subnet (from the VNet lab), the Azure CLI, and an SSH keypair.
Before you begin: note your public IP to scope SSH access.
Sign in and set defaults. Authenticate the CLI and point it at the subscription and resource group you used for the VNet lab, so every later command can omit the -g flag. Setting defaults once up front is a small habit that prevents the classic mistake of deploying a VM into the wrong resource group.
How to verify: run az account show --query name -o tsv and confirm it prints the subscription you intended. If it prints the wrong one, repeat az account set with the correct name or ID before going any further. Also confirm the resource group itself is present, az configure happily sets a default group that does not exist, and you only find out at deploy time:
It is also worth confirming the CLI itself is reasonably current, flags and output formats below were checked against the 2.x series:
Confirm the target network exists. The VM will land in the web subnet of vnet-a, so take ten seconds to prove both are really there before deploying. A typo in either name fails the whole deployment after a minute of waiting, catching it now is cheaper.
How to verify: the command returns the subnet's address prefix instead of a ResourceNotFound error. If it errors, go back to the VNet lab and finish it first, this lab assumes that foundation.
Create an SSH keypair if you do not have one. Ed25519 keys are short, fast, and the current best practice; there is no reason to use RSA for a new deployment. The public half (id_ed25519.pub) is what you hand to Azure, the private half never leaves your machine.
Your public key has been saved in /home/you/.ssh/id_ed25519.pub
Common mistake: overwriting an existing key. If ~/.ssh/id_ed25519 already exists, either reuse it or pick a new filename such as id_ed25519_azure, overwriting it will lock you out of everywhere the old key was authorized.
Record your current public IP. This is the address you will scope the SSH rule to in step 03. The lab uses the documented example range below; substitute the value the command actually returns for you.
If ifconfig.me is unreachable from your network, https://api.ipify.org and https://checkip.amazonaws.com return the same single line of text and work identically.
Common mistake: looking the IP up on a "what is my IP" website while on VPN, then scoping the NSG to the VPN exit address instead of the address your SSH client will actually source from. Check it from the same terminal and network you will SSH from.
Windows users: every command in this lab works identically in PowerShell or WSL2 once the Azure CLI is installed, the only differences are the tilde path for the SSH key (~/.ssh resolves fine in both) and that head/tail are Unix-only, so use PowerShell's Select-Object -First where noted or just run the lab inside WSL.
Step-by-Step Execution
cloud-init is the industry-standard bootstrap system baked into every major cloud Linux image. The file must start with the literal header #cloud-config on line one, without it the VM treats the content as inert data and nothing happens. The directives below are deliberately minimal: refresh package metadata, install nginx, then enable and start it. Because this is declarative, re-running the lab produces an identically configured server every time.
# cloud-init.yaml
#cloud-config
package_update: true
packages: [nginx]
runcmd:
- systemctl enable --now nginx
Expected output:
$ cloud-init schema --config-file cloud-init.yaml
Valid cloud-config: cloud-init.yaml
package_update: true runs the equivalent of apt-get update so the package index is fresh; packages is a list of APT packages to install; runcmd executes arbitrary commands late in the boot, after packages are in place. The -now flag both enables nginx at boot and starts it immediately.
cloud-init can do far more than install packages, and the same file format scales to real hardening work: a write_files block can drop a tuned sshd_config or nginx site config in place, a users block can create additional accounts with their own keys, and setting ssh_pwauth: false makes the key-only posture explicit in the file itself rather than relying on Azure's default. For this lab the three directives above are enough, add complexity only when the simple version works.
One behavior to internalize early: custom data runs once, on first boot. Editing the file on your laptop does nothing to an existing VM, and re-attaching it does not re-trigger the run. The supported way to iterate is to wipe cloud-init's state on the VM (sudo cloud-init clean --logs), reboot, and let it treat the next boot as the first, or simply delete the VM and redeploy, which in this lab takes about two minutes.
Common mistake: broken YAML. cloud-init files are whitespace-sensitive, a tab character or misaligned list item makes the run fail silently. Validate locally before deploying with cloud-init schema --config-file cloud-init.yaml if you have cloud-init installed locally, or at minimum paste it through a YAML linter.
What this teaches: cloud-init turns a generic marketplace image into a configured server during first boot; everything after depends on this file being right.
One command creates the VM, attaches it to your existing web subnet, injects your public key for the taki user, and passes the cloud-init file as custom data. Passing -ssh-key-values (instead of -admin-password) is what disables password authentication in sshd, Azure configures the image for key-only auth automatically when a key is supplied. Each flag earns its place: -image Ubuntu2204 selects the LTS image alias, -vnet-name/-subnet place the NIC inside your own network rather than a throwaway one, and -custom-data wires in the bootstrap file from step 01.
az vm create -n web01 --image Ubuntu2204 --vnet-name vnet-a --subnet web --admin-username taki --ssh-key-values ~/.ssh/id_ed25519.pub --custom-data cloud-init.yamlOn size: without a -size flag Azure picks a default for the image (typically a D-series general-purpose VM). For a lab, a burstable B-series is dramatically cheaper and perfectly adequate, add -size Standard_B2s if your subscription quota allows it. Size never affects the security posture, only the bill and the speed of the package install.
Note on the admin username: Azure reserves names like root, admin, and administrator and will reject them outright. Using a personal, non-obvious username such as taki also shaves a little noise off the constant background hum of bots trying root against every open port 22 on the internet.
Two useful variations on the same command: -ssh-key-values accepts several public keys separated by spaces if more than one person needs access, and swapping -image for another alias (such as Debian11 or a full URN) changes the operating system without touching anything else, cloud-init's package and runcmd model is the same across Debian-family images.
The command blocks for a minute or two while Azure provisions the resources (VM, NIC, public IP, managed OS disk, and an auto-created NSG named web01NSG), then returns a large JSON document. The two fields worth noting are the provisioning state and the public IP:
"publicIpAddress": "20.151.42.17"
That single command actually created five separate billable resources in tyf-rg. Listing them is a good sanity check, and it also shows you exactly what to clean up later:
------------ -------------------------------------- -------------
web01 Microsoft.Compute/virtualMachines canadacentral
web01VMNic Microsoft.Network/networkInterfaces canadacentral
web01PublicIP Microsoft.Network/publicIPAddresses canadacentral
web01NSG Microsoft.Network/networkSecurityGroups canadacentral
web01_OSDisk Microsoft.Compute/disks canadacentral
How to verify: "provisioningState": "Succeeded" only means the VM resource exists, it says nothing about cloud-init, which typically still needs another 1-3 minutes to finish installing packages. Do not panic if nginx is not answering the second the command returns; step 05 shows how to wait properly.
Common mistake: omitting -vnet-name/-subnet. Without them, Azure silently creates a new VNet for the VM, and it will not be in the segmented network you built in the VNet lab. If that happened, delete the VM and redeploy, you cannot move a VM's NIC between VNets in place.
Expected output:
{
"name": "tyf-vm01",
"provisioningState": "Succeeded",
"publicIpAddress": "20.42.17.9"
}
What this teaches: Key-based auth removes passwords from the equation; the private key never leaves your machine.
az vm open-port adds an inbound allow rule for port 22 to the VM's NSG, but its default source is *, the whole internet. Leaving SSH open to the world invites password-spraying and key-scanning bots within minutes, so the second half of the command rewrites that rule's source to the single /32 address you recorded during setup. Priority 100 places the rule ahead of the lower-priority default rules so it takes effect deterministically.
NSGs evaluate rules in priority order, lowest number first, and stop at the first match. Below your new priority-100 allow sit Azure's default rules: AllowVnetInBound (priority 65000) permits traffic inside the VNet, and DenyAllInBound (priority 65500) refuses everything else. The practical result is exactly what you want, your IP reaches SSH, other VNet resources can still talk to the VM, and the rest of the internet is dropped without you writing a single deny rule.
How to verify: list the rules and confirm exactly one inbound allow exists for port 22 with your /32 as the source:
------------ -------- ---------------- --------------------- --------------------- -------
open-port-22 100 * 203.0.113.10/32 22 Allow
For the authoritative view of what the VM's NIC will actually permit, which matters once subnet-level NSGs enter the picture in the next lab, ask the NIC for its effective rules, which merges every NSG applied to it:
az network nic list-effective-nsg -n web01VMNic -o tableCommon mistake: scoping the rule to an IP that later changes. Home connections usually sit behind dynamic DHCP addresses; if SSH suddenly stops working next week, re-run curl -s https://ifconfig.me and update the rule with the new address before assuming the VM is broken.
Expected output:
{
"name": "allow-ssh-home",
"properties": {
"access": "Allow",
"destinationPortRange": "22",
"priority": 100,
"provisioningState": "Succeeded"
}
}
What this teaches: An NSG rule for your IP is allowlisting at the network edge; it shrinks the audience for SSH from the internet to one address.
Rather than copying the public IP out of the portal, query it directly from Azure and feed it straight to the SSH client. The $() substitution runs az vm show -d (the -d shows runtime details including the public IP) and prints just the address as plain text. The private key in the default location is offered automatically, so no password prompt should ever appear.
ssh taki@$(az vm show -d -n web01 --query publicIps -o tsv)On first connection the client asks you to trust the server's host key, this is normal exactly once per host:
ED25519 key fingerprint is SHA256:xK3...
Are you sure you want to continue connecting (yes/no)? yes
taki@web01:~$
If the connection fails, add -v (or -vvv for maximum detail) to the SSH command, the verbose output shows exactly which keys the client offered and what the server rejected, which turns "permission denied" mysteries into ten-second diagnoses.
If you will manage this box regularly, save yourself the substitution dance with an entry in your SSH config. After this, ssh web01 is the whole command:
# ~/.ssh/config
Host web01
HostName 20.151.42.17
User taki
IdentityFile ~/.ssh/id_ed25519
Expected output:
The authenticity of host '20.42.17.9' can't be established.
ED25519 key fingerprint is SHA256:EXAMPLEfingerprint.
Are you sure you want to continue connecting (yes/no)? yes
taki@tyf-vm01:~$
How to verify: you land at a shell prompt on web01 without ever being asked for a password. If you are prompted for a password at any point, key authentication is not in play, see the Troubleshooting section before typing anything.
Common mistake: a REMOTE HOST IDENTIFICATION HAS CHANGED warning on a later rebuild. Deleting and recreating the VM gives the same IP a new host key; remove the stale entry with ssh-keygen -R 20.151.42.17 and reconnect.
What this teaches: The first SSH connection verifies the whole chain at once: network path, key auth, and a running SSH daemon.
Never assume the bootstrap ran, check it. cloud-init status --wait blocks until the run completes and then reports the outcome, which makes it perfect for scripts and for the few minutes right after provisioning. A done status with exit code 0 means every directive in your file executed without error.
status: done
cloud-init runs in stages, network configuration first, then module stages for packages and config, and runcmd near the end, which is why a VM can accept SSH connections while the bootstrap is still mid-flight. Connecting early is harmless; just do not judge the result until the status reads done. If you are curious where the time went, cloud-init analyze show prints a per-module timing breakdown:
The total time elapsed since receiving an event is 00:01:12.443
00:00:41.012 - modules-final (runcmd)
00:00:22.318 - modules-config (package installs)
If anything failed, the human-readable log of everything your directives printed lives in /var/log/cloud-init-output.log; the structured module-by-module trace is in /var/log/cloud-init.log, and cloud-init's own state (including the data source it detected and per-module results) is kept under /var/lib/cloud/:
Created symlink /etc/systemd/system/multi-user.target.wants/nginx.service ...
Cloud-init v. 23.x finished at ...
Common mistake: reading status: done as proof the service works. cloud-init reports on its own run, not on the health of what it installed, a package can install fine and still fail to start. The next step closes that gap.
Expected output:
$ cloud-init status --wait
status: done
What this teaches: cloud-init logs are the ground truth for boot customization; read them before guessing.
Two final checks turn "the VM exists" into "the lab worked". First, confirm nginx is actually running and answering on the VM itself, testing locally on the box sidesteps the NSG, which currently only allows port 22:
<!DOCTYPE html>
<html>
<head>
A listening-socket check confirms exactly which ports the box exposes, which should be 22 (sshd) and 80 (nginx) and nothing unexpected:
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* nginx
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* sshd
Second, prove the security posture rather than trusting it. Ask the running sshd for its effective configuration and confirm password authentication is off, this is the check that validates the key-only decision made back in step 02:
pubkeyauthentication yes
sshd -T dumps the effective config after all includes and defaults are resolved, so it cannot be fooled by a commented-out line or an unexpected file in sshd_config.d/ the way a plain grep over the config file can. While you are here, note that Ubuntu's unattended-upgrades is enabled on this image by default, so security patches apply themselves, one less thing to schedule. You can confirm with systemctl is-enabled unattended-upgrades, which should print enabled.
How to verify: active, the nginx welcome HTML, and passwordauthentication no together mean the server is bootstrapped, serving, and unreachable by password guessing. If you later want to browse to nginx from your own machine, open port 80 the same scoped way as step 03: az vm open-port -n web01 --port 80 --priority 110 and then update the rule's source to your /32.
Expected output:
$ curl -s -o /dev/null -w "%{http_code}" http://localhost/
200
$ ss -tlnp | grep :22
LISTEN 0 128 0.0.0.0:22
What this teaches: Verification has two halves: prove the service works, and prove the attack surface is as small as you intended.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
With the VM deployed and SSH scoped, the remaining work (steps 04-06) happens on the box itself: connect, confirm the bootstrap, and validate the service and its security posture.
How It Works
cloud-init is the industry-standard way to customize a cloud VM on its first boot. Azure passes your configuration as customData through the platform's wire server; on boot, cloud-init runs in stages (generator, local, network, config, final) and applies your users, packages, files, and runcmd entries in order. Because it runs once at first boot, a mistake means rebuilding the VM or fixing it by hand, which is why validating the config before deploying pays for itself.
Authentication is split into two independent layers. The SSH keypair proves who you are without a password ever crossing the network; the network security group decides which source IPs may even attempt the handshake. Either layer alone is weak: keys without an NSG are exposed to the whole internet's scanners, and an NSG without keys still allows password guessing from your own network.
Everything cloud-init does is logged. /var/log/cloud-init.log and cloud-init status are the ground truth when a boot customization misbehaves; guessing from symptoms is slower than reading the log once. This generalizes: on Azure, the serial console and boot diagnostics exist for exactly the moments when SSH itself is the thing that is broken.
Testing & Validation
Everything below runs from your own machine in one shot, no portal clicking required. The final command chains the whole validation: resolve the VM's public IP from Azure, connect with your key, and ask systemd whether the cloud-init-installed service is alive.
ssh taki@$(az vm show -d -n web01 --query publicIps -o tsv) 'systemctl is-active nginx'You should connect with your key (no password) and see active, proving cloud-init bootstrapped the server.
For a complete end-to-end picture, run the full validation sequence and compare against the expected results. The --query flags use JMESPath to reshape Azure's verbose JSON into exactly the fields you care about, the brace syntax {state:powerState,...} builds a small object, and the [?...] filter keeps only the NSG rule for port 22:
# 1. VM exists and is running
az vm show -d -n web01 --query '{state:powerState,ip:publicIps}' -o jsonc
# { "state": "VM running", "ip": "20.151.42.17" }
# 2. NSG exposes port 22 to your IP only
az network nsg rule list -g tyf-rg --nsg-name web01NSG \
--query "[?destinationPortRange=='22'].{src:sourceAddressPrefix,access:access}" -o table
# Src Access
# --------------- --------
# 203.0.113.10/32 Allow
# 3. Key-only SSH and the bootstrapped service
ssh [email protected] 'systemctl is-active nginx'
# active
All three checks passing is your definition of done: the resource is healthy, the network exposure matches intent, and the unattended bootstrap produced a working service. If any one fails, the Troubleshooting section maps the symptom to a fix.
A proper security validation also includes a negative test: connect from a network that is not your scoped IP, tether to your phone, for instance, and try SSH. It should hang until it times out, with no banner and no prompt:
A timeout here is the proof that the NSG rule is doing its job; if you unexpectedly get in, the rule's source is too broad and step 03 needs a second look.
Troubleshooting
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| SSH times out with no response | NSG has no allow rule for your IP, or your public IP changed | Re-check the allow rule against your current public IP; residential IPs rotate. |
| Permission denied (publickey) | The VM has a different key than the client offers | Compare with az vm show public key data and use ssh -i with the right private key. |
| cloud-init changes never applied | customData was not passed, or the config failed schema validation | Read /var/log/cloud-init.log; validate YAML with cloud-init schema --config-file before redeploying. |
| VM stuck in Creating for a long time | Quota exhaustion for the size in the region | Try a different VM size or region; check usage with az vm list-usage. |
| Package installs fail inside cloud-init | Transient mirror or DNS failure at boot | Add retries in runcmd, or re-run the failing commands over SSH to distinguish transient from broken. |
Optional Hardening Quick Wins
Out of scope for the graded lab but cheap to do while you are connected: enable the host firewall as a second layer underneath the NSG. Defense in depth means a mistake in one layer does not expose the box, if someone later broadens the NSG rule, ufw still only allows what you permitted:
Rule added
Firewall is active and enabled on system startup
The order matters: allow SSH before enabling, or you lock yourself out of your own VM mid-session. For a fuller pass, disabling root login, changing the SSH port, adding fail2ban, follow the SSH Hardening lab on this same VM.
Troubleshooting
- cloud-init did not run: check
/var/log/cloud-init-output.logon the VM. The most common cause is a missing#cloud-configfirst line or broken YAML indentation, fix the file, then redeploy (custom data only executes on first boot, so editing the file alone does nothing for an existing VM). - SSH times out: confirm the NSG allows port 22 from your current IP with
az network nsg rule list -g tyf-rg --nsg-name web01NSG -o table. Also re-check that your public IP has not changed since step 03 (curl -s https://ifconfig.me) and update the rule's source if it has. - Password prompt: deploy with
-ssh-key-values, not-admin-password. A password prompt on a key-deployed VM usually means the client offered no key the server accepts, run the connection withssh -vvvand look forOffering public keylines to confirm your key is being presented at all. - Permission denied (publickey): you are connecting as the wrong user or with the wrong key. The login name must match
-admin-username(taki), and the private key must be the pair of the.pubfile passed at creation, specify it explicitly withssh -i ~/.ssh/id_ed25519 taki@...if your agent is offering a different one. - az vm create fails with a quota or SKU error: new subscriptions have low per-region vCPU quotas and some sizes are unavailable in some regions. Retry with a smaller size such as
-size Standard_B2s, or request a quota increase in the portal under Subscriptions › Usage + quotas.
Cleanup
Azure bills a running VM by the second, so decide what "done" means for your wallet. If you want to keep the lab around but stop the meter, deallocate the VM, compute billing stops while the disk, IP configuration, and NSG stay intact for next time:
Note the difference between stopping a VM from inside the OS and deallocating it through Azure: a plain shutdown releases nothing on the Azure side and compute billing continues, while deallocation releases the underlying hardware allocation. The portal's "Stop" button deallocates; sudo shutdown -h now over SSH does not.
If the lab is finished entirely, delete the resource group to remove the VM and all five resources it created in one operation:
Confirm the group is really gone before walking away, a failed dependency can quietly leave resources billing behind:
Warning: deleting the group also deletes vnet-a and anything else living in tyf-rg, including work from the other Cloud & Azure labs. If you share the group, delete only the five web01* resources individually with az vm delete -n web01 --yes instead.
Key Takeaways
- Declare the server's whole first-boot configuration in one cloud-init file, it makes the build reproducible and reviewable.
- Passing
-ssh-key-valuesat creation is what disables password authentication; key-only SSH is a deploy-time decision, not an afterthought. az vm open-portdefaults to an internet-wide source, always follow it by scoping the rule to your own /32."provisioningState": "Succeeded"means the VM exists, not that it is configured; usecloud-init status --waitto know when bootstrap is really finished.- Verify posture empirically:
systemctl is-active,curl localhost,ss -tlnp, andsshd -Tbeat trusting the portal, and a negative test from another network proves the NSG actually blocks. - Stop or delete what you are not using; cloud bills accrue whether or not anyone is looking at the server, and "stopped" is not the same as "deallocated".
Extension Ideas
- Tighten traffic further with NSGs & Route Tables, add a second NSG at the subnet level and steer traffic with user-defined routes.
- Harden the OS with SSH Hardening, move SSH off port 22, disable root login, and add fail2ban on top of this build.
- Replace imperative creation with ARM templates, export the deployed resources and rebuild the whole lab declaratively.
Key Results
- Deployed a cloud VM bootstrapped automatically at first boot.
- Enforced key-only SSH with no password authentication.
- Restricted inbound SSH to a single source IP.
- Verified the service end to end over SSH.
Same build, run twice, produces the same server, that is the point of doing it declaratively, and the foundation every later cloud lab builds on.
Practice Challenges
Challenge 1: Serve a page via cloud-init
Goal: Extend the cloud-init config to install nginx and write a custom index page, then verify over HTTP.
Hint: Do it in runcmd so ordering is explicit, and remember the NSG needs port 80 open too.
Challenge 2: Attach and mount a data disk
Goal: Add a 32 GB data disk at deploy time and mount it at /data from cloud-init.
Hint: Use the disk_setup and mounts modules rather than shell commands; they are idempotent.
Challenge 3: Lock SSH to a bastion subnet
Goal: Change the NSG rule so SSH is only reachable from a second subnet, then prove direct internet SSH fails.
Hint: Test from a VM in that subnet; the failure from your laptop is the proof.