DEV Community

Cover image for Building a Multi-Cloud Infrastructure with Terraform (AWS + Azure)
Isaiah Izibili
Isaiah Izibili

Posted on

Building a Multi-Cloud Infrastructure with Terraform (AWS + Azure)

Introduction

In modern cloud engineering, organizations increasingly adopt multi-cloud strategies to improve resilience, avoid vendor lock-in, and optimize cost.

In this project, I designed and deployed infrastructure across:

  • Amazon Web Services (AWS)
  • Microsoft Azure

Using a single Infrastructure as Code (IaC) tool:

  • πŸ›  Terraform

This article explains the architecture, configuration, deployment steps, and lessons learned.

Project Architecture Overview

AWS Resources

  • EC2 Instance (Ubuntu Server)
  • RDS MySQL Database

Azure Resources

  • Resource Group
  • Virtual Network + Subnet
  • Windows Virtual Machine
  • IIS Web Server
  • Public IP + NSG

All provisioned using Terraform providers for both clouds.

Step 1: Install Prerequisites

Before starting, install:

  • Terraform
  • AWS CLI
  • Azure CLI
  • Git
  • VS Code

Install Terraform
HashiCorp distributes Terraform as an executable CLI that you can install on supported operating systems, including Microsoft Windows, macOS, and several Linux distributions. You can also compile the Terraform CLI from source if a pre-compiled binary is not available for your system.

If you use a package manager to install software on your macOS, Windows, or Linux system, you can use it to install Terraform. For this project, the focus will be on the Windows installation.

Chocolatey https://chocolatey.org/is a free and open-source package management system for Windows. If you have Chocolatey installed, use it to install Terraform from your command line.

choco install terraform
Enter fullscreen mode Exit fullscreen mode

NOTE: HashiCorp does not maintain Chocolatey or the Terraform package. The latest version of Terraform is always available for you to download and install manually. Refer to the Manual installation tab below for instructions.

Verify the Installation
Verify that the installation worked by opening a new terminal session and listing Terraform's available subcommands.

terraform -help
Enter fullscreen mode Exit fullscreen mode

Install AWS CLI

  1. For Windows:
  2. Download the installer from AWS CLI official page (docs.aws.amazon.com in Bing).
  3. Run the .msi installer and follow the prompts.

Verify installation with:

aws --version
Enter fullscreen mode Exit fullscreen mode

aws version

Configure AWS CLI

Once installed, configure it with your credentials:

aws configure
Enter fullscreen mode Exit fullscreen mode

It will ask for:

Install Azure CLI

For Windows:

  • Download the installer from Microsoft’s official page.
  • Run the .msi installer and follow the prompts.
  • Verify installation:
az --version
Enter fullscreen mode Exit fullscreen mode

AZ version

Step 2: Authenticate with Cloud Providers

AWS Login

aws configure
Enter fullscreen mode Exit fullscreen mode

Provide:

  • AWS Access Key ID
  • AWS Secret Access Key
  • Default region name (e.g., us-east-1)
  • Default output format (e.g., json)

AWs configure

NOTE: Your AWS Access key and ID must have been created in your AWS console. For security purpose we advise creating a USER and GROUP under IAM resources.

Azure Login

az login
Enter fullscreen mode Exit fullscreen mode
  • A browser window will open for you to authenticate with your Azure account.
  • Once logged in, the terminal will display your subscription details. Verify subscription:
az account show
Enter fullscreen mode Exit fullscreen mode

AZ account show

Step 3: Define Terraform Providers

Create terraform.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }

    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

provider "azurerm" {
  features {}
}
Enter fullscreen mode Exit fullscreen mode

Note: terraform.tf is usually used for Terraform settings or backend configuration.
What goes inside: Terraform version, Required providers, Backend configuration

Step 4: Create AWS and Azure Infrastructure

Create main.tf (Complete Working Script):

AWS Resources: EC2 Instance, AWS RDS MySQL Database,

Azure Resources: Resource Group, Virtual Machine, Web Application

############################################
# Get Latest Ubuntu 22.04 AMI
############################################
data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}

############################################
# Create EC2 Instance
############################################
resource "aws_instance" "isaiah" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  tags = {
    Name = "isaiah"
  }
}
#Get default VPC
data "aws_vpc" "default" {
  default = true
}

#GET subnets in default VPC
data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

#Create Security Group for RDS
resource "aws_security_group" "rds_sg" {
  name   = "isaiah-rds-sg"
  vpc_id = data.aws_vpc.default.id

  ingress {
    from_port   = 3306
    to_port     = 3306
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # ⚠️ for testing only
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

#Create RDS Module
module "db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "~> 6.0"

  identifier = "isaiah-demodb"

  engine               = "mysql"
  engine_version       = "8.0"
  instance_class       = "db.t3.micro"   # cheaper for testing
  allocated_storage    = 20

  db_name  = "demodb"
  username = "isaiah"
  password = "IsaiahStrongPassword123!"   # change this
  port     = 3306

  publicly_accessible = true

  vpc_security_group_ids = [aws_security_group.rds_sg.id]

  create_db_subnet_group = true
  subnet_ids             = data.aws_subnets.default.ids

  family               = "mysql8.0"
  major_engine_version = "8.0"

  skip_final_snapshot = true
  deletion_protection = false

  tags = {
    Owner       = "Isaiah"
    Environment = "dev"
  }
}

#add random suffix
resource "random_string" "suffix" {
  length  = 5
  special = false
  upper   = false
}

#Resource group for Azure
resource "azurerm_resource_group" "isaiah_rg" {
  name     = "isaiah-webapp-rg"
  location = "West us 3"
}

#Add service plan
resource "azurerm_service_plan" "isaiah_plan" {
  name                = "isaiah-service-plan"
  resource_group_name = azurerm_resource_group.isaiah_rg.name
  location            = azurerm_resource_group.isaiah_rg.location
  sku_name            = "P1v2"
  os_type             = "Windows"
}

#Customize windows web app
resource "azurerm_windows_web_app" "isaiah_app" {
  name                = "isaiah-app-${random_string.suffix.result}"
  resource_group_name = azurerm_resource_group.isaiah_rg.name
  location            = azurerm_resource_group.isaiah_rg.location
  service_plan_id     = azurerm_service_plan.isaiah_plan.id

  site_config {
    always_on = true
  }

  app_settings = {
    "ENVIRONMENT" = "Production"
    "OWNER"       = "Isaiah"
    "WEBSITE_RUN_FROM_PACKAGE" = "1"
  }

  connection_string {
    name  = "Database"
    type  = "MySql"
    value = "Server=myserver.mysql.database.azure.com;Database=mydb;User Id=isaiah;Password=StrongPassword123;"
  }

  tags = {
    Owner       = "Isaiah"
    Environment = "Dev"
  }
}

# Create Resource Group
resource "azurerm_resource_group" "rg" {
  location = var.resource_group_location
  name     = "${var.prefix}-rg"
}

# Virtual Network
resource "azurerm_virtual_network" "vnet" {
  name                = "${var.prefix}-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

# Subnet
resource "azurerm_subnet" "subnet" {
  name                 = "${var.prefix}-subnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.1.0/24"]
}

# Public IP
resource "azurerm_public_ip" "public_ip" {
  name                = "${var.prefix}-public-ip"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network Security Group
resource "azurerm_network_security_group" "nsg" {
  name                = "${var.prefix}-nsg"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  # RDP (You should replace with your public IP)
  security_rule {
    name                       = "Allow-RDP"
    priority                   = 1000
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "3389"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  # HTTP
  security_rule {
    name                       = "Allow-HTTP"
    priority                   = 1001
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

# Network Interface
resource "azurerm_network_interface" "nic" {
  name                = "${var.prefix}-nic"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

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

# Associate NSG
resource "azurerm_network_interface_security_group_association" "nsg_association" {
  network_interface_id      = azurerm_network_interface.nic.id
  network_security_group_id = azurerm_network_security_group.nsg.id
}

# Storage Account for Boot Diagnostics
resource "azurerm_storage_account" "diag" {
  name                     = "diag${random_id.rand.hex}"
  location                 = azurerm_resource_group.rg.location
  resource_group_name      = azurerm_resource_group.rg.name
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

# Windows Virtual Machine
resource "azurerm_windows_virtual_machine" "vm" {
  name                  = "${var.prefix}-vm"
  location              = azurerm_resource_group.rg.location
  resource_group_name   = azurerm_resource_group.rg.name
  network_interface_ids = [azurerm_network_interface.nic.id]
  size                  = "Standard_B1s"  # cheaper for lab

  admin_username = "isaiahadmin"
  admin_password = random_password.password.result

  os_disk {
    name                 = "${var.prefix}-osdisk"
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-datacenter-azure-edition"
    version   = "latest"
  }

  boot_diagnostics {
    storage_account_uri = azurerm_storage_account.diag.primary_blob_endpoint
  }
}

# Install IIS
resource "azurerm_virtual_machine_extension" "iis_install" {
  name                 = "iis-install"
  virtual_machine_id   = azurerm_windows_virtual_machine.vm.id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.8"

  settings = <<SETTINGS
  {
    "commandToExecute": "powershell Install-WindowsFeature -Name Web-Server -IncludeManagementTools"
  }
  SETTINGS
}

# Random ID
resource "random_id" "rand" {
  byte_length = 4
}

# Random Password
resource "random_password" "password" {
  length  = 16
  special = true
}
Enter fullscreen mode Exit fullscreen mode

NOTE: main.tf is the main infrastructure definition file.
It contains the actual resources Terraform will create.
Think of it as the core blueprint of your infrastructure.

Step 5: Create Variable.tf file

The main reason for creating variables.tf is for Azure VM. Before deploying a VM in main.tf in Terraform is important because it makes your infrastructure flexible, reusable, organized, and easier to maintain. Below is a clear explanation.

If you create a VM directly in main.tf without variables, you will hard-code values like region, VM name, or size.

variable "prefix" {
  description = "Prefix for all resources"
  default     = "isaiah"
}

variable "resource_group_location" {
  description = "Azure region"
  default     = "west US 3"
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Initialize and Deploy

1. Initialize Terraform:

terraform init
Enter fullscreen mode Exit fullscreen mode

Purpose
Initializes the Terraform working directory.

Why is it important
This command prepares your project so Terraform can run properly.

_What it does

  • Downloads providers (e.g., Azure, AWS, Google Cloud)
  • Downloads modules
  • Creates the .terraform folder
  • Sets up the backend if configured

Running terraform init downloads the AWS and Azure provider plugin. It prepares the environment before Terraform can run.

terraform

2. Validate Terraform:

terraform validate
Enter fullscreen mode Exit fullscreen mode

Purpose
Checks if the Terraform configuration syntax is correct.

Why it is important
It helps detect errors in your code before deployment.

What it checks

  • Syntax errors
  • Missing brackets
  • Incorrect variable references
  • Invalid configuration

It checks if your code is written correctly.

3. terraform plan

Purpose
Shows what Terraform will create, change, or delete before deployment.

Why it is important
It allows you to preview the changes before applying them.

What it shows

  • Resources to be created
  • Resources to be modified
  • Resources to be destroyed

terraform plan

terraform plan1

Plan: 3 to add, 0 to change, 0 to destroy.
Enter fullscreen mode Exit fullscreen mode

This means Terraform will create 12 new resources.

Simple explanation

πŸ‘‰ It shows the execution plan before making changes.

4. terraform apply

Purpose
Executes the plan and creates the infrastructure.

What it does

  • Deploys resources to the cloud
  • Creates VMs
  • Creates networks
  • Creates storage
  • Updates infrastructure

Terraform apply

Terraform Apply1

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Enter fullscreen mode Exit fullscreen mode

Simple explanation
πŸ‘‰ It actually builds the infrastructure.

AWS resources

Azure resources

Azure1

Tree

The main purpose of tree is to visualize how files and folders are organized inside a directory.

Instead of listing files in a simple list like ls, tree shows the relationship between folders and subfolders.

tree

Step 6 β€” Add Files to Git

git init
git add .
git commit -m "Initial commit: multi-cloud Terraform project"
git remote add origin https://github.com/izibili123/multi-cloud-terraform.git
git branch -M main
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

git1

Step 7 Terraform destroy

In Terraform, the command terraform destroy is used to delete all the infrastructure resources that Terraform created.

This is done to Prevent Unnecessary Cloud Costs

Cloud resources keep running and continue charging money if not deleted.

Using terraform destroy helps you:

  • stop billing
  • avoid unexpected cloud charges
  • clean up test environments

This is very important when working with cloud providers

terraform destroy
Enter fullscreen mode Exit fullscreen mode

terraform destroy

terraform2

Plan: 0 to add, 0 to change, 22 to destroy
Enter fullscreen mode Exit fullscreen mode

Then you must confirm:

Do you really want to destroy all resources?
  Terraform will destroy all managed infrastructure.

Enter a value: yes
Enter fullscreen mode Exit fullscreen mode

terraform destroy1

Plan: 0 to add, 0 to change, 22 to destroy
Enter fullscreen mode Exit fullscreen mode

Key Lessons Learned

  1. Multi-cloud requires clear provider separation.
  2. State file protection is critical.
  3. Always destroy lab infrastructure.
  4. Azure and AWS networking models differ significantly.
  5. Infrastructure as Code improves reproducibility and scalability.

Why Multi-Cloud Matters

Organizations use multi-cloud to:

  • Reduce vendor dependency
  • Improve disaster recovery
  • Optimize regional availability
  • Compare cost efficiency between providers Terraform makes multi-cloud deployment seamless.

Conclusion

This project demonstrates how Terraform can orchestrate infrastructure across AWS and Azure from a single configuration.

By implementing EC2, RDS, and Azure VM resources, I built a resilient, scalable, and reproducible multi-cloud environment.

Author
Isaiah Izibili
Cloud & DevOps Enthusiast

Top comments (0)