Deploying Terraform Infrastructure to Azure using Azure DevOps Pipelines
Tags: terraform, azure, devops, cicd
Published: 2023-08-28 10:16:59

In my experience knowing how to deploy a service intro production is as important as knowing the application itself. As Software Engineers we’ll like to manage the risk of deployments, and deploy as often and early as possible in the release cycle without downtime. However, every now and then you have to deploy infrastructure as well, so why not subject it, to the same level of automation as your applications?

This is the Second part of how to use Terraform with Azure, so if you haven’t already go read my [first blog entry](https://dev.to/martinhc/getting-started-with-terraform-and-azure-34ng) on how to setup a terraform repository and create the necessary service principles to get your started. Alternatively you can go to the repository on [Github](https://github.com/mclausen/Cloud.Platform.Foundation/tree/main/samples/01-initial-terraform-with-azure-backend) see what is there.

## What kind of Infrastructure are we deploying?

When working with bigger Cloud Platforms you quickly discover that not all infrastructure are deployed at the same time, and more often than not, we gave contain multiple layers of infrastructure. To drive the point through consider these two layers

- Global Infrastructure, this typically includes changes to an Azure Kubernetes Cluster, a Azure Container Registry, or an  Azure Service Bus. Global Infrastructure is the shared components that your applications can leverage. Also global infrastructure can be deployed independently of the rest of your application stack.
- Service Infrastructure is the the infrastructure that is needed to run your application, this includes Databases, Redis cache etc. And is deployed together with the service deployment.

In our organisation we have a couple of more layers, but lets just keep it simple for demonstration purpose, so for this article, I would like to focus on the global infrastructure, since we its easy to zoom in on the pipeline itself.

## What does our deployment pipeline look like?

For this sample pipeline we are going to build two stages

1. Validate Terraform - First stage we validate that our terraform configuration is correctly configured. This includes Terraform syntax and connectivity to our Azure Backend Store. 
2. Release to Dev - We have some infrastructure to deploy, so lets release it to our development environment


![Release Pipeline](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/az0zy0bnarunnodh4e8r.png)

I have chosen to organizing the pipeline in the following way.

```bash
├── azure-pipelines.yaml
├── main.yaml
└── stages
    ├── tf-release.yaml
    └── tf-validate.yaml
```

- `azure-pipelines.yaml` is the entry point for the pipelines. It contains all the triggers, variables and other pipeline controls
- `main.yaml` can be though of an orchestrator of stages. It contains a list stages that we would like to execute
- `tf-release.yaml` is our terraform release stage, that is responsible for doing the actual release of terraform. Note here that we do not denote the environment as to which we would like to deploy. We can simply reuse the stage and give it other parameters on runtime.
- `tf-validate.yaml` is or Terraform Validation Stage

## Deploying your infrastructure

All the magic happens in the `tf-release.yaml`. Here we basically do the exact same things, as if we had run the command locally.

```bash
terraform init
terraform plan
terraform apply
```

Starting at the file, we define three variables

- `tf_version` - We need to parse in the terraform version into the release stage to ensure that our whole pipeline is using the same version of terraform. Since we use terraform in multiple pipelines this variable is defined in the azure-pipelines.yaml and parsed through all stages
- `working_directory` - this will be our base directory where we are executing commands
- `environment` - is the name of the environment. We can use this to do some magic display names, or reference azure pipelines variables using naming conventions.

Additionally I will be pulling in, the variable group that we created in the <last blog post> to get the service principle along with the access key to our storage account, plus three additional variables to help with location of our terraform artifacts.

```bash
parameters:
  tf_version:
  working_directory:
  environment:

stages:
  - stage: release_${{ parameters.environment }}
    displayName: Release to ${{ parameters.environment }}
    variables:
      - group: root-terraform-backend-credentials
      - name: backend_tfvars
        value: "${{ parameters.working_directory }}/tf/backend/backend.ci.tfvars"
      - name: variable_tfvars
        value:  "${{ parameters.working_directory }}/tf/environments/${{ parameters.environment }}/variables.tfvars"
      - name: infrastructure_workingdirectory
        value:  "${{ parameters.working_directory }}/tf"
```

### Install Terraform on the build agent

Until we have explicitly installed terraform on our build agent, we’ll not be able to execute any Terraform. This is however pretty straight forward. Though you might have to install [Terraform Extension](https://marketplace.visualstudio.com/items?itemName=ms-devlabs.custom-terraform-tasks) into your Azure DevOps Organization

```bash
- task: TerraformInstaller@0
  displayName: "Use Terraform ${{ parameters.tf_version }}"
  inputs:
    terraformVersion: ${{ parameters.tf_version }}
```

### Terraform Init

When initialising terraform we must provide it the initial backend file that we would like to use. We first have to replace the tokens in our `backend.ci.tfvar` file. You might also have to install  [Replace Tokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens) Extension into your Azure DevOps Organization.

After we have replace the token, we can proceed to initializing terraform

```bash
- task: qetza.replacetokens.replacetokens-task.replacetokens@3
  displayName: "Replace tokens in backend.tfvars with variables from the CI/CD environment vars"
  inputs:
    targetFiles: $(backend_tfvars)
    encoding: "auto"
    writeBOM: true
    actionOnMissing: "fail"
    keepToken: false
    tokenPrefix: "#{"
    tokenSuffix: "}#"

- bash: |
    terraform init -backend-config=$(backend_tfvars) -input=false
  env:
    ARM_CLIENT_ID: $(sp_clientId)
    ARM_CLIENT_SECRET: $(sp_clientSecret)
    ARM_SUBSCRIPTION_ID: $(sp_subscriptionId)
    ARM_TENANT_ID: $(sp_tenantId)
  displayName: Initialize configuration
  workingDirectory: $(infrastructure_workingdirectory)
  failOnStderr: true
```

See the `-input=false` flag at the end? This tells Terraform that it should not prompt for input, and is important not to miss when running in a CI environment.

### Terraform Plan

Terraform plan, is the action of making the change set for our infrastructure. For this command we are parsing providing a `-out` parameter which is a mechanism to store that change set to a file on disk, and since this is running in a single build agent job, we can use that file to apply the infrastructure in a later task. 

<aside>
💡 You can export the terraform plan file and use it in another stage. This is especially useful if you want to add an additional approval step, or inspect the file manually.

</aside>

```bash
- bash: |
    terraform plan -var-file=$(variable_tfvars) -input=false -out=tfplan
  env:
    ARM_CLIENT_ID: $(sp_clientId)
    ARM_CLIENT_SECRET: $(sp_clientSecret)
    ARM_SUBSCRIPTION_ID: $(sp_subscriptionId)
    ARM_TENANT_ID: $(sp_tenantId)
  displayName: Create execution plan
  workingDirectory: $(infrastructure_workingdirectory)
  failOnStderr: true
```

### Terraform Apply

Last thing we need to do is to apply the changes. For this example, we parse the `-auto-approve` flag, which will just apply the changes that we have, without prompting us. Additionally we are using the `tfplan` that we created in the previous task.

```bash
- bash: |
    terraform apply -input=false -auto-approve tfplan
    terraform output -json > output.${{ parameters.environment }}.json
  env:
    ARM_CLIENT_ID: $(sp_clientId)
    ARM_CLIENT_SECRET: $(sp_clientSecret)
    ARM_SUBSCRIPTION_ID: $(sp_subscriptionId)
    ARM_TENANT_ID: $(sp_tenantId)
  displayName: Apply execution plan
  workingDirectory: $(infrastructure_workingdirectory)
  failOnStderr: true
```

## Conclusion

In my previous article [Getting Started with Terraform and Azure](https://dev.to/martinhc/getting-started-with-terraform-and-azure-34ng), we started by setting up the the initial terraform, which would serve as the foundation for constructing a Cloud Platform using Terraform. 

For this article we have gone through how to apply the infrastructure using Azure Pipelines.

This is a very basic example, and I am sure that you need to do adjustments to fit your development flow. However here are some ideas, that you can consider for your next project

- Have the pipeline add an additional approval step before applying the infrastructure structure
- Have the multistage Azure Pipeline, Plan your live environment, after you have deployed to Development and see how the change will affect your live environment, before approving it.
- Configure a web hook that tells your team what changes are being deployed.

You can find the full code sample on [Github](https://github.com/mclausen/Cloud.Platform.Foundation/tree/main/samples/01-initial-terraform-with-azure-backend), with more examples to come.

## References
- Github Repository: [Cloud.Platform.Foundation](https://github.com/mclausen/Cloud.Platform.Foundation/tree/main/samples/01-initial-terraform-with-azure-backend)
- Azure DevOps Extension: [ReplaceTokens](https://marketplace.visualstudio.com/items?itemName=qetza.replacetokens)
- Azure DevOps Extension: [Terraform](https://marketplace.visualstudio.com/items?itemName=ms-devlabs.custom-terraform-tasks)