Deploy a Three-Tier App on Azure

Deploy a three-tier cloud architecture on Azure using Terraform IaC.

Introduction

30 Second Summary

Every secure web application separates its concerns into layers. The website visitors see, the logic processing their requests, and the database storing their data all live in isolated tiers so that a breach in one layer can't reach the others.

In this project, you will deploy a complete three-tier architecture on Azure using Terraform. You'll write Infrastructure as Code that provisions a virtual network, a Linux web server, and a private MySQL database across isolated subnets.

What You'll Build

A production-grade three-tier network architecture deployed entirely through Terraform configuration files, featuring a Linux VM serving a status page that proves connectivity to a private MySQL database.

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

  • 5 Terraform configuration files that define your entire infrastructure as code, deployable with a single command.
  • A Virtual Network with three isolated subnets protected by tier-specific Network Security Groups.
  • A working web server displaying a status page you can open in your browser, proving end-to-end database connectivity.
  • A private MySQL Flexible Server with zero public internet exposure, reachable only through the internal network.
  • Secret Mission: Add an app-tier NSG with precise source-based access control that only permits traffic from the web subnet on port 8080.

Are there any prerequisites?

None. This project includes full setup instructions for every tool you need, including Terraform, Azure CLI, and an Azure free account. You just need a Windows device with VS Code installed.

Set Up Your Tools

You have Terraform and Azure CLI installed, and your Azure free account is authenticated. Now it's time to verify the setup and write your first Terraform configuration.

Terraform needs a provider configuration to know which cloud platform to manage. You'll create a configuration file that targets Azure, then initialize the project to download the required plugins.

In this step, get ready to:
  • Confirm Terraform is working by running a version check.
  • Write the provider configuration in main.tf.
  • Initialize the Terraform project to download the Azure provider plugin.
Verify your Terraform installation
  • Open a terminal in VS Code by pressing Ctrl+`.
  • Confirm Terraform is accessible by running this command:
terraform version
  • You should see Terraform v1.15.6 (or a similar version starting with 1.15) along with your platform (windows_amd64).

Seeing 'command not found'?

This means Terraform is not on your system PATH. Make sure the folder containing terraform.exe is added to your PATH environment variable. Close and reopen VS Code after making PATH changes so the new terminal picks them up.

terraform version not working

Create the provider configuration

Terraform uses a provider block to know which cloud platform's API it should talk to. The azurerm provider gives Terraform the ability to create and manage Azure resources.

  • In the VS Code Explorer sidebar, right-click inside your project folder and select New File.
  • Name the file main.tf and press Enter.
  • Paste the following code into main.tf:
# Define the required Terraform version and provider
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  required_version = ">= 1.15.0"
}

# Configure the Azure provider
provider "azurerm" {
  features {}
  resource_provider_registrations = "legacy"
}

What does this code do?

  • required_providers tells Terraform to download the azurerm provider from the HashiCorp registry. The ~> 4.0 constraint accepts any 4.x version, giving you the latest patches automatically.
  • required_version ensures anyone running this code has Terraform 1.15.0 or higher installed.
  • features {} is a required (but empty) block for the Azure provider to function.
  • resource_provider_registrations = "legacy" tells Azure to auto-register any resource providers your configuration needs. This prevents errors on a fresh Azure account that hasn't manually enabled services yet.
  • Save main.tf by pressing Ctrl+S.

File saved in the wrong location?

Make sure main.tf appears at the top level of your project folder in the Explorer sidebar. If it ended up inside a subfolder, drag it to the root of your project folder.

My main.tf file is in the wrong place

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  required_version = ">= 1.15.0"
}

provider "azurerm" {
  features {}
  resource_provider_registrations = "legacy"
}
Initialize the project

Terraform needs to download the Azure provider plugin before it can create any resources. The terraform init command handles this automatically.

  • Download the Azure provider by running this command in your terminal:
terraform init
  • You should see output ending with Terraform has been successfully initialized! confirming the provider was downloaded.
  • Confirm the provider files were created by running:
ls .terraform
  • You should see a providers folder in the output. A .terraform.lock.hcl file also appears in your project folder, locking the provider version for consistency.

What did terraform init do?

It created a hidden .terraform directory and downloaded the azurerm provider plugin into it. It also generated .terraform.lock.hcl to pin the exact provider version. This ensures everyone working on this project uses the same version.

Seeing a provider error?

If the output shows an error about finding the provider, double check that your main.tf has source = "hashicorp/azurerm" spelled correctly. Also confirm you are running terraform init from the same folder where main.tf is saved.

Help me fix a terraform init error

Your environment is ready to build real infrastructure. Next up, you'll create the network layer that isolates your three-tier architecture into secure, separate subnets.

Build the Network Layer

In the previous step, you set up your tools and initialized Terraform. Now you have the foundation to start writing real infrastructure code.

A three-tier architecture separates your application into web, app, and database layers. Each layer lives in its own subnet with strict rules about what traffic can flow between them. In this step, you will define all the networking resources that enforce that isolation.

In this step, get ready to:
  • Create a Virtual Network with three isolated subnets.
  • Lock down each tier with Network Security Groups.
  • Deploy the network layer and verify it in the Azure Portal.
Create the VNet and subnets

Every Azure resource needs a home. A resource group is that logical container, and a Virtual Network (VNet) is the private network your resources communicate through.

  • In VS Code, create a new file called networking.tf in the same folder as your main.tf.
  • Add the resource group, VNet, and three subnets by pasting this code:
resource "azurerm_resource_group" "main" {
  name     = "three-tier-rg"
  location = "East US"
}

resource "azurerm_virtual_network" "main" {
  name                = "three-tier-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_subnet" "web" {
  name                 = "web-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.1.0/24"]
}

resource "azurerm_subnet" "app" {
  name                 = "app-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]
}

resource "azurerm_subnet" "database" {
  name                 = "database-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.3.0/24"]

  delegation {
    name = "mysql-delegation"
    service_delegation {
      name = "Microsoft.DBforMySQL/flexibleServers"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action",
      ]
    }
  }
}

What does this code do?

This code defines the network topology for your three-tier architecture.

  • The resource group (three-tier-rg) is the container that holds everything in this project.
  • The VNet (10.0.0.0/16) gives you 65,536 private IP addresses to work with.
  • Each subnet carves out a /24 range (256 addresses) for its tier: web at 10.0.1.0/24, app at 10.0.2.0/24, database at 10.0.3.0/24.
  • The delegation block on the database subnet tells Azure that a MySQL Flexible Server will live there. Azure requires this delegation for VNet-integrated database services.
  • Save networking.tf.
  • You will not see changes in Azure yet. The next substep adds security rules, and then you will deploy everything together.

Seeing red squiggles in VS Code?

If VS Code shows syntax warnings, make sure you have the HashiCorp Terraform extension installed. It provides syntax highlighting for .tf files.

Also check that every opening brace { has a matching closing brace }. Missing braces are the most common Terraform syntax error.

Help me fix syntax errors in my networking.tf file.

Add Network Security Groups

Subnets alone do not block traffic. Network Security Groups (NSGs) are the firewalls that control what flows in and out of each tier.

  • Add the following NSG definitions and subnet associations to the bottom of networking.tf, below the subnet blocks you just created:
resource "azurerm_network_security_group" "web" {
  name                = "web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "allow-http"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-ssh"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_network_security_group" "database" {
  name                = "database-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_subnet_network_security_group_association" "web" {
  subnet_id                 = azurerm_subnet.web.id
  network_security_group_id = azurerm_network_security_group.web.id
}

resource "azurerm_subnet_network_security_group_association" "database" {
  subnet_id                 = azurerm_subnet.database.id
  network_security_group_id = azurerm_network_security_group.database.id
}

What does this code do?

These NSGs define the security boundaries for each tier.

  • The web NSG allows inbound HTTP (port 80) so browsers can reach the web server. It also allows SSH (port 22) so you can connect for troubleshooting.
  • The database NSG has no rules at all. Azure NSGs deny all inbound traffic by default, so an empty NSG means nothing can reach the database from the internet.
  • The association resources link each NSG to its subnet. Without these, the NSGs exist but do not protect anything.

Why does the database NSG have no rules?

Azure NSGs follow an implicit deny-all-inbound policy. If you do not add an allow rule, all inbound traffic is blocked. This is exactly what you want for the database tier.

The MySQL server will only be reachable through private VNet routing from the web subnet. No public IP, no public access.

  • Save networking.tf.
  • The next substep deploys all these resources to Azure.

Not sure about the associations?

A common mistake is creating an NSG without associating it to a subnet. The association block (azurerm_subnet_network_security_group_association) is a separate resource that connects the two. If you skip it, your subnets have no firewall rules applied.

Help me understand how NSG associations work in Terraform.

✔️ Awesome, I've got everything!

Great. Double check you have saved networking.tf before moving on to deploy.

ⓧ I'd like to double check the full code

Here is the complete networking.tf file. Compare yours against it:

resource "azurerm_resource_group" "main" {
  name     = "three-tier-rg"
  location = "East US"
}

resource "azurerm_virtual_network" "main" {
  name                = "three-tier-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_subnet" "web" {
  name                 = "web-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.1.0/24"]
}

resource "azurerm_subnet" "app" {
  name                 = "app-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]
}

resource "azurerm_subnet" "database" {
  name                 = "database-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.3.0/24"]

  delegation {
    name = "mysql-delegation"
    service_delegation {
      name = "Microsoft.DBforMySQL/flexibleServers"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action",
      ]
    }
  }
}

resource "azurerm_network_security_group" "web" {
  name                = "web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "allow-http"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-ssh"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_network_security_group" "database" {
  name                = "database-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_subnet_network_security_group_association" "web" {
  subnet_id                 = azurerm_subnet.web.id
  network_security_group_id = azurerm_network_security_group.web.id
}

resource "azurerm_subnet_network_security_group_association" "database" {
  subnet_id                 = azurerm_subnet.database.id
  network_security_group_id = azurerm_network_security_group.database.id
}
Deploy and verify the network layer

Your code is complete. Time to let Terraform turn it into real Azure resources.

  • Preview what Terraform will create by running this command:
terraform plan

What does this command do?

The terraform plan command compares your .tf files to the current state of your Azure account. It shows what will be created, changed, or destroyed without actually doing anything. Think of it as a dry run.

You should see a summary saying it will create 8 resources (1 resource group, 1 VNet, 3 subnets, 2 NSGs, 2 associations).

  • Deploy the resources to Azure by running:
terraform apply
  • Terraform will show the plan again and ask for confirmation. Type yes and press Enter to proceed.
  • Wait for the deployment to finish. You should see a message saying Apply complete! Resources: 8 added, 0 changed, 0 destroyed. in your terminal.

What should I see?

Terraform will output each resource as it creates them. The whole process takes about 30-60 seconds. When it finishes, the final line confirms all 8 resources were created successfully.

  • Verify the resources exist in the Azure Portal. Open your browser and go to portal.azure.com.
  • In the search box at the top, type Virtual networks and select Virtual networks from the results.
  • Click three-tier-vnet to open it.
  • Select Subnets from the left navigation. You should see all three subnets listed: web-subnet, app-subnet, and database-subnet.

Don't see your resources in the portal?

Make sure you are signed into the same Azure account you used with az login. If you have multiple subscriptions, check that the portal is showing the correct one.

If terraform apply showed errors, check that your networking.tf file matches the complete code above. Common issues include missing commas after list items or mismatched brace counts.

Help me troubleshoot my terraform apply failure for the networking layer.

Your network foundation is in place. Next up, you will deploy a MySQL database that lives entirely inside the private database subnet.

Deploy the Database Tier

Your network layer is live with three isolated subnets and tier-specific Network Security Groups enforcing access rules. Now it's time to deploy the database that your web server will eventually connect to.

In this step, you'll provision a MySQL Flexible Server inside the database subnet with private-only access. The database will use Private DNS for name resolution within the VNet, meaning no public endpoint exists at all.

In this step, get ready to:
  • Create a database.tf configuration file with private DNS and MySQL resources.
  • Deploy the MySQL Flexible Server with private-only VNet access.
Create the database configuration file

Azure Database for MySQL Flexible Server requires a private DNS zone to resolve its hostname within the VNet. You'll set up the DNS zone first, link it to your VNet, and then define the MySQL server itself.

  • In VS Code, create a new file called database.tf in your project folder.
  • Add the private DNS zone and VNet link by pasting this code:
# Private DNS zone for MySQL name resolution within the VNet
resource "azurerm_private_dns_zone" "mysql" {
  name                = "threetier.mysql.database.azure.com"
  resource_group_name = azurerm_resource_group.main.name
}

# Link the DNS zone to our VNet so resources can resolve the MySQL hostname
resource "azurerm_private_dns_zone_virtual_network_link" "mysql" {
  name                  = "mysql-vnet-link"
  private_dns_zone_name = azurerm_private_dns_zone.mysql.name
  virtual_network_id    = azurerm_virtual_network.main.id
  resource_group_name   = azurerm_resource_group.main.name
}

What does this code do?

The private DNS zone creates a namespace ending in .mysql.database.azure.com that only resources inside your VNet can resolve.

The VNet link connects this DNS zone to three-tier-vnet so any VM in the VNet can look up the MySQL server by hostname without needing a public DNS record.

  • Below the VNet link resource, add the MySQL Flexible Server definition:
# MySQL Flexible Server with private-only VNet access
resource "azurerm_mysql_flexible_server" "main" {
  name                   = "threetier-mysql-db"
  resource_group_name    = azurerm_resource_group.main.name
  location               = azurerm_resource_group.main.location
  administrator_login    = "mysqladmin"
  administrator_password = "P@ssw0rd1234!"
  backup_retention_days  = 7
  delegated_subnet_id    = azurerm_subnet.database.id
  private_dns_zone_id    = azurerm_private_dns_zone.mysql.id
  sku_name               = "B_Standard_B1ms"

  # Ensures DNS zone is linked before server creation
  depends_on = [azurerm_private_dns_zone_virtual_network_link.mysql]
}

What does this code do?

The delegated_subnet_id places the server inside the database subnet, which already has the MySQL delegation from Step 2. The private_dns_zone_id tells Azure to register the server's hostname in your private DNS zone.

When both of these are set, Azure automatically disables public network access. The database becomes reachable only from inside the VNet.

The B_Standard_B1ms SKU is a burstable tier that keeps costs minimal while you learn. The depends_on block ensures Terraform creates the DNS link before attempting to create the server.

  • Save database.tf.

Seeing a red underline or error in VS Code?

Make sure the file is saved with the .tf extension. VS Code may not recognise Terraform syntax without a Terraform extension installed, but the code will still work when you run terraform apply.

Help me fix a syntax error in my database.tf file.

✔️ Awesome, I've got everything!

Great. Double check you've saved database.tf before moving on.

ⓧ I'd like to double check the full code

resource "azurerm_private_dns_zone" "mysql" {
  name                = "threetier.mysql.database.azure.com"
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "mysql" {
  name                  = "mysql-vnet-link"
  private_dns_zone_name = azurerm_private_dns_zone.mysql.name
  virtual_network_id    = azurerm_virtual_network.main.id
  resource_group_name   = azurerm_resource_group.main.name
}

resource "azurerm_mysql_flexible_server" "main" {
  name                   = "threetier-mysql-db"
  resource_group_name    = azurerm_resource_group.main.name
  location               = azurerm_resource_group.main.location
  administrator_login    = "mysqladmin"
  administrator_password = "P@ssw0rd1234!"
  backup_retention_days  = 7
  delegated_subnet_id    = azurerm_subnet.database.id
  private_dns_zone_id    = azurerm_private_dns_zone.mysql.id
  sku_name               = "B_Standard_B1ms"

  depends_on = [azurerm_private_dns_zone_virtual_network_link.mysql]
}
Deploy the database to Azure

With the configuration written, you can now deploy the MySQL server. This is the longest-running resource in the project, typically taking 5-7 minutes to provision.

  • Deploy the database resources by running this command in your terminal:
terraform apply
  • When prompted, type yes and press **Enter** to confirm.

Why does this take so long?

Azure is provisioning a fully managed MySQL database engine, configuring VNet integration, registering private DNS records, and setting up automated backups. This is real infrastructure being built in a data centre. 5-7 minutes is typical for database services.

  • Wait for Terraform to finish. You should see a message saying Apply complete! Resources: 3 added, 0 changed, 0 destroyed. in your terminal output.
  • Verify the server exists in the Azure Portal by searching for threetier-mysql-db in the top search bar.
  • On the server's **Overview** page, confirm the **Status** shows as available and that the **Server name** is listed.

Where is the public endpoint?

There isn't one. Because you configured delegated_subnet_id and private_dns_zone_id together, Azure automatically disabled public network access. The MySQL server is only reachable from resources inside three-tier-vnet.

Terraform apply failed?

If you see an error about the DNS zone or subnet delegation, make sure networking.tf still has the mysql-delegation block on the database subnet from Step 2.

If you see a naming conflict, the server name threetier-mysql-db must be globally unique across Azure. Try appending your initials to the name if you get a conflict.

Help me debug my terraform apply failure for the MySQL Flexible Server.

Your database tier is live and completely hidden from the public internet. Next up, you'll deploy a Linux VM in the web tier that connects to this database and serves a status page you can visit in your browser.

Deploy the Web Tier

Your database is live and locked behind a private network. Now it is time to deploy the web tier. This is the only layer that users can reach from the internet.

You will create a Linux Virtual Machine that runs a cloud-init startup script. This script installs Nginx and a MySQL client, tests the database connection, and writes the result to an HTML page.

In this step, get ready to:
  • Create compute.tf with a public IP, network interface, and Linux VM.
  • Create outputs.tf to surface the public IP address.
  • Verify the status page loads in your browser.
Create the compute configuration

The VM needs an SSH key pair so Azure can authenticate you as the admin user. The Terraform code references ~/.ssh/id_rsa.pub, so you need an RSA key at that path before running the deployment.

  • Check whether you already have an SSH key by running this command:
ls ~/.ssh/id_rsa.pub

✔️ I see the file listed

Your SSH key already exists. You are ready to write the compute configuration.

ⓧ No such file or directory

  • Generate an RSA key pair by running this command:
ssh-keygen -t rsa
  • Press Enter three times to accept the default file location and skip setting a passphrase.
  • Confirm the key was created by running:
ls ~/.ssh/id_rsa.pub

You should see the file path listed in the output.

Why SSH keys?

Azure Linux VMs use SSH key authentication instead of passwords. Terraform reads your public key file and injects it into the VM during creation so you can connect securely later.

Now you will build the compute resources. The first piece is the VM's network plumbing: a static public IP address and a network interface card that connects it to the web subnet.

  • In VS Code, create a new file called compute.tf in your project folder.
  • Add the public IP and network interface resources by pasting this code:
resource "azurerm_public_ip" "web" {
  name                = "web-public-ip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_network_interface" "web" {
  name                = "web-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.web.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.web.id
  }
}

What does this code do?

  • azurerm_public_ip creates a static public IP address with the Standard SKU. Static means the address will not change between VM reboots.
  • azurerm_network_interface creates a virtual NIC placed in the web subnet. It receives a dynamic private IP on the VNet and associates the public IP so the VM is internet-reachable.
  • Save compute.tf.

Next, add the Linux VM resource below the network interface. This is the core of the web tier.

  • Add the VM resource to compute.tf by pasting this code below the network interface block:
resource "azurerm_linux_virtual_machine" "web" {
  name                = "web-vm"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  size                = "Standard_B1s"
  admin_username      = "azureuser"

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  network_interface_ids = [
    azurerm_network_interface.web.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }

  custom_data = base64encode(<<-EOF
    #!/bin/bash
    apt-get update
    apt-get install -y nginx mysql-client
    MYSQL_HOST="${azurerm_mysql_flexible_server.main.fqdn}"
    MYSQL_USER="mysqladmin"
    MYSQL_PASS="P@ssw0rd1234!"

    if mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -p"$MYSQL_PASS" -e "SELECT 1;" 2>/dev/null; then
      STATUS="SUCCESS"
      COLOR="green"
    else
      STATUS="FAILED"
      COLOR="red"
    fi

    cat > /var/www/html/index.html <<HTML
    <!DOCTYPE html>
    <html>
    <head><title>Three-Tier Architecture Status</title></head>
    <body style="font-family: sans-serif; padding: 40px; text-align: center;">
      <h1>Three-Tier Architecture</h1>
      <h2>Database Connection: <span style="color: $COLOR;">$STATUS</span></h2>
      <p>Server FQDN: $MYSQL_HOST</p>
      <p>Network Path: Web Subnet (10.0.1.0/24) -> Database Subnet (10.0.3.0/24)</p>
      <p>Public Access: Disabled (Private VNet only)</p>
      <hr>
      <p><em>Deployed with Terraform</em></p>
    </body>
    </html>
HTML

    systemctl restart nginx
  EOF
  )
}

What does this code do?

This resource creates a Linux VM with a cloud-init startup script:

  • Standard_B1s is a burstable VM size that costs approximately $0.01/hour from your free credit.
  • source_image_reference selects Ubuntu 22.04 LTS as the operating system.
  • custom_data passes a Base64-encoded cloud-init script that runs on first boot. The script installs Nginx and a MySQL client, attempts to connect to your private database, and writes the connection result to an HTML status page.
  • The MySQL connection uses the Terraform interpolation ${azurerm_mysql_flexible_server.main.fqdn} to inject the database's private FQDN at deploy time.
  • Save compute.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved compute.tf before moving on.

ⓧ I'd like to double check the full code

resource "azurerm_public_ip" "web" {
  name                = "web-public-ip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_network_interface" "web" {
  name                = "web-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.web.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.web.id
  }
}

resource "azurerm_linux_virtual_machine" "web" {
  name                = "web-vm"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  size                = "Standard_B1s"
  admin_username      = "azureuser"

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  network_interface_ids = [
    azurerm_network_interface.web.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }

  custom_data = base64encode(<<-EOF
    #!/bin/bash
    apt-get update
    apt-get install -y nginx mysql-client
    MYSQL_HOST="${azurerm_mysql_flexible_server.main.fqdn}"
    MYSQL_USER="mysqladmin"
    MYSQL_PASS="P@ssw0rd1234!"

    if mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -p"$MYSQL_PASS" -e "SELECT 1;" 2>/dev/null; then
      STATUS="SUCCESS"
      COLOR="green"
    else
      STATUS="FAILED"
      COLOR="red"
    fi

    cat > /var/www/html/index.html <<HTML
    <!DOCTYPE html>
    <html>
    <head><title>Three-Tier Architecture Status</title></head>
    <body style="font-family: sans-serif; padding: 40px; text-align: center;">
      <h1>Three-Tier Architecture</h1>
      <h2>Database Connection: <span style="color: $COLOR;">$STATUS</span></h2>
      <p>Server FQDN: $MYSQL_HOST</p>
      <p>Network Path: Web Subnet (10.0.1.0/24) -> Database Subnet (10.0.3.0/24)</p>
      <p>Public Access: Disabled (Private VNet only)</p>
      <hr>
      <p><em>Deployed with Terraform</em></p>
    </body>
    </html>
HTML

    systemctl restart nginx
  EOF
  )
}
Create outputs and deploy

Terraform outputs let you extract useful values after deployment. You will define outputs for the public IP and database FQDN so you can easily access your status page.

  • Create a new file called outputs.tf in your project folder.
  • Add the output definitions by pasting this code:
output "web_public_ip" {
  description = "Public IP address of the web server"
  value       = azurerm_public_ip.web.ip_address
}

output "mysql_server_fqdn" {
  description = "FQDN of the MySQL Flexible Server (private only)"
  value       = azurerm_mysql_flexible_server.main.fqdn
}

output "web_url" {
  description = "URL to access the web status page"
  value       = "http://${azurerm_public_ip.web.ip_address}"
}

What does this code do?

  • web_public_ip prints the VM's public IP address after deployment.
  • mysql_server_fqdn prints the database's private FQDN, confirming it has no public endpoint.
  • web_url constructs the full HTTP URL you will open in your browser.
  • Save outputs.tf.
  • Deploy the VM and outputs by running this command in your terminal:
terraform apply
  • Terraform will show you a plan with 3 new resources to create. Type yes and press Enter to confirm.

How long does this take?

The VM creation typically takes 1-2 minutes. Terraform shows progress messages with elapsed time so you know it is working.

Once complete, you will see the outputs printed at the bottom of your terminal, including your web_url.

  • After the apply finishes, note the web_url value in the outputs. You will use it in the next substep.

Seeing an error about the SSH key?

  • If you see an error mentioning ~/.ssh/id_rsa.pub not found, go back to the SSH key check above and generate a key with ssh-keygen -t rsa.
  • If you see a quota or capacity error, try a different region by changing "East US" to "West US 2" in networking.tf and running terraform apply again.

Help me troubleshoot my Terraform apply error.

Verify the status page

The VM is now running, but the cloud-init script needs a couple of minutes to install packages and test the database connection.

  • Wait 2-3 minutes for cloud-init to finish running on the VM.
  • Copy the web_url value from your Terraform output and paste it into your browser address bar.

You should see a page titled "Three-Tier Architecture" showing the database connection status, the server FQDN, and the network path between subnets.

✔️ I see Database Connection: SUCCESS in green

Your three-tier architecture is fully working. The web VM connected to the MySQL database through the private VNet, proving your network isolation is correct.

ⓧ I see Database Connection: FAILED in red

The VM deployed and cloud-init ran, but the database connection test failed. This usually means the MySQL server has not fully completed startup yet.

  • Wait another 2-3 minutes and refresh your browser.
  • If it still shows FAILED after 5 minutes total, the MySQL server may still be starting up. Give it up to 10 minutes from when terraform apply completed, then refresh again.

ⓧ The page will not load at all

  • Confirm you are using http:// (not https://) in your browser URL. Nginx is serving on port 80 only.
  • Cloud-init may still be installing Nginx. Wait another minute and try again.
  • Verify the web NSG allows port 80 by checking that your networking.tf has the allow-http rule with destination_port_range set to "80".

What is cloud-init doing?

When the VM first boots, cloud-init runs the script you passed via custom_data. It installs Nginx and the MySQL client, connects to the private MySQL server using its FQDN, and writes the connection result to /var/www/html/index.html.

Because the MySQL server is VNet-integrated with private DNS, the web VM resolves the FQDN through the private DNS zone you linked in the previous step. No traffic ever touches the public internet.

Your three-tier architecture is live and verified. In the Secret Mission, you will tighten security even further by adding a dedicated app-tier NSG.

Secret mission

Add the App Tier NSG

Your three-tier architecture has web and database security groups, but the app subnet is wide open. Can you add a Network Security Group that locks down the app tier so only the web subnet can reach it on port 8080?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your Azure resources consume approximately $0.03/hour total from your $200 free credit while running.

Cost warning

Your Linux VM, MySQL Flexible Server, and Public IP collectively cost about $0.03/hour from your Azure free credit. If you are not continuing to experiment, run terraform destroy now to reclaim that credit for future projects.

The Azure free account spending limit prevents charges beyond your $200 credit. If credit runs out, resources are automatically stopped.

Resources you used:

  • Azure Resource Group (three-tier-rg) containing all cloud resources.
  • Linux VM Standard_B1s (costs ~$0.01/hour from $200 credit).
  • MySQL Flexible Server B_Standard_B1ms (costs ~$0.02/hour from $200 credit).
  • Public IP address (costs ~$0.004/hour from $200 credit).
  • Virtual Network, Subnets, and Network Security Groups (free).
  • Private DNS Zone and VNet link (free).
  • Local project folder with Terraform configuration files.

Keep everything running

No action needed. Choose this if you are still actively experimenting with your three-tier architecture or plan to add more resources.

  • Your resources will continue consuming approximately $0.03/hour from your $200 free credit.
  • Terraform CLI and Azure CLI remain installed on your system for future projects.
  • Your Azure free account stays active for 30 days from signup.

Pause - I'll come back to this later

Shut down running resources to conserve credit while keeping your Terraform files intact for redeployment later.

  • Run terraform destroy in your project folder and type yes when prompted. This removes all Azure resources but keeps your local .tf files.
  • When you are ready to resume, run terraform apply to recreate everything from your saved configuration.
  • Terraform CLI and Azure CLI remain installed on your system.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your Azure infrastructure and local project files.

  • In your VS Code terminal, run the following command and type yes when prompted:
terraform destroy

This removes the Resource Group, VM, MySQL Server, VNet, Subnets, NSGs, Public IP, and Private DNS Zone from your Azure account.

  • Delete your local project folder. On Windows, open PowerShell and run:
cd ~\Desktop
Remove-Item -Recurse -Force .\three-tier-terraform
  • On macOS or Linux, run:
cd ~/Desktop
rm -rf three-tier-terraform
  • To uninstall Azure CLI on Windows, run:
winget uninstall --id Microsoft.AzureCLI
  • To remove Terraform, delete the folder where you extracted the terraform.exe binary and remove that folder from your system PATH.
  • Your Azure free account can remain open at no cost. The spending limit ensures you are never charged beyond your credit.

That's a Wrap!

That's a Wrap!

Nice work! You just deployed a production-grade three-tier cloud architecture on Azure using nothing but Terraform code.

You've learned how to:

  • Write Terraform configuration across multiple organized files (main.tf, networking.tf, compute.tf, database.tf, outputs.tf) following real-world Infrastructure as Code patterns.
  • Deploy a Virtual Network with three isolated subnets protected by tier-specific Network Security Groups that enforce security boundaries between web, app, and database tiers.
  • Provision a MySQL Flexible Server with private-only VNet access and a Linux VM with cloud-init automation that proves end-to-end connectivity through a browser-accessible status page.
  • Secret Mission: Implement a fully isolated app-tier NSG with precise source-based access control, allowing only port 8080 traffic from the web subnet.

Ready to quiz yourself?