One approach to structuring terraform
Tags: terraform, azure
Published: 2023-03-19 14:59:42

For this post I wanted to share how I prefer to structure my terraform code for use with multiple environments, modules and how I can work with it locally. If you have a different approach then please feel free to leave a comment - I'm always happy to learn and improve.

**First off, the folder structure**. The infrastructure folder sits in the root along side src, test, build and whatever else goes in the git repository.

```
.
├── infrastructure
│   │── environments
│   │   ├── dev
│   │   ├── live
│   │   ├── main
│   └── modules
```

Within an environment folder I would put `local.backend.tfvars` and `local.secrets.tfvars` files for local development purposes. These files should NOT be committed to git, so I always make sure to include them in the .gitignore file of the repository.
Then there is the `backend.tfvars` file, which is used to configure the backend used for the terraform state. Finally the (maybe?) most important file for the environment `variables.tfvars`, which contains all the environment specific variables. A good example of this is using different SKUs of Azure App Service in Development versus Production. It could also be the subscription used for the environment - maybe you want to use an Azure Dev/Test Subscription for Development and a CSP/PAYG subscription for Production.

The main folder contains the `main.tf`, `secrets.tf`, `outputs.tf` and `variables.tf` files. Depending on the size and content of the `main.tf` file I tend to split it up into multiple files, so I don't end up having to scroll thousands of lines of code. The structure and naming of files can vary depending on what makes sense, but one simple approach is to divide the different types of resources into different files. There will likely be things like KeyVault or Networking, which ties into other resources, so it really depends.

The modules folder is used for reusable modules. It's not always relevant, but I tend to keep the modules outside of the environments. The structure highlights that its reusable across environments.

## Working locally

When you need to run Terraform locally against the Development environment, use the steps listed below.

Ensure that you have both `local.backend.tfvars` and `local.secrets.tfvars` in your environment folder with the actual keys in place.

1. `cd infrastructure/environments/main`
2. `terraform init -backend-config="../dev/local.backend.tfvars"`
3. `terraform validate`
4. `terraform plan -var-file="../dev/local.secrets.tfvars" -detailed-exitcode -out=tfplan`

Let's have a look at the content of the `local.backend.tfvars` when using Azure Blob Storage as the backend for the terraform state.

```
resource_group_name     = "rg-terraform-state-we-dev"
storage_account_name    = "terraformwestatedev"
container_name          = "terraform-state-dev"
key                     = "azure/terraform.tfstate"
access_key              = "<actual access key goes here>"
```

The content of the `local.secrets.tfvars` should correspond to what you put in the `secrets.tf` from within the main folder, and what is needed to create resources within Azure/AWS/GCP. For Azure it would be Service Principal details.

```
tenant_id       = "<actual tenant id goes here>"
subscription_id = "<actual subscription id goes here>"
client_id       = "<actual client id goes here>"
client_secret   = "<actual client secret goes here>"
```

When working locally you could connect to the development environment or use a different subscription / account setup. If you use the same subscription and terraform state locally as when deploying to development, you should be aware of the changes between working locally versus deploying via CI/CD. The deployed changes might revert what you are testing locally, and other deployments might fail because the state is expecting resources, which are not part of what is being deployed.

## main.tf

With the backend and secrets setup as mentioned above there is one important thing to include in the `main.tf` file - the required providers and something like `azurerm` as the backend like so:

```
terraform {
  backend "azurerm" {}
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "= 2.29.0"
    }
    cloudflare = {
      source  = "terraform-providers/cloudflare"
      version = "~> 2.9.0"
    }
  }
}
```

The backend part is what will be "filled" with the `*backend.tfvars` files.

## Azure DevOps, Terraform and Secrets

When running infrastructure as code (IaC) as part of a pipeline in something like Azure DevOps (same applies to Github Actions and other CI/CD setups), you want to keep your secrets safe and inject them when needed as part of the pipeline.
In order to run the terraform init, plan and apply commands there needs to be a backend for storing state and there needs to be secrets (like a Service Principal) for creating the actual resources in Azure. We also need to know, which environment we are deploying to, so we can select the right one (using the `variables.tfvars` file that corresponds to the environment).

The secrets can be added as a variable group for the environment under Pipelines/Library in Azure DevOps. You could also store the secrets in KeyVault and connect KeyVault to Azure DevOps, so they are not visible by anyone.

The `backend.tfvars` for the development environment would typically look like this:

```
resource_group_name     = "rg-terraform-state-we-dev"
storage_account_name    = "terraformwestatedev"
container_name          = "terraform-state-dev"
key                     = "azure/terraform.tfstate"
access_key              = "#{terraform_access_key}"
```

As you can see its pretty much the same as the `local.backend.tfvars` where the only difference is the `access_key` property, which will be replaced within the pipeline.

The `secrets.tf` file within the main folder will be variable definitions for the secrets needed to run the terraform. These variables will need to be set in the pipeline, so the IaC can be initialized.

```
variable "tenant_id" {
  description = "Azure subscription tenant id."
}

variable "subscription_id" {
  description = "Azure subscription id."
}

variable "client_secret" {
  description = "Azure provider client secret"
}

variable "client_id" {
  description = "Azure provider Azure AD client id."
}
```

Within the variables.tfvars file for an environment I keep the variable secrets as tokens, which can later be replaced:
```
tenant_id       = "#{tenant_id}"
subscription_id = "#{subscription_id}"
client_id       = "#{client_id}"
client_secret   = "#{client_secret}"
```

When doing this in Azure DevOps with a YAML pipeline I use the following approach for replacing the tokens with secrets and generating - here its the validation stage of the pipeline:

```
parameters:
  variable_group_name:
  environment_name:
  working_directory:
  source_branch:

jobs:
  - job: Validate
    displayName: Terraform Validate Plan
    continueOnError: false
    variables:
      - name: tf_work_dir
        value: "$(working_directory)/environments/main"
      - group: ${{ format('telemetry-{0}', parameters.environment_name) }}
      - ${{ if ne(parameters.variable_group_name, '') }}:
          - group: ${{ format('{0}-{1}', parameters.variable_group_name, parameters.environment_name) }}
    steps:
      - task: qetza.replacetokens.replacetokens-task.replacetokens@3
        displayName: "Replace tokens in *.tfvars with variables from the desired Variable Group"
        inputs:
          targetFiles: "$(working_directory)/environments/$(environment_name)/*.tfvars => *.env.tfvars"
          encoding: "auto"
          writeBOM: true
          actionOnMissing: "warn"
          keepToken: false
          tokenPrefix: "#{"
          tokenSuffix: "}"
      - bash: |
          terraform init -backend-config="../$(environment_name)/backend.env.tfvars" -input=false
        displayName: Initialize configuration
        workingDirectory: $(tf_work_dir)
      - bash: terraform validate
        displayName: Validate configuration
        workingDirectory: $(tf_work_dir)

      - bash: |
          terraform plan -var-file="../$(environment_name)/variables.env.tfvars" -input=false
        displayName: Create execution plan
        workingDirectory: $(tf_work_dir)
```

With this setup I can work with terraform locally, and I can choose to create a throw-away environment for trying out different things locally without interfering with Development. Alternatively, I can run it against the Development environment, which can be handy if resources needs to be imported or deleted without having to involve a pipeline.
I can create any number of environments, which can have each their own subscription and resource configuration.

Here is the file structure with all of the mentioned files

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sygn2wg3rk546z34etr0.png)

The screenshot is from a real world telemetry service, which is composed of a Cloudflare Worker (point of entry / reverse proxy) and an Azure backend (API and Storage).