The previous lesson ended with an uncomfortable observation: everything Contoso Airlines has deployed so far — the legacy engine's VMs, the API scale set, the App Service applications and the boarding pass storage account — communicates over the internet. It works, but no serious architecture stays like that. And fixing it afterwards is far more expensive than doing it right from the start, because a virtual network's address space conditions everything that connects to it for years.

That is why every serious architecture starts with the network. Changing a VM's size takes two minutes; changing the address range of a virtual network with a hundred resources inside it and a VPN to two offices is a project with a service outage attached.

In this lesson you will design and deploy Contoso's complete network: the address space of vnet-contoso-pro with CIDR arithmetic explained from scratch, its four subnets, the network security groups that open only what is strictly necessary, private DNS resolution, peering between networks with the hub-and-spoke pattern, the decisive difference between service endpoints and Azure Private Link, how App Service connects to the network, and why nobody should expose SSH to the internet when Azure Bastion exists.

Cost warning: virtual networks, subnets and NSGs cost nothing. What does cost money are private endpoints (per hour and per data processed), static public IPs and, above all, Azure Bastion, which is billed by the hour from the moment it exists. The cleanup is at the end.

Contents

  1. Why the network comes first
  2. Virtual networks and address space
  3. CIDR arithmetic without the pain
  4. Subnet design for vnet-contoso-pro
  5. Addresses reserved by Azure in every subnet
  6. Network security groups (NSGs)
  7. Service tags and application security groups
  8. Public and private IPs, static and dynamic
  9. DNS in Azure and private DNS zones
  10. Virtual network peering and hub-and-spoke
  11. Service endpoints versus Azure Private Link
  12. App Service integration with the virtual network
  13. Azure Bastion versus exposing SSH and RDP
  14. Verification with Network Watcher
  15. The complete topology and cleanup
  16. Common Mistakes and Tips
  17. Exercises
  18. Conclusion

  1. Why the network comes first

A virtual network (VNet) is your private network inside Azure: an isolated address space that you control, in which you place resources that can see each other and through which you decide what comes in and what goes out.

What the network determines, and why it comes first:

Network decision What it conditions
Address space Which on-premises networks you will be able to connect to without overlaps, forever
Subnet sizes How many resources fit; growing a subnet that already has resources is problematic
Segmentation What can talk to what when somebody gets in where they should not
Private connectivity Whether your data travels over the internet or never leaves Microsoft's network
Region A VNet lives in one region; it connects to others through peering

And a warning about what you cannot do: two networks with overlapping address spaces cannot be peered or joined by VPN. If you pick 10.0.0.0/16 because it is the example in every tutorial, and your Barcelona office uses 10.0.0.0/16, you have created a problem that can only be fixed by renumbering one of the two.

  1. Virtual networks and address space

A VNet has one or more address spaces in CIDR notation, taken from the private ranges of RFC 1918:

Private range Addresses Common use
10.0.0.0/8 16.7 million Large corporate networks; the most used in Azure
172.16.0.0/12 1 million Medium-sized networks
192.168.0.0/16 65,536 Home networks and small offices

Contoso Airlines' addressing plan, decided by Marta Ríos with the office VPN in mind (lesson 02-06):

Network Space Use
vnet-contoso-hub-pro 10.10.0.0/16 Hub: VPN gateway, Bastion, shared services
vnet-contoso-pro 10.20.0.0/16 Production spoke: web, application, data, management
vnet-contoso-dev 10.30.0.0/16 Development spoke
Barcelona office 10.100.0.0/16 Existing on-premises network
Palma office 10.101.0.0/16 Existing on-premises network

Notice the discipline: separate /16 blocks with nothing overlapping, and gaps reserved between them for growth. This costs nothing now and avoids a painful migration three years from now.

NET_GROUP="rg-contoso-red-pro"   # the long-lived networking group from module 1
REGION="westeurope"
TAGS=(entorno=produccion proyecto=contoso-reservas centro-coste=CC-1042
      [email protected] criticidad=alta)

az network vnet create \
  --resource-group "${NET_GROUP}" \
  --name vnet-contoso-pro \
  --location "${REGION}" \
  --address-prefixes 10.20.0.0/16 \
  --tags "${TAGS[@]}" \
  --output table

Remember why the network lives in rg-contoso-red-pro and not alongside the applications: the grouping criterion from lesson 01-05 was lifecycle + environment, and the network outlives many generations of applications.

  1. CIDR arithmetic without the pain

If CIDR already feels natural to you, skip to the next section. If not, this is the bare minimum, and three ideas are enough.

Idea 1: the number after the slash is the count of fixed bits. An IPv4 address has 32 bits. In 10.20.1.0/24, the first 24 bits are the network (fixed) and the remaining 8 identify the host (variable).

Idea 2: the more fixed bits, the smaller the network. A /24 is smaller than a /16. The arithmetic is direct: 2^(32 - prefix) addresses.

Prefix Total addresses Usable in Azure (−5) Equivalent to
/16 65,536 65,531 A complete virtual network
/20 4,096 4,091 A large block of subnets
/24 256 251 A typical subnet
/26 64 59 A small subnet (the minimum for Bastion)
/27 32 27 The minimum for GatewaySubnet
/29 8 3 The smallest subnet allowed in Azure

Idea 3: to carve things up, you move block by block. A /24 covers 256 addresses, so inside 10.20.0.0/16 the /24 subnets step one at a time through the third octet:

10.20.0.0/16  →  from 10.20.0.0 to 10.20.255.255 (65,536 addresses)
  ├── 10.20.1.0/24   →  10.20.1.0   to  10.20.1.255
  ├── 10.20.2.0/24   →  10.20.2.0   to  10.20.2.255
  ├── 10.20.3.0/24   →  10.20.3.0   to  10.20.3.255
  └── 10.20.4.0/24   →  10.20.4.0   to  10.20.4.255

And if a subnet needs less, it is carved up with longer prefixes. Inside 10.20.250.0/24:

10.20.250.0/26   →  10.20.250.0   to  10.20.250.63   (64 addresses)
10.20.250.64/26  →  10.20.250.64  to  10.20.250.127
10.20.250.128/26 →  10.20.250.128 to  10.20.250.191
10.20.250.192/26 →  10.20.250.192 to  10.20.250.255

A mental rule that solves 90% of day-to-day cases: a /24 is "a block of 256 with the same third octet". Plan with /24 unless you know you need something else, and leave gaps between subnets for growth.

  1. Subnet design for vnet-contoso-pro

A subnet is a portion of the virtual network's address space where the resources are actually placed. You segment by function, because segmentation is what lets you apply different rules to each layer.

Subnet Range What it contains Who talks to it
snet-web 10.20.1.0/24 Public front end, Application Gateway The internet (443 only)
snet-app 10.20.2.0/24 Availability API, App Service integration snet-web only
snet-datos 10.20.3.0/24 Private endpoints for SQL and Storage snet-app only
snet-gestion 10.20.4.0/24 Legacy engine VM, administration servers snet-gestion and Bastion only
AzureBastionSubnet 10.20.250.0/26 Azure Bastion (mandatory name) A managed service
GatewaySubnet 10.20.255.0/27 VPN gateway (mandatory name, lesson 02-06) A managed service
NET_GROUP="rg-contoso-red-pro"
VNET="vnet-contoso-pro"

# Functional subnets.
for PAIR in "snet-web:10.20.1.0/24" "snet-app:10.20.2.0/24" \
            "snet-datos:10.20.3.0/24" "snet-gestion:10.20.4.0/24"; do
  NAME="${PAIR%%:*}"
  RANGE="${PAIR##*:}"
  az network vnet subnet create \
    --resource-group "${NET_GROUP}" \
    --vnet-name "${VNET}" \
    --name "${NAME}" \
    --address-prefixes "${RANGE}" \
    --output none
  echo "Subnet ${NAME} created with ${RANGE}"
done

# Managed service subnets: the name is mandatory and literal.
az network vnet subnet create -g "${NET_GROUP}" --vnet-name "${VNET}" \
  --name AzureBastionSubnet --address-prefixes 10.20.250.0/26 --output none

az network vnet subnet create -g "${NET_GROUP}" --vnet-name "${VNET}" \
  --name GatewaySubnet --address-prefixes 10.20.255.0/27 --output none

# Check.
az network vnet subnet list -g "${NET_GROUP}" --vnet-name "${VNET}" \
  --query "[].{Subnet:name, Range:addressPrefix}" --output table

Reserved names that have to be respected to the letter, because Azure looks for them literally:

Mandatory name Service Recommended minimum size
GatewaySubnet VPN or ExpressRoute gateway /27 (better /26)
AzureBastionSubnet Azure Bastion /26
AzureFirewallSubnet Azure Firewall /26

And a restriction with consequences: a subnet can be grown, but not if resources get in the way, and it cannot be shrunk with resources inside. Size with headroom.

  1. Addresses reserved by Azure in every subnet

In a /24 subnet you do not get 256 addresses, nor 254, but 251. Azure reserves five in every subnet:

Address (in 10.20.1.0/24) Reserved for
10.20.1.0 Network identifier (standard)
10.20.1.1 Azure's default gateway
10.20.1.2 Assigned to Azure DNS (virtual server mapping)
10.20.1.3 Reserved for future Azure use
10.20.1.255 Broadcast (standard)

So the first assignable address in snet-web is 10.20.1.4. This matters when you size right to the limit: a /29 subnet has 8 addresses and only 3 usable. If you planned "eight servers in a /29", they do not fit.

  1. Network security groups (NSGs)

A network security group is a list of stateful filtering rules applied to a subnet or to a NIC. Stateful means that if you allow an inbound connection, the outbound response is allowed automatically; there is no need to write the reverse rule.

Each rule has:

Field What it is
Priority From 100 to 4096. Evaluated from lowest to highest, and the first match wins
Source / Destination An IP, a CIDR, a service tag or an application security group
Ports Source (almost always *) and destination
Protocol Tcp, Udp, Icmp or *
Direction Inbound or Outbound
Action Allow or Deny

Default rules

Every NSG comes with invisible rules that cannot be deleted, only overridden with lower priorities:

Priority Name Effect
65000 AllowVnetInBound Allows all traffic within the virtual network
65001 AllowAzureLoadBalancerInBound Allows the load balancer's probes
65500 DenyAllInBound Denies everything else coming in
65000 AllowVnetOutBound Allows outbound traffic within the virtual network
65001 AllowInternetOutBound Allows outbound traffic to the internet
65500 DenyAllOutBound Denies the rest of the outbound traffic

Two consequences to internalize:

  1. By default, all traffic within the VNet is allowed, even between different subnets. Segmentation is not automatic: you have to write it.
  2. By default, everything can go out to the internet. If you want to prevent that, you have to deny it explicitly.

Segmenting Contoso's layers

NET_GROUP="rg-contoso-red-pro"
REGION="westeurope"

# --- Web layer NSG ---
az network nsg create -g "${NET_GROUP}" -n nsg-snet-web -l "${REGION}" --output none

# Allow HTTPS from the internet.
az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-web \
  --name permitir-https-internet --priority 100 \
  --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes Internet --source-port-ranges '*' \
  --destination-address-prefixes '*' --destination-port-ranges 443 \
  --description "Public traffic for Contoso Bookings" --output none

# Deny plain HTTP: the application redirects, but the network does not accept it.
az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-web \
  --name denegar-http-plano --priority 110 \
  --direction Inbound --access Deny --protocol Tcp \
  --source-address-prefixes Internet --destination-port-ranges 80 \
  --output none

az network vnet subnet update -g "${NET_GROUP}" --vnet-name vnet-contoso-pro \
  --name snet-web --network-security-group nsg-snet-web --output none

# --- Application layer NSG: it only accepts traffic from the web layer ---
az network nsg create -g "${NET_GROUP}" -n nsg-snet-app -l "${REGION}" --output none

az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-app \
  --name permitir-api-desde-web --priority 100 \
  --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes 10.20.1.0/24 --destination-port-ranges 8080 \
  --description "Availability API from snet-web only" --output none

# Explicitly deny the rest of the internal traffic (it overrides AllowVnetInBound).
az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-app \
  --name denegar-resto-vnet --priority 4000 \
  --direction Inbound --access Deny --protocol '*' \
  --source-address-prefixes VirtualNetwork --destination-port-ranges '*' \
  --output none

az network vnet subnet update -g "${NET_GROUP}" --vnet-name vnet-contoso-pro \
  --name snet-app --network-security-group nsg-snet-app --output none

The denegar-resto-vnet rule with priority 4000 is the key piece of the segmentation: it is evaluated before the default 65000 rule that allowed all internal traffic, but after the priority 100 rule that authorizes the web layer. The result is exactly what we were after: snet-app only listens to snet-web.

Checking the effective rules, including the invisible ones:

az network nsg rule list -g "${NET_GROUP}" --nsg-name nsg-snet-app \
  --include-default \
  --query "sort_by([].{Priority:priority, Name:name, Dir:direction, Action:access, Source:sourceAddressPrefix, Port:destinationPortRange}, &Priority)" \
  --output table

  1. Service tags and application security groups

Writing IP ranges by hand ages badly: the IPs of Azure services change, and so do those of your machines. Azure offers two abstractions so that you do not depend on them.

Service tags

A service tag represents a set of IP prefixes that Microsoft keeps up to date for you.

Tag What it represents
Internet Everything outside your network
VirtualNetwork Your virtual network, peered networks and connected on-premises networks
AzureLoadBalancer Azure's load balancer (needed for the health probes)
Storage / Storage.WestEurope Azure Storage, globally or in one region
Sql / Sql.WestEurope Azure SQL Database
AzureCloud All the public Azure services
AzureMonitor The monitoring endpoints (module 7)
# Allow outbound traffic ONLY to Azure SQL in West Europe, without knowing a single IP.
az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-app \
  --name permitir-salida-sql --priority 200 \
  --direction Outbound --access Allow --protocol Tcp \
  --source-address-prefixes VirtualNetwork \
  --destination-address-prefixes Sql.WestEurope \
  --destination-port-ranges 1433 --output none

Application security groups (ASGs)

An ASG is a logical label that groups NICs. Instead of writing rules per IP range, you write rules between groups: "whatever is in asg-web can talk to whatever is in asg-api". When you add a machine to the group, it inherits the rules without touching the NSG.

# 1. Create the groups.
az network asg create -g "${NET_GROUP}" -n asg-web -l "${REGION}" --output none
az network asg create -g "${NET_GROUP}" -n asg-api -l "${REGION}" --output none

# 2. A rule between groups, without a single IP written down.
az network nsg rule create -g "${NET_GROUP}" --nsg-name nsg-snet-app \
  --name permitir-web-a-api --priority 90 \
  --direction Inbound --access Allow --protocol Tcp \
  --source-asgs asg-web --destination-asgs asg-api \
  --destination-port-ranges 8080 --output none

# 3. Associate a NIC with its group.
az network nic ip-config update \
  -g rg-contoso-reservas-pro --nic-name nic-api-01 --name ipconfig1 \
  --application-security-groups asg-api --output none

This is how you keep rules readable as the network grows: they read like business sentences, not like lists of octets.

  1. Public and private IPs, static and dynamic

Type Assignment Behavior When to use it
Private dynamic Azure picks one from the subnet Kept as long as the VM exists; it can change on deallocate and reallocate The default for normal VMs
Private static You choose it Always fixed Domain controllers, DNS servers, appliances
Public dynamic Azure assigns it at start-up It changes when the VM is deallocated Testing only
Public static Reserved for you Fixed; billed even while the VM is powered off Load balancers, gateways, anything in a DNS record
# A static private IP for the legacy engine's VM.
az network nic ip-config update \
  --resource-group rg-contoso-reservas-pro \
  --nic-name nic-motor-disponibilidad-01 \
  --name ipconfig1 \
  --private-ip-address 10.20.4.10 \
  --output none

About public IP SKUs: Standard is the current one (always static, closed by default — it needs an NSG that explicitly allows the traffic — and zone-compatible). The Basic SKU has been retired. Always use Standard.

One outbound nuance that confuses a lot of people: a VM without a public IP can still reach the internet through Azure's default outbound access, using an IP you do not control and which Microsoft is retiring for new networks. For predictable, auditable outbound traffic you use a NAT Gateway, which gives the whole subnet a fixed outbound IP and prevents SNAT port exhaustion.

  1. DNS in Azure and private DNS zones

Inside a VNet, Azure provides automatic DNS resolution: machines on the same network resolve each other by host name without you configuring anything, through the virtual server at 168.63.129.16 (an address that shows up in every Azure diagnostic and is worth recognizing).

That automatic resolution has limits: it does not work between peered networks and it does not resolve private service names. That is what private DNS zones are for.

# 1. Contoso's own private zone.
az network private-dns zone create \
  --resource-group rg-contoso-red-pro \
  --name interno.contosoairlines.example \
  --output none

# 2. Link the zone to the virtual network, with automatic VM registration.
az network private-dns link vnet create \
  --resource-group rg-contoso-red-pro \
  --zone-name interno.contosoairlines.example \
  --name enlace-vnet-contoso-pro \
  --virtual-network vnet-contoso-pro \
  --registration-enabled true \
  --output none

# 3. A manual record for the legacy engine.
az network private-dns record-set a add-record \
  --resource-group rg-contoso-red-pro \
  --zone-name interno.contosoairlines.example \
  --record-set-name motor \
  --ipv4-address 10.20.4.10 \
  --output none

Now any resource on the network resolves motor.interno.contosoairlines.example to 10.20.4.10. With --registration-enabled true, VMs also register themselves as they are created.

Private zones are essential for Private Link: they are the mechanism by which sql-contoso-reservas-pro.database.windows.net stops resolving to a public IP and starts resolving to an IP in your subnet. We look at that now.

Public DNS (registering contosoairlines.example and publishing its records to the world) is Azure DNS, and it is covered in lesson 02-06.

  1. Virtual network peering and hub-and-spoke

Peering connects two virtual networks so that they see each other as if they were one: private traffic over Microsoft's backbone, low latency, with no gateways and no internet in between.

Characteristics you need to know:

  • It works within a region and between regions (global peering).
  • It is not transitive: if A peers with B and B with C, A does not see C. This is the point that surprises people the most and the one that shapes the hub-and-spoke topology.
  • The address spaces cannot overlap.
  • It is billed by data transferred in both directions.
  • It has to be created in both directions: two commands, one per network.
NET_GROUP="rg-contoso-red-pro"

# From the hub to the production spoke.
az network vnet peering create \
  --resource-group "${NET_GROUP}" \
  --name peer-hub-a-pro \
  --vnet-name vnet-contoso-hub-pro \
  --remote-vnet vnet-contoso-pro \
  --allow-vnet-access \
  --allow-gateway-transit \
  --output none

# From the spoke to the hub (mandatory: peering is bidirectional by definition).
az network vnet peering create \
  --resource-group "${NET_GROUP}" \
  --name peer-pro-a-hub \
  --vnet-name vnet-contoso-pro \
  --remote-vnet vnet-contoso-hub-pro \
  --allow-vnet-access \
  --use-remote-gateways \
  --output none

The two important options: --allow-gateway-transit on the hub means "you may use my VPN gateway", and --use-remote-gateways on the spoke means "I will use it". Thanks to that pair, a single VPN gateway in the hub provides connectivity to every spoke, instead of paying for one per network. That is the pattern's main economic argument.

The hub-and-spoke pattern

graph TD
    OFI["Barcelona and Palma offices<br/>10.100.0.0/16 and 10.101.0.0/16"] -->|site-to-site VPN| GW["VPN gateway<br/>(lesson 02-06)"]
    GW --> HUB["vnet-contoso-hub-pro<br/>10.10.0.0/16<br/>Bastion, shared services"]
    HUB -->|peering| PRO["vnet-contoso-pro<br/>10.20.0.0/16<br/>production"]
    HUB -->|peering| DEV["vnet-contoso-dev<br/>10.30.0.0/16<br/>development"]
    PRO -.->|"no direct peering:<br/>production and development<br/>do NOT see each other"| DEV

Because peering is not transitive, production and development do not see each other even though both see the hub. That is not a limitation: it is exactly the isolation property we want, and it comes for free.

  1. Service endpoints versus Azure Private Link

Here is the most important decision of the lesson, and the one that solves the problem we opened with: Contoso's database and storage account are reachable from the internet.

Aspect Service endpoint Private endpoint (Private Link)
What it does Extends your subnet's identity towards the service, which keeps its public IP Creates a NIC with a private IP from your subnet for that service
Destination address The service's public IP (even though the traffic does not go out to the internet) A private IP from snet-datos
Is the service still exposed to the internet? Yes, only restricted by the service's firewall No, you can close public access completely
Access from the on-premises network over VPN Does not work Yes
Scope The whole subnet towards the whole service One specific resource (this account, this database)
DNS Unchanged Requires a private DNS zone to resolve to the private address
Cost Free Per hour and per data processed

The practical difference that settles it: with a service endpoint, somebody holding the storage account key can still get in from anywhere the firewall authorizes; and the offices connected by VPN cannot use it. With a private endpoint, the resource has an IP inside your network, it can be closed off from the world entirely and the offices reach it over the VPN.

Contoso chooses private endpoints for the sql-contoso-reservas-pro database and for the sttarjetascontosopro account. The added cost is small compared with exposing passengers' data.

NET_GROUP="rg-contoso-red-pro"
VNET="vnet-contoso-pro"

# 1. Turn off network policies on the data subnet (a Private Link requirement).
az network vnet subnet update -g "${NET_GROUP}" --vnet-name "${VNET}" \
  --name snet-datos --disable-private-endpoint-network-policies true --output none

# 2. Private endpoint towards the boarding pass storage account.
ACCOUNT_ID=$(az storage account show -g rg-contoso-reservas-pro \
  -n sttarjetascontosopro --query id -o tsv)

az network private-endpoint create \
  --resource-group "${NET_GROUP}" \
  --name pe-storage-tarjetas \
  --vnet-name "${VNET}" --subnet snet-datos \
  --private-connection-resource-id "${ACCOUNT_ID}" \
  --group-id blob \
  --connection-name conexion-blob-tarjetas \
  --output none

# 3. The service's private DNS zone: without this, the name still resolves to the public IP.
az network private-dns zone create -g "${NET_GROUP}" \
  --name "privatelink.blob.core.windows.net" --output none

az network private-dns link vnet create -g "${NET_GROUP}" \
  --zone-name "privatelink.blob.core.windows.net" \
  --name enlace-blob-vnet-pro --virtual-network "${VNET}" \
  --registration-enabled false --output none

# 4. Register the endpoint in the zone automatically.
az network private-endpoint dns-zone-group create \
  --resource-group "${NET_GROUP}" \
  --endpoint-name pe-storage-tarjetas \
  --name grupo-zonas-blob \
  --private-dns-zone "privatelink.blob.core.windows.net" \
  --zone-name blob --output none

# 5. Close the account's public access: now it is only reachable from the network.
az storage account update -g rg-contoso-reservas-pro -n sttarjetascontosopro \
  --public-network-access Disabled --output none

Step 3 is the one most people forget and the one that causes the classic failure: you create the private endpoint, close public access and the application stops working, because it still resolves the name to the public IP that is now closed. Without the linked private DNS zone, Private Link is worth nothing.

Verification from a VM on the network:

nslookup sttarjetascontosopro.blob.core.windows.net
# Expected: a CNAME to sttarjetascontosopro.privatelink.blob.core.windows.net
# and a 10.20.3.x (private) address, not a public IP.

The private zones for the services Contoso will use: privatelink.blob.core.windows.net (Storage), privatelink.database.windows.net (SQL, module 3), privatelink.vaultcore.azure.net (Key Vault, 04-03) and privatelink.azurewebsites.net (App Service).

  1. App Service integration with the virtual network

With App Service you have to distinguish two directions, and confusing them is a source of lost hours.

Need Mechanism What it does
Outbound: the application reaching private resources Virtual network integration Routes outbound traffic through a delegated subnet
Inbound: the application only being reachable from the network Private endpoint or access restrictions Takes the application off the internet
# 1. A dedicated subnet delegated to App Service (the delegation is mandatory
#    and the subnet cannot be shared with other resources).
az network vnet subnet create \
  -g rg-contoso-red-pro --vnet-name vnet-contoso-pro \
  --name snet-integracion-app --address-prefixes 10.20.5.0/24 \
  --delegations Microsoft.Web/serverFarms --output none

# 2. Outbound integration for the bookings website.
az webapp vnet-integration add \
  -g rg-contoso-reservas-pro -n app-contoso-reservas-pro \
  --vnet vnet-contoso-pro --subnet snet-integracion-app --output none

# 3. Restricted inbound access for the internal API: from snet-web only.
az webapp config access-restriction add \
  -g rg-contoso-reservas-pro -n app-contoso-api-disponibilidad-pro \
  --rule-name permitir-solo-web --priority 100 \
  --action Allow --vnet-name vnet-contoso-pro --subnet snet-web --output none

Practical details: outbound integration requires the Basic tier or above, the delegated subnet is for the exclusive use of that plan, and by default only traffic to private addresses is routed (to force all outbound traffic through the network you set WEBSITE_VNET_ROUTE_ALL=1, which is what Contoso does in production so that outbound traffic is auditable).

  1. Azure Bastion versus exposing SSH and RDP

In lesson 02-01 we opened port 22 to the internet with --nsg-rule SSH and warned that it was temporary. The time has come to do it properly.

Aspect SSH/RDP with a public IP Azure Bastion
Ports exposed to the internet 22 or 3389, attacked within minutes None
A public IP on each VM Required (and billed) None needed at all
Access An SSH or RDP client A browser (HTTPS) or a native client over a tunnel
Auditing The operating system's Sessions logged at the platform level
Cost Low Per hour, from the moment it exists
# Bastion needs AzureBastionSubnet (/26) and a Standard public IP.
az network public-ip create -g rg-contoso-red-pro -n ip-bastion-contoso-pro \
  --sku Standard --allocation-method Static --output none

az network bastion create \
  --resource-group rg-contoso-red-pro \
  --name bastion-contoso-pro \
  --vnet-name vnet-contoso-pro \
  --public-ip-address ip-bastion-contoso-pro \
  --location westeurope \
  --sku Standard \
  --output none

# SSH connection with no public IP on the VM and no port 22 open.
az network bastion ssh \
  --name bastion-contoso-pro \
  --resource-group rg-contoso-red-pro \
  --target-resource-id "$(az vm show -g rg-contoso-reservas-pro -n vm-motor-disponibilidad-01 --query id -o tsv)" \
  --auth-type ssh-key --username azureuser --ssh-key ~/.ssh/contoso_motor

Important cost warning: Bastion is billed by the hour as long as it exists, plus outbound traffic. In a lab, create it, use it and delete it the same day. In production, its cost compares favorably with managing public IPs and surviving an incident caused by exposed SSH.

A cheaper alternative for occasional administration: az ssh vm with Microsoft Entra ID and the corresponding extension, or a simple tunnel over a point-to-site VPN, which is exactly what Marta Ríos will use from home (lesson 02-06).

  1. Verification with Network Watcher

When something does not connect, guessing is expensive. Network Watcher tells you exactly which rule is blocking what.

# 1. IP flow verify: is this specific connection allowed or denied?
az network watcher test-ip-flow \
  --resource-group rg-contoso-reservas-pro \
  --vm vm-motor-disponibilidad-01 \
  --direction Inbound --protocol TCP \
  --local 10.20.4.10:22 --remote 10.20.1.5:60000 \
  --output json
# The response includes "access": "Allow"/"Deny" and the exact rule responsible.

# 2. Effective security rules on a NIC (the sum of the subnet's and the NIC's NSGs).
az network nic list-effective-nsg \
  --resource-group rg-contoso-reservas-pro \
  --name nic-motor-disponibilidad-01 \
  --output json

# 3. End-to-end connection troubleshooting.
az network watcher test-connectivity \
  --resource-group rg-contoso-reservas-pro \
  --source-resource vm-motor-disponibilidad-01 \
  --dest-address sttarjetascontosopro.privatelink.blob.core.windows.net \
  --dest-port 443 \
  --output table

test-ip-flow is the tool to use before touching any rule: it gives you the name of the rule that decides, so you stop changing things at random. And NSG flow logs, which send everything allowed and denied to Log Analytics, are the basis of the analysis you will see in lesson 07-02.

  1. The complete topology and cleanup

graph TB
    NET["Internet"] -->|443| WEB
    subgraph VNET["vnet-contoso-pro  ·  10.20.0.0/16"]
        WEB["snet-web · 10.20.1.0/24<br/>nsg-snet-web: inbound 443 only"]
        APP["snet-app · 10.20.2.0/24<br/>nsg-snet-app: from snet-web only"]
        DAT["snet-datos · 10.20.3.0/24<br/>private endpoints"]
        GES["snet-gestion · 10.20.4.0/24<br/>legacy engine, no public IP"]
        INT["snet-integracion-app · 10.20.5.0/24<br/>delegated to App Service"]
        BAS["AzureBastionSubnet · 10.20.250.0/26"]
        GWS["GatewaySubnet · 10.20.255.0/27<br/>(lesson 02-06)"]
    end
    WEB --> APP
    APP --> DAT
    INT --> DAT
    DAT -.->|Private Link| SQL["sql-contoso-reservas-pro"]
    DAT -.->|Private Link| ST["sttarjetascontosopro"]
    BAS --> GES
    HUB["vnet-contoso-hub-pro · 10.10.0.0/16"] <-->|peering| VNET

Cleaning up what bills:

# 1. Bastion: the most expensive thing in this lesson. Delete it as soon as you finish.
az network bastion delete -g rg-contoso-red-pro -n bastion-contoso-pro
az network public-ip delete -g rg-contoso-red-pro -n ip-bastion-contoso-pro

# 2. Private endpoints (billed by the hour).
az network private-endpoint delete -g rg-contoso-red-pro -n pe-storage-tarjetas

# 3. VNets, subnets and NSGs cost nothing: they can stay.
#    If you still want to delete everything in a lab:
# az group delete --name rg-contoso-red-pro --yes --no-wait

A residual-spend check: az network public-ip list --query "[?ipConfiguration==null]" -o table and az network private-endpoint list -o table.

Common Mistakes and Tips

  • Using 10.0.0.0/16 because it is the tutorial's example. It is the range half of corporate internet uses; the day you build the office VPN, it will overlap. Plan the addressing before creating anything.
  • Sizing subnets right to the limit. Azure reserves 5 addresses per subnet and growing one with resources inside is problematic. Leave headroom.
  • Forgetting the mandatory names. GatewaySubnet and AzureBastionSubnet are written exactly like that; with any other name, the service does not deploy.
  • Assuming that subnets are isolated by default. The AllowVnetInBound rule allows all internal traffic. Segmentation has to be written.
  • Getting the priority order wrong. Evaluation goes from lowest to highest and the first match wins. A Deny rule with priority 100 overrides an Allow with priority 200.
  • Writing IP ranges where a service tag would do. The IPs of Azure services change; the tags update themselves.
  • Expecting peering to be transitive. It is not. In hub-and-spoke, the spokes do not see each other (and normally that is what you want).
  • Creating the peering in one direction only. It stays in the Initiated state and does not work until the reverse link exists.
  • Creating a private endpoint without its private DNS zone. The name still resolves to the public IP and, once you close public access, the application stops working. It is Private Link's number one failure.
  • Leaving Azure Bastion running in a lab. It is billed by the hour even if you do not use it.
  • Debugging connectivity by trying rules out. az network watcher test-ip-flow gives you the guilty rule in one command.
  • Tip: document the addressing plan in a file in the repository, alongside the scripts. The network is the part of the architecture the most people need to consult and the fewest people remember.
  • Tip: apply NSGs to subnets, not to individual NICs, except in justified cases. Per-NIC rules get forgotten and create invisible holes.

Exercises

Exercise 1: planning the addressing

Contoso is opening a third office in Seville and also wants a virtual network for load testing, isolated from production.

  1. Assign address spaces to the Seville office and to the new vnet-contoso-pruebas network, consistent with the existing plan and with no overlaps.
  2. Divide vnet-contoso-pruebas into three /24 subnets with names consistent with the course's naming convention.
  3. How many usable addresses does each /24 subnet have? And a /27?
  4. Explain why you cannot use 10.20.0.0/16 for the test network.

Exercise 2: segmenting with NSGs

Write the NSG rules (with priorities) that implement exactly this policy for vnet-contoso-pro:

  1. snet-web accepts 443 from the internet and nothing else.
  2. snet-app accepts 8080 from snet-web only; no other internal traffic.
  3. snet-datos accepts 1433 from snet-app only.
  4. snet-gestion accepts nothing from the internet; administration from Bastion only.
  5. snet-app can go out to Azure SQL in West Europe, but not to the rest of the internet.

Exercise 3: making the database private

Describe, with the corresponding commands, the steps to make sql-contoso-reservas-pro unreachable from the internet and reachable only from snet-app:

  1. Prepare the subnet.
  2. Create the private endpoint.
  3. Configure DNS resolution.
  4. Close public access.
  5. Verify that resolution returns a private IP.

Explain as well what would happen if you skipped step 3.

Solutions

Solution 1:

  1. Addressing consistent with the existing plan:
Network Space Reason
Seville office 10.102.0.0/16 It follows the office series (Barcelona 10.100, Palma 10.101)
vnet-contoso-pruebas 10.40.0.0/16 It follows the Azure network series (hub 10.10, pro 10.20, dev 10.30)
  1. Subnets:
az network vnet create -g rg-contoso-red-pro -n vnet-contoso-pruebas \
  --address-prefixes 10.40.0.0/16 --location westeurope --output none

az network vnet subnet create -g rg-contoso-red-pro --vnet-name vnet-contoso-pruebas \
  -n snet-web --address-prefixes 10.40.1.0/24 --output none
az network vnet subnet create -g rg-contoso-red-pro --vnet-name vnet-contoso-pruebas \
  -n snet-app --address-prefixes 10.40.2.0/24 --output none
az network vnet subnet create -g rg-contoso-red-pro --vnet-name vnet-contoso-pruebas \
  -n snet-datos --address-prefixes 10.40.3.0/24 --output none
  1. A /24 has 256 addresses, minus the 5 Azure reserves: 251 usable. A /27 has 32, minus 5: 27 usable.

  2. Because 10.20.0.0/16 is already the space of vnet-contoso-pro. Two networks with overlapping ranges cannot be peered or connected by VPN, and the routing would be ambiguous. Even if you were not going to peer them today, reusing the range closes that door forever.

Solution 2:

G="rg-contoso-red-pro"

# 1. snet-web: 443 from the internet, nothing else (the denial comes from the default 65500 rule).
az network nsg rule create -g $G --nsg-name nsg-snet-web -n permitir-https \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes Internet --destination-port-ranges 443 --output none

# 2. snet-app: 8080 from snet-web only; the rest of the internal traffic, denied.
az network nsg rule create -g $G --nsg-name nsg-snet-app -n permitir-web \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes 10.20.1.0/24 --destination-port-ranges 8080 --output none
az network nsg rule create -g $G --nsg-name nsg-snet-app -n denegar-resto-vnet \
  --priority 4000 --direction Inbound --access Deny --protocol '*' \
  --source-address-prefixes VirtualNetwork --destination-port-ranges '*' --output none

# 3. snet-datos: 1433 from snet-app only.
az network nsg rule create -g $G --nsg-name nsg-snet-datos -n permitir-app-sql \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes 10.20.2.0/24 --destination-port-ranges 1433 --output none
az network nsg rule create -g $G --nsg-name nsg-snet-datos -n denegar-resto-vnet \
  --priority 4000 --direction Inbound --access Deny --protocol '*' \
  --source-address-prefixes VirtualNetwork --destination-port-ranges '*' --output none

# 4. snet-gestion: nothing from the internet; SSH and RDP from the Bastion subnet only.
az network nsg rule create -g $G --nsg-name nsg-snet-gestion -n permitir-bastion \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes 10.20.250.0/26 --destination-port-ranges 22 3389 --output none
az network nsg rule create -g $G --nsg-name nsg-snet-gestion -n denegar-internet \
  --priority 200 --direction Inbound --access Deny --protocol '*' \
  --source-address-prefixes Internet --destination-port-ranges '*' --output none

# 5. snet-app outbound: only Azure SQL in the region; the rest of the internet, denied.
az network nsg rule create -g $G --nsg-name nsg-snet-app -n permitir-salida-sql \
  --priority 200 --direction Outbound --access Allow --protocol Tcp \
  --destination-address-prefixes Sql.WestEurope --destination-port-ranges 1433 --output none
az network nsg rule create -g $G --nsg-name nsg-snet-app -n denegar-salida-internet \
  --priority 4000 --direction Outbound --access Deny --protocol '*' \
  --destination-address-prefixes Internet --destination-port-ranges '*' --output none

A detail on point 5: the outbound rule to SQL must have a lower priority number (200) than the general denial (4000), or the legitimate traffic would be blocked.

Solution 3:

G="rg-contoso-red-pro"; V="vnet-contoso-pro"

# 1. Prepare the subnet that will host the endpoint.
az network vnet subnet update -g $G --vnet-name $V -n snet-datos \
  --disable-private-endpoint-network-policies true --output none

# 2. Private endpoint towards the SQL server (group-id sqlServer).
SQL_ID=$(az sql server show -g rg-contoso-reservas-pro -n sql-contoso-reservas-pro --query id -o tsv)
az network private-endpoint create -g $G -n pe-sql-reservas \
  --vnet-name $V --subnet snet-datos \
  --private-connection-resource-id "${SQL_ID}" --group-id sqlServer \
  --connection-name conexion-sql-reservas --output none

# 3. The service's private DNS zone, linked to the network and tied to the endpoint.
az network private-dns zone create -g $G -n "privatelink.database.windows.net" --output none
az network private-dns link vnet create -g $G \
  --zone-name "privatelink.database.windows.net" -n enlace-sql-vnet-pro \
  --virtual-network $V --registration-enabled false --output none
az network private-endpoint dns-zone-group create -g $G \
  --endpoint-name pe-sql-reservas -n grupo-zonas-sql \
  --private-dns-zone "privatelink.database.windows.net" --zone-name sql --output none

# 4. Close the server's public access.
az sql server update -g rg-contoso-reservas-pro -n sql-contoso-reservas-pro \
  --enable-public-network false --output none

# 5. Verify from a VM on the network.
nslookup sql-contoso-reservas-pro.database.windows.net
# Expected: a CNAME to ...privatelink.database.windows.net and a 10.20.3.x IP

If you skip step 3, the private endpoint exists and has its IP, but the name sql-contoso-reservas-pro.database.windows.net still resolves to the public IP. As soon as you run step 4, the application will try to connect to that now-closed public IP and will fail with a connection timeout, with no clue at all pointing at DNS. It is Private Link's most frequent and most baffling failure.

Conclusion

You now have the platform's third leg. You know why the network comes first and which decisions get frozen for years. You can handle private address spaces, enough CIDR arithmetic to carve a /16 into subnets with no overlaps, and you know the five addresses Azure reserves in every subnet. You have designed and deployed vnet-contoso-pro with snet-web, snet-app, snet-datos, snet-gestion and the mandatorily named AzureBastionSubnet and GatewaySubnet subnets. You write NSG rules understanding priorities and the default rules — including the most dangerous one, AllowVnetInBound, which means segmentation is not automatic — and you use service tags and application security groups so that rules do not depend on IP lists. You can tell public from private IPs and static from dynamic, with their effect on the bill. You know how Azure's DNS resolution works and what private DNS zones are for. You understand peering and its lack of transitivity, which is exactly what shapes the hub-and-spoke pattern and lets you pay for a single VPN gateway. And you have made the decision that most protects passengers' data: private endpoints with their DNS zone for the database and the storage account, rather than service endpoints. On top of that, you have connected App Service to the network, you have replaced exposed SSH with Azure Bastion and you know how to diagnose with Network Watcher instead of guessing.

One piece is left to close the module, and it is the one that connects Azure with Contoso Airlines' real world. The Barcelona and Palma offices are still outside this network: their staff cannot reach the private resources you have just locked down, the legacy check-in system still lives on premises, and Marta Ríos cannot administer anything from home without exposing something. At the same time, a customer buying a ticket from South America suffers the latency of crossing the Atlantic on every request to West Europe.

In the module's last lesson, Hybrid Connectivity and Global Delivery, we solve both ends: a point-to-site VPN for Marta and a site-to-site one for the offices, with their gateways and their CLI deployment; ExpressRoute and when it justifies its price; Azure Virtual WAN as the evolution of hybrid hub-and-spoke; and global delivery of the site with Azure Front Door, Azure CDN and Traffic Manager, including the data transfer out charges that surprise everyone the first time they read a bill.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved