Chapter 5: Creating a basic Azure infrastructure with Terraform

In the previous chapters, we learned how to interact with Azure using Terraform. Now, it's time to put that knowledge into practice with a hands-on example. We will create an Azure infrastructure with the following components:

  • A resource group

  • An Azure App Service Plan

  • Azure Linux Web Apps

Creating Infrastructure with a Single main.tf File

In Chapter 1, we discussed the Terraform ground rules to follow when working with Terraform. Let's create a team agreement that reflects these best practices, even though the agreement below is intentionally simplified to get us started. We will enhance and apply these ground rules as we progress through this chapter.

Practice
Decision

Directory structure

single main.tf file

Terraform Resource Naming Convention

Resource Group: basic-infra Service Plan: basic-infra Linux Web App: basic-app

State file properties

Use local state file with default name

Terraform output naming convention

Resource Group Name: rg_name Service Plan Name: sp_name Linux Web App Name: webapp_name Linux Web App URL: webapp_url

Lifecycle of resources

Do not use lifecycle

Let's follow the core workflow we worked on in Chapter 4 and create our infrastructure. The first step is to handle the authentication between Azure and Terraform. We will use a Service Principal that has enough privileges to create infrastructure under our subscription. For now, we will intentionally provide the secret values in the main.tf file and we will refactor our code to make it more enterprise-grade as we go along in this chapter.

To begin, let's create a directory called basic-infra-single-file-local-state and navigate to that directory.

I strongly recommend using the VSCode code editor and the official HashiCorp Terraform plugin.

First, we need to let Terraform know that we will be working with Azure by using the terraform and provider blocks. We need to provide the name of the provider, its source to download, and also the version we want to use.

It is highly important to note that you should never include your service principal's secret in any of the Terraform files that you will be pushing to a shared location. This example is just to show the steps, including some bad (even the worst) practices, so that we can fix them along the way in this chapter.

# Azure Provider source and version being used
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.37.0"
    }
  }
}

# Configure the Microsoft Azure Provider
provider "azurerm" {
  features {}
}

Under the terraform block, we tell Terraform that we want to interact with an Azure infrastructure by using the azurerm provider and also specify where to download it and what version to use. Then, in the provider block, we let Terraform use the subscription authentication details.

In the above code snippet, the azurerm provider version is fixed to 3.37.0. You can learn more about version constraints at https://developer.hashicorp.com/terraform/language/expressions/version-constraints. In an enterprise environment, it is a good practice to fix the version to prevent unnecessary updates and potential conflict issues.

It's time to start writing the actual Terraform code to create the infrastructure. Your starting point should be the official documentation to learn how to consume Terraform resources and data types. We will refer to the following documentation:

Resource Name
Terraform Type

Resource Group

Azure Service Plan

Azure Linux Web App

Use the Terraform azurerm documentation as your source of truth when writing your code. Almost all Azure resources will be listed there, along with their required and optional parameters. You will also find information about how to define data types for these resources, which we will cover in the next chapters.

Below is how we can create the infrastructure with only the required parameters:

# Create a resource group
resource "azurerm_resource_group" "basic-infra" {
  name     = "rg-dev-basic-infra-neu"
  location = "North Europe"
}

# Create a Service Plan
resource "azurerm_service_plan" "basic-infra" {
  name                = "dev-asp-001"
  resource_group_name = azurerm_resource_group.basic-infra.name
  location            = azurerm_resource_group.basic-infra.location
  os_type             = "Linux"
  sku_name            = "F1"
}

# Create a Linux Web App
resource "azurerm_linux_web_app" "basic-app" {
  name                = "dev-webapp-001"
  resource_group_name = azurerm_resource_group.basic-infra.name
  location            = azurerm_resource_group.basic-infra.location
  service_plan_id     = azurerm_service_plan.basic-infra.id

  site_config {
    always_on = false
  }
}

output "rg_name" {
  description = "Name of the resource group"
  value       = azurerm_resource_group.basic-infra.name
}

output "sp_name" {
  description = "Name of the Azure service plan"
  value       = azurerm_service_plan.basic-infra.name
}

output "webapp_name" {
  description = "Name of the Azure Linux Web App"
  value       = azurerm_linux_web_app.basic-app.name
}

output "webapp_url" {
  description = "URL of the Azure Linux Web App"
  value       = "https://${azurerm_linux_web_app.basic-app.default_hostname}"
}

Now, let's go over the details. In Terraform, we create resources by defining a resource block in the code. Each resource has its own arguments, which can be found in the azurerm resources documentation.

After the resources have been created, we can display output for a specific resource by defining an output block.

A question we need to consider is how Terraform understands the dependencies between these resources and ensures that it does not try to create the Azure Service Plan before the resource group, which would cause an error. On lines 10 and 11, we allow Terraform to use the location of the resource group and implicitly create the dependency without mentioning it. We could also use depends_on block and provide the list of resources to create a dependency explicitly.

Please note that we have set the location of the azurerm_service_plan resource by azurerm_resource_group.basic-infra.location. If the input resource has been created under the same Terraform code, you can reference it by using the syntax: <azurerm_resource_type>.<resource_name>.<resource_attribute>, but how do we know which attributes are exported per resource? Each Terraform resource has a resources and data resources section in the Base section of azurerm documentation. The complete list of attributes is defined with their example usage in the documentation.

If we would like to refer to an existing resource in our code, we can create a data source and export its attributes. You can create data sources by defining a data block using the construct data "<azure_resource_data_type>" "name" {}, and then in your Terraform code, you can consume it as data.<azure_resource_data_type>.<name>.<resource_data_attribute>.

There are also other ways to use data resources, which will be covered in Chapter 7.

Now it is time to continue with the next steps of the workflow: initializing, validating, planning, and applying our code.

> terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "3.37.0"...
- Installing hashicorp/azurerm v3.37.0...
- Installed hashicorp/azurerm v3.37.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Terraform has downloaded the required plugin(s) and stored them in the ".terraform" directory within the current working directory.

> tree .terraform
.terraform
|-- providers
    |-- registry.terraform.io
        |-- hashicorp
            |-- azurerm
                |-- 3.37.0
                    |-- linux_amd64
                        |-- terraform-provider-azurerm_v3.37.0_x5

Now let's validate our code with terraform terraform validate command.

> terraform validate
Success! The configuration is valid.

Since the validation has been passed, it is time to run the plan:

> terraform plan

| Error: building AzureRM Client: obtain subscription() from Azure CLI: parsing json result from the Azure CLI: waiting for the Azure CLI: exit status 1: ERROR: Please run 'az login' to setup account.
|
|   with provider["registry.terraform.io/hashicorp/azurerm"],
|   on main.tf line 12, in provider "azurerm":
|   12: provider "azurerm" {

It looks like we forgot something here. We have inputted everything for Terraform to create the resources, except the authentication. We have not provided the environment variables for Terraform to use to authenticate with Azure. In Chapter 1, we mentioned how to do that and we covered having a shell alias for the task:

> echo $ARM_CLIENT_ID
> alias azure-auth
azure-auth='. ~/.azure-auth.sh'
> azure-auth
> cat ~/.azure-auth.sh
export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_CLIENT_SECRET="Xxxxxxxxxxxxx"
export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000"
export ARM_TENANT_ID="0000000-0000-0000-0000-000000000000"

Now, we have exported the required environment variables for Terraform to authenticate with our Azure subscription.

> terraform plan

Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:
# ... other output omitted for brevity
  
Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + rg_name     = "rg-dev-basic-infra-neu"
  + sp_name     = "dev-asp-001"
  + webapp_name = "dev-onur-webapp-001"
  + webapp_url  = (known after apply)

We can review the plan and apply it if we are satisfied with the result:

> terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:
# ... other output omitted for brevity

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + rg_name     = "rg-dev-basic-infra-neu"
  + sp_name     = "dev-asp-001"
  + webapp_name = "dev-onur-webapp-001"
  + webapp_url  = (known after apply)

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes  
# ... other output omitted for brevity

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

rg_name = "rg-dev-basic-infra-neu"
sp_name = "dev-asp-001"
webapp_name = "dev-onur-webapp-001"
webapp_url = "https://dev-onur-webapp-001.azurewebsites.net"

Once we applied the code, Terraform will create a local state file under the current working directory and will compare the desired state of the environment with the state file. If we run the same code again now, Terraform will not detect any changes and will skip the execution.

> terraform apply
# ... other output omitted for brevity

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

rg_name = "rg-dev-basic-infra-neu"
sp_name = "dev-asp-001"
webapp_name = "dev-onur-webapp-001"
webapp_url = "https://dev-onur-webapp-001.azurewebsites.net"

If you decide to change the name of a resource in Terraform (not the Azure resource name), Terraform will compare your code with the state file and decide to remove the Azure resource with the old Terraform resource name and create a new Azure resource with the new Terraform resource name. This is why it is important to follow a Terraform resource naming convention in your team.

For example, let's change the resource name of azurerm_linux_web_app resource from basic-appto basic-app2:

> terraform apply
# ... other output omitted for brevity

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # azurerm_linux_web_app.basic-app will be destroyed
  # (because azurerm_linux_web_app.example is not in configuration)
  - resource "azurerm_linux_web_app" "basic-app" {
      - app_settings                      = {} -> null
  
    # ... other output omitted for brevity  # azurerm_linux_web_app.example2 will be created
  
  + resource "azurerm_linux_web_app" "basic-app2" {
      + client_affinity_enabled           = false

    # ... other output omitted for brevity

Plan: 1 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  ~ webapp_url = "https://dev-onur-webapp-001.azurewebsites.net" -> (known after apply)

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

As you can see, Terraform wants to delete the resource and wants to recreate it with the same Azure resource name as the Terraform resource object name has changed.

The above example demonstrates how to start using Terraform with a single file, but it does not allow us to utilize many of Terraform's useful features. Now, we will refactor the code to make it more suitable for use in an enterprise setting. Here is a list of improvements we can make:

  1. State file: The state file should always be stored remotely in a secure location.

  2. Divide and conquer: Divide the main.tf file into multiple logical sections to make it easier to read, especially for more extensive code.

  3. Directory structure: The directory structure should allow you to use the same Terraform skeleton for different environments.

Integrating Remote State Files into Terraform Workflows

If you are working with a team, the remote state file can be used to share the state of your infrastructure and configuration with your team members. This ensures that everyone is working with the same set of resources and helps to coordinate changes properly. The state file can contain passwords and sensitive infrastructure details about your environment. Keeping the state file in a remote location helps to protect this sensitive information and secure the endpoint.

Let's now integrate our code with an existing Azure storage account.

We should have a solid storage account, container, and key name convention.

In order to use a remote state file, we need to define a backend block under the terraform block in our main.tf file, as shown below:

# Configure the Azure provider
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.37.0"
    }
  }
  
  backend "azurerm" {
    resource_group_name = "rg-common-tf-state-neu"
    storage_account_name = "onursa001"
    container_name       = "tfstate"
    key                  = "dev.basic-infra.tfstate"
  }
}

Now, we need to reinitialize our Terraform code so that it uses the remote backend for state storage instead of the local state file. If there is already a local state file, Terraform will automatically attempt to copy it to the remote backend.

> terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "azurerm" backend. No existing state was found in the newly
  configured "azurerm" backend. Do you want to copy this state to the new "azurerm"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "azurerm"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Reusing previous version of hashicorp/azurerm from the dependency lock file
- Using previously-installed hashicorp/azurerm v3.37.0

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

From now on, Terraform will use the remote backend for the state file, as it is the configured backend in our main.tf file. To avoid confusion, you may want to delete the local state file and its backup.

terraform init command will keep the initialization metadata under the .terraform directory. You need to run all Terraform commands from the same root directory.

Dividing the Infrastructure into Multiple Terraform Files

Dividing your code into multiple files can make it easier to read and understand, particularly for larger configurations. By organizing your code into logical sections and giving each file a descriptive name, you can more effectively convey the purpose of the code and improve its readability.

Let's create a structure for our code and divide the main.tf into multiple Terraform files:

File Name
Purpose

main.tf

Define infrastructure resources

variables.tf

Define variables

outputs.tf

Define output values

backend.tf

Define backend configuration

versions.tf

Specify providers, plugins with versions

terraform.tfvars

Specify values for the variables

The names of the files are self-explanatory. Let's move all output blocks to outputs.tf, and backend details to backend.tf, as shown below:

# outputs.tf
output "rg_name" {
  description = "Name of the resource group"
  value       = azurerm_resource_group.basic-infra.name
}

output "sp_name" {
  description = "Name of the Azure service plan"
  value       = azurerm_service_plan.basic-infra.name
}

output "webapp_name" {
  description = "Name of the Azure Linux Web App"
  value       = azurerm_linux_web_app.basic-app.name
}

output "webapp_url" {
  description = "URL of the Azure Linux Web App"
  value       = "https://${azurerm_linux_web_app.basic-app.default_hostname}"
}
# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name = "rg-common-tf-state-neu"
    storage_account_name = "onursa001"
    container_name       = "tfstate"
    key                  = "dev.basic-infra.tfstate"
  }
}

We will also move the required provider and its version details into the versions.tf file:

# versions.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.37.0"
    }
  }
}

# Configure the Microsoft Azure Provider
provider "azurerm" {
  features {}
}

If you review our initial main.tf file, you will notice that we have not used any variables. We have hardcoded every value in the code. To make our code more modular and easier to maintain, let's define all possible variables in the variables.tf file and reference them in main.tf, as shown below:

# variables.tf
variable "resource_group_name" {
  type        = string
  description = "Name of the resource group"
}

variable "service_plan_name" {
  type        = string
  description = "Name of the service plan"
}

variable "sp_sku_name" {
  type        = string
  description = "SKU name of the service plan"
  default     = "F1"
}

variable "location" {
  type        = string
  description = "Location of the resources"
  default     = "North Europe"
}

variable "webapp_name" {
  type        = string
  description = "Name of the Linux Web App"
}

variable "webapp_always_on" {
  type        = bool
  description = "If this Linux Web App is Always On enabled"
  default     = true
}

In the variables.tf file, we define variables using the variable block, which can accept multiple arguments. We can set a default value for a variable in the variable block in the variables.tf file. If we do not explicitly set the value of the variable in a .tfvars file, it will use the default value defined in the variables.tf file.

Terraform also allows validating the variable under the variable block as below.

variable "location" {
  type        = string
  description = "Location of the resources"
  default     = "North Europe"
  validation {
    condition     = contains(["North Europe", "West Europe"], var.location)
    error_message = "Valid locations are `North Europe` and `West Europe`"
  } 
}

It will not allow users to use a different location than what has been defined in the condition.

Now we can input those variables in our main.tf file as below:

# main.tf

# Create a resource group
resource "azurerm_resource_group" "basic-infra" {
  name     = var.resource_group_name
  location = var.location
}

# Create a Service Plan
resource "azurerm_service_plan" "basic-infra" {
  name                = var.service_plan_name
  resource_group_name = azurerm_resource_group.basic-infra.name
  location            = azurerm_resource_group.basic-infra.location
  os_type             = "Linux"
  sku_name            = var.sp_sku_name
}

# Create a Linux Web App
resource "azurerm_linux_web_app" "basic-app" {
  name                = var.webapp_name
  resource_group_name = azurerm_resource_group.basic-infra.name
  location            = azurerm_resource_group.basic-infra.location
  service_plan_id     = azurerm_service_plan.basic-infra.id

  site_config {
    always_on = var.webapp_always_on
  }

  lifecycle {
    precondition {
      # always_on must be explicitly set to false when using Free, F1, D1, or Shared Service Plans.
      condition     = !var.webapp_always_on && contains(["Free", "F1", "D1", "Shared"], var.sp_sku_name)
      error_message = "webapp_always_on must be explicitly set to false when using Free, F1, D1, or Shared Service Plans"
    }
  }
}

As you can see above, we can set the value of an argument by referencing variables using the var.<variable_name> format. On line 29, we can also use the lifecycle block to specify conditions that must be met before a resource is created. lifecycle is an important feature in Terraform.

Validation can also be performed in the variables.tf file, but you can only validate the variable itself within its own variable block. You cannot reference other variables in the condition.

The only remaining step is to specify the non-default values for the variables. By default, Terraform will look for a terraform.tfvars file in the working directory to determine the variable inputs, unless the file is explicitly specified when running the terraform binary.

# terraform.tfvars
resource_group_name = "rg-dev-basic-infra-neu"
service_plan_name   = "dev-asp-001"
sp_sku_name         = "F1"
webapp_name         = "dev-onur-webapp-001"
webapp_always_on    = false

If we set webapp_always_on variable to true and run the plan or apply command with the same Terraform code, we will encounter an error due to the lifecycle precondition of the azurerm_linux_web_app resource.

> terraform plan
Acquiring state lock. This may take a few moments...
# ... other output omitted for brevity

| Error: Resource precondition failed
| 
|   on main.tf line 36, in resource "azurerm_linux_web_app" "example":
|   36:       condition = !var.webapp_always_on && contains(["Free", "F1", "D1", "Shared"], var.sp_sku_name)
|     |--------------------
|     | var.sp_sku_name is "F1"
|     | var.webapp_always_on is true
| 
| webapp_always_on must be explicitly set to false when using Free, F1, D1, or Shared Service Plans

Introducing a Terraform Directory Structure

In Chapter 8, we will discuss and compare different directory structures for Terraform.

We have already divided our Terraform code into multiple files, but we are using the default terraform.tfvars file and provisioning only a single environment (development environment). In a real-world scenario, we may have multiple environments such as DEV, PREPROD, or PROD. It is my best practice to keep the code fixed and use different variables files to ensure consistent infrastructure across different environments. There are various approaches to organizing a Terraform directory structure, but here is one option that I personally prefer: I keep all my code in a single directory and use a directory layout like the following in the root directory for variables:

.
|--- main.tf
|--- backend.tf
|--- variables.tf
|--- versions.tf
|--- outputs.tf
|--- environment
    |--- DEV
    |   |--- backend.hcl
    |   |--- terraform.tfvars
    |--- PREPROD
    |   |--- backend.hcl
    |   |--- terraform.tfvars
    |--- PROD
        |--- backend.hcl
        |--- terraform.tfvars

This structure allows me to use the same infrastructure code for all of my environments by using dedicated Terraform variables files and a dedicated remote Terraform backend. As an example, I (or my pipeline) can initialize the remote Terraform backend for the DEV environment using this structure:

terraform init -backend-config=environment/DEV/backend.hcl

and then run the plan command as:

terraform plan -var-file=environment/DEV/terraform/tfvars

We will cover the details of this setup in upcoming chapters, but for now, let's create a structure with only DEV and PROD environments. Let's start with the DEV environment and move the terraform.tfvars file to the environment/DEV path. We also need to create a backend.hcl file and modify the backend.tf file as follows:

# ./environment/DEV/backend.hcl
resource_group_name = "rg-common-tf-state-neu"
storage_account_name = "onursa001"
container_name       = "tfstate"
key                  = "dev.basic-infra.tfstate"
# ./backend.tf
terraform {
  backend "azurerm" {}
}

Similarly, you can create new backend.hcl and terraform.tfvars files under the PROD directory with their dedicated values and remote backend configuration.

Microsoft Azure has a limit of 1 Free Linux app service plan(s) it can create in a single region. That's why let's edit the ./environment/PROD/terraform.tfvars file as below:

# ./environment/PROD/terraform.tfvars
resource_group_name = "rg-prod-basic-infra-weu"
service_plan_name   = "prod-asp-001"
sp_sku_name         = "F1"
webapp_name         = "prod-onur-webapp-001"
webapp_always_on    = false
location            = "West Europe"

Let's apply for the same code against PROD environment as below:

> terraform init -backend-config=environment/PROD/backend.hcl

Initializing the backend...

# ... other output omitted for brevity

> terraform apply -var-file=environment/PROD/terraform.tfvars

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

# ... other output omitted for brevity
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

rg_name = "rg-prod-basic-infra-weu"
sp_name = "prod-asp-001"
webapp_name = "prod-onur-webapp-001"
webapp_url = "https://prod-onur-webapp-001.azurewebsites.net"

The example provided above demonstrates how to create a Resource Group and Service Plan using only the mandatory arguments of the respective Terraform resources. This has been done intentionally to keep the example simple and focused on creating infrastructure with Terraform without getting into too much detail. In the upcoming chapters, we will cover more advanced examples.

Last updated