Skip to main content

Command Palette

Search for a command to run...

Building a Secure Three-Tier Web App on Azure with Terraform: Lessons Learned

Published
3 min read

Introduction

As part of the FREE DevOps Micro Internship (DMI) Cohort by Pravin Mishra, I deployed a secure, highly available three-tier web application on Microsoft Azure using Terraform. The architecture included a public-facing NGINX web server, an application layer, and a database backend, all isolated in dedicated subnets. While the design followed Azure best practices, several networking and configuration challenges emerged during deployment. This article outlines the implementation process, key obstacles, and how they were resolved.

Architecture Overview

The assignment included:

  • Azure Virtual Network (VNet) with separate subnets for web, app, database, and Bastion

  • Standard SKU Load Balancer for public HTTP (port 80) traffic

  • Azure Bastion for secure, browser-based SSH access (no public IPs on VMs)

  • Network Security Groups (NSGs) with least-privilege rules

  • NAT Gateway for secure outbound internet access

  • Terraform for Infrastructure-as-Code (IaC)

All resources were defined in a single Terraform configuration using the azurerm provider.

Key Challenges & Solutions

Challenge 1: VM Unreachable on Port 80

Symptom: The Load Balancer showed backend instances as unhealthy, and the public IP returned no response.

Root Cause:
The Standard Load Balancer requires an explicit NSG rule allowing traffic from the AzureLoadBalancer service tag for health probes. Without it, probes are blocked, even if port 80 is open to the internet.

Fix:

security_rule {
  name                       = "allow_azure_lb"
  source_address_prefix      = "AzureLoadBalancer"
  # ... other settings
}

Challenge 2: VM Had No Internet Access

Symptom: The setup.sh script failed silently; NGINX was never installed, hence the load balancer IP address was unreachable.

Root Cause:
VMs behind a Standard Load Balancer have no outbound internet access by default. Additionally, disable_outbound_snat = true in the LB rule explicitly blocked SNAT.

Fix:
Deployed a NAT Gateway associated with the web subnet; Microsoft’s recommended method for secure, scalable outbound connectivity:

resource "azurerm_nat_gateway" "web_nat" { ... }
resource "azurerm_subnet_nat_gateway_association" "web_nat_assoc" { ... }

This allowed the VM to download packages and run apt-get install nginx successfully.

Challenge 3: Invalid NSG Rule for Bastion Access

Symptom: Terraform apply failed with SecurityRuleInvalidAddressPrefix.

Root Cause:
I attempted to use source_address_prefix = "AzureBastion", but AzureBastion is not a valid service tag for NSG rules.

Fix:
Allowed SSH only from the Bastion subnet CIDR:

source_address_prefix = azurerm_subnet.bastion_subnet.address_prefixes[0]

This is both secure and compliant with Azure’s networking model.

Challenge 4: custom_data Only Runs Once

Symptom: After fixing networking, the app still wouldn’t install.

Root Cause:
Terraform’s custom_data (used to run setup.sh) executes only on first VM boot. Subsequent terraform apply runs don’t re-execute it.

Resolution:
For production fidelity, I forced VM recreation by updating the OS disk name. In development, I manually ran the script via Bastion to validate fixes quickly.

Final Outcome

After implementing the fixes:

  • The public Load Balancer IP now serves the NGINX welcome page

  • Health probes report "Healthy"

  • SSH access is restricted to Azure Bastion only

  • Outbound traffic flows securely via NAT Gateway

  • No public IPs are assigned directly to VMs

The infrastructure is now secure, repeatable, and fully managed via Terraform.

Lessons Learned

  1. Standard Load Balancer ≠ Basic: It’s secure-by-default, plan for explicit outbound rules.

  2. Service tags matter: Not all Azure services have NSG tags (e.g., no AzureBastion).

  3. Test custom_data early: Use cloud-init-output.log to debug provisioning.

  4. NAT Gateway > LB SNAT: For outbound, NAT Gateway is more reliable and scalable.

  5. Least privilege works: Tight NSG rules + Bastion = strong security posture.

Conclusion

Deploying a secure multi-tier application on Azure with Terraform is achievable, but requires deep understanding of Azure’s networking model. By addressing Load Balancer behavior, NSG rules, and outbound connectivity systematically, I built a resilient and secure foundation ready for production workloads.

The full Terraform code is available here for reuse across projects.

Resources

https://github.com/omiete01/three_tier_azure

https://www.udemy.com/course/devops-for-beginners-docker-k8s-cloud-cicd-4-projects/learn/practice/1655971#overview

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources

https://www.youtube.com/watch?v=qJD5UCdtjg4&list=PLVOdqXbCs7bX88JeUZmK4fKTq2hJ5VS89