Deploying a Static Website to Azure Storage with Terraform and Azure DevOps

15 minute read

This week I’ve been working on using static site hosting more as I continue working with Blazor on some personal projects.

My goal is to deploy a static site to Azure, specifically into an Azure Storage account to host my site, complete with Terraform for my infrastructure as code. Now, I’ll caveat this with saying Terraform isn’t my strong point but this seems to work for me and I hope you find it useful too. I’m also going to use Terraform 0.12 for this post rather than 0.13 as I’ve not gone through changes and so on yet for it.

As always, if you’d like to skip straight to the code, you can find it in the lgulliver/TerraformAzureStorageStaticSite repository on GitHub.

Outline of workflow

Push to GitHub
Push to...
Deploy to Blob Storage
Deploy t...
Deploy infrastructurewith Terraform
Trigger Azure
DevOps pipeline
Trigge...
Viewer does not support full SVG 1.1

This is a highly simplified version of what I want to do. I’m going to keep my code in GitHub (being completely honest here, it’s partly because I can then play with the new Codespaces beta), which will trigger an Azure DevOps pipeline to do three things:

  1. Deploy any required infrastructure to host my static site (Azure Storage Account)
  2. Deploy my static files to the infrastructure (HTML page)
  3. Define two environments, dev and prod.

I’m also going to stick to an “everything as code” approach here so that’ll include my pipeline as well.

As for this example I’m just moving a HTML page around, we won’t need to do a build here.

Setting up the Terraform backend

My pipeline is made up of two stage, dev and prod, but before we get started, we also need to create the infrastructure for the terraform backend.

I’m almost 100% certain there’s a better way than this, but what I’ve done here is created an ARM template to create the storage account that will store the Terraform state. It’s created with a partially randomly generated name to ensure uniqueness.

The ARM template also creates the blob storage container in the storage account.

It will be the first thing we run in the pipeline.

I’ve created this file under infrastructure/backend/tfbackend.deploy.json.

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "storageAccountType": {
      "type": "string",
      "defaultValue": "Standard_LRS",
      "allowedValues": [
        "Standard_LRS",
        "Standard_GRS",
        "Standard_ZRS",
        "Premium_LRS"
      ],
      "metadata": {
        "description": "Storage Account type"
      }
    },
    "location": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]",
      "metadata": {
        "description": "Location for all resources."
      }
    }
  },
  "variables": {
    "storageAccountName": "[concat('tfstate', uniquestring(resourceGroup().id))]"
  },
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2019-04-01",
      "name": "[variables('storageAccountName')]",
      "location": "[parameters('location')]",
      "sku": {
        "name": "[parameters('storageAccountType')]"
      },
      "kind": "StorageV2",
      "properties": {},
      "resources": [
        {
          "type": "blobServices/containers",
          "apiVersion": "2019-06-01",
          "name": "default/tfstate",
          "dependsOn": [
            "[variables('storageAccountName')]"
          ]
        }
      ]
    }
  ],
  "outputs": {
    "storageAccountName": {
      "type": "string",
      "value": "[variables('storageAccountName')]"
    }
  }
}

If there’s a better way to do this, please let me know! I’m 100% guilty of sticking to what I know here for berevity.

Defining the Terraform

For the Terraform part of the solution, I’ve created 4 files to handle the core infrastructure, anything that may vary from environment to environment and then I’ve also stuck provider versions.

az-storage-account-main.tf

This file contains the definition for the infrastructure I want to create.

terraform {
  backend "azurerm" {}
}

locals {
    env_prefix = "${var.shortcode}-${var.product}-${var.envname}-${var.location_short_code}"
    env_prefix_no_separator = "${var.shortcode}${var.product}${var.envname}${var.location_short_code}"
}

resource "azurerm_resource_group" "rg" {
  name     = "${local.env_prefix}-rg"
  location = var.location

  tags = {
    product = var.product      
  }
}

resource "azurerm_storage_account" "static_storage" {
  name                     = "${local.env_prefix_no_separator}stor"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_kind             = "StorageV2"
  account_tier             = "Standard"
  account_replication_type = "GRS"
  enable_https_traffic_only = true

  static_website {
    index_document = "index.html"
  }

  tags = {
    product = var.product
  }
}

The important parts here are that I need to be using a StorageV2 storage account.

All that is needed to enable the static site hosting on the storage account is to include the static_website block in the storage account definition. I’ve also gone ahead and defined what my index document for my static site is.

  static_website {
    index_document = "index.html"
  }

When this isn’t included, static site hosting is disabled as per the default behaviour.

az-storage-account-variables.tf

This contains the variables I want to use for values that will change between environments and it defines their types.

variable "location" { #Location of the Azure resources
    type = string
}
variable "location_short_code" { #3-character short code for location for naming
    type = string
}
variable "shortcode" { #buisiness unit short code
    type = string
}
variable "product" { #name of product or service being deployed
    type = string
}
variable "envname" { #name of the environment being deployed
    type = string
}

az-storage-account-variables.tfvars

This is the equivalent of a parameters file in ARM. I’m going to be doing some token replacement here for each stage.

location = "__location__"
location_short_code = "__location_short_code__"
shortcode = "__shortcode__"
product = "__product__"
envname = "__environment_name__"

versions.tf

Finally, I have a version file that sticks providers and modules to specific versions. In particular for this solution I need version 2.2.0 or greater of the azurerm provider, but I’m going to stick it to 2.2.0.

terraform {
    required_version = "~> 0.12.29"
}

provider "azurerm" {
    version = "~>2.2.0"
    features {}
}

Defining the pipeline

Now that I’ve got my code for both my Terraform backend and my storage account to host my site, I need to define my build and release pipeline for Azure DevOps.

As I’ve only got a single HTML file for this example, I’m going to skip defining a build here.

I mentioned earlier on in this post that I was aiming for two environments, dev and prod.

dev
dev
prod
prod
Viewer does not support full SVG 1.1

To achieve this, I’m going to use the stages capability in Azure DevOps YAML.

A basic setup of stages could look something a little like this:

stages:
- stage: dev
  jobs:
  - job: infrastructure
    pool: 'ubuntu-latest'

    steps:
    # tasks go here

- stage: prod
  dependsOn: dev
  jobs:
  - job: infrastructure
    pool: 'ubuntu-latest'

    steps:
    # tasks go here

Stages can each have their own variables too as well as use globally defined variables in the pipeline. They can also have approval gates enabled, but for the purpose of this example, I’m not going to do that and I’m going to go ahead and deploy automatically.

In each stage I’m going to execute the following:

  1. Deployment of ARM for Terraform backend.
  2. Replace tokens in my tfvars.
  3. Ensure I have the version of Terraform I want to use (0.12.29) available on the build agent
  4. terraform init
  5. terraform plan
  6. terraform apply
  7. Deploy to Azure storage

The first thing I’m going to do is create myself a service connection in Azure DevOps to my Azure subscription so that I can deploy. Take a look at the official docs on how to go about that.

As I don’t want to store the name of that service connection or any subscription IDs in my repository, I’m going to store those as variables in Azure DevOps.

Azure DevOps variables

Now I’ve done that, I can start putting together my pipeline YAML starting with my global variables that don’t need to be secret or are values I want to keep wherever I go.

trigger:
- master

variables:
  resource_group_tfstate: 'tfstate-uks-rg'
  product: 'staticsite'
  shortcode: 'lg'  

stages:

You’ll notice I don’t define an agent pool here either, that’s because I do it at the job level as one of the tasks we use later is currently only supported on Windows agents.

So let’s define our first stage, dev:

- stage: dev
  variables:    
    location: 'uksouth'        
    environment_name: 'dev'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfdev 
  jobs: 

Dev doesn’t depend on any pre-requisite steps that it depends on before the stage can be executed. Under normal cricumstance, I would potentially have a build as the first step which my dev stage would then depend on.

What I do have here though are my stage specific variables. Some are the same in both stages but I’ve defined them in each as they are the most likely to change.

The next thing I’m going to do is define the first job that will be executed for the stage which is to setup the infrastructure.

jobs:
  - job: Infrastructure
    displayName: 'Infrastructure'
    pool:
      vmImage: 'ubuntu-latest'

This is naming the job and setting what agent pool I wish to use.

As I mentioned earlier, my first step is to deploy the ARM template for the backend. I’m going to run this same step for each environment too using the Azure Resource Manager Template Deployment task.

    steps:
    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'ARM Template deployment: Resource Group scope'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: '$(armConnection)'
        subscriptionId: '$(subscription_id)'
        action: 'Create Or Update Resource Group'
        resourceGroupName: '$(resource_group_tfstate)'
        location: '$(location)'
        templateLocation: 'Linked artifact'
        csmFile: '$(System.DefaultWorkingDirectory)/infrastructure/backend/tfbackend.deploy.json'
        deploymentMode: 'Incremental'

This will create a resource group solely for the shared backend storage account if it doesn’t already exist, then create or update the storage account along with the blob containers required as defined by the ARM template.

You may have noticed in the ARM template I define an output. This can be quite tricky to retrieve on an agent as the default task outputs it to JSON which you then have to parse. Thankfully, there’s an ARM outputs task which will do all the heavy lifting for you and provide you with a pipeline variable that is named the same as your output.

"outputs": {
    "storageAccountName": {
      "type": "string",
      "value": "[variables('storageAccountName')]"
    }
  }

As you can see above, it creates an output called storageAccountName which the ARM outputs task will then use to create a pipeline variable with the same name that I can then use as $(storageAccountName).

This variable contains the name of the storage account for the Terraform backend where I store my state.

    - task: ARM Outputs@6
      inputs:
        ConnectedServiceNameSelector: 'ConnectedServiceNameARM'
        ConnectedServiceNameARM: '$(armConnection)'
        resourceGroupName: '$(resource_group_tfstate)'      
        whenLastDeploymentIsFailed: 'fail'

From here, I’m going to do my other pre-requisite task which is to replace tokens in my tfvars file that I have defined with double underscores, with the appropriate values from the pipeline. To do this, I’m going to use the Replace Tokens task.

    - task: qetza.replacetokens.replacetokens-task.replacetokens@3
      displayName: 'Replace tokens in **/*.tfvars'
      inputs:
        rootDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        targetFiles: '**/*.tfvars'
        escapeType: none
        tokenPrefix: '__'
        tokenSuffix: '__'
        enableTelemetry: false

The last setup task for the stage is to ensure we have the right Terraform version available. To do that, I use the Terraform Installer task.

    - task: TerraformInstaller@0
      displayName: 'Install Terraform 0.12.29'
      inputs:
        terraformVersion: 0.12.29

At this point, the pipeline YAML looks a little something like this:

trigger:
- master

variables:
  resource_group_tfstate: 'tfstate-uks-rg'
  product: 'staticsite'
  shortcode: 'lg'  

stages:
- stage: dev
  variables:    
    location: 'uksouth'        
    environment_name: 'dev'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfdev  

  jobs:
  - job: Infrastructure
    displayName: 'Infrastructure'
    pool:
      vmImage: 'ubuntu-latest'

    steps:
    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'ARM Template deployment: Resource Group scope'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: '$(armConnection)'
        subscriptionId: '$(subscription_id)'
        action: 'Create Or Update Resource Group'
        resourceGroupName: '$(resource_group_tfstate)'
        location: '$(location)'
        templateLocation: 'Linked artifact'
        csmFile: '$(System.DefaultWorkingDirectory)/infrastructure/backend/tfbackend.deploy.json'
        deploymentMode: 'Incremental'

    - task: ARM Outputs@6
      inputs:
        ConnectedServiceNameSelector: 'ConnectedServiceNameARM'
        ConnectedServiceNameARM: '$(armConnection)'
        resourceGroupName: '$(resource_group_tfstate)'      
        whenLastDeploymentIsFailed: 'fail'

    - task: qetza.replacetokens.replacetokens-task.replacetokens@3
      displayName: 'Replace tokens in **/*.tfvars'
      inputs:
        rootDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        targetFiles: '**/*.tfvars'
        escapeType: none
        tokenPrefix: '__'
        tokenSuffix: '__'
        enableTelemetry: false

    - task: TerraformInstaller@0
      displayName: 'Install Terraform 0.12.29'
      inputs:
        terraformVersion: 0.12.29

I’m defining my global variables, the dev stage and the pre-requistes are deployed or configured ahead of executing the Terraform.

Executing the Terraform is broken down into 3 steps, init, plan and apply.

    - task: TerraformTaskV1@0
      displayName: 'Terraform init'
      inputs:
        provider: 'azurerm'
        command: 'init'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-backend-config=$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: '$(backendAzureRmContainerName)'
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform plan'
      inputs:
        provider: 'azurerm'
        command: 'plan'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-var-file="$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars" --out=planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform apply'
      inputs:
        provider: 'azurerm'
        command: 'apply'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-auto-approve planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

These steps will create an environment specific resource group and deploy the required resources into it.

Now, I need to create another job. The Azure File Copy job is by far the easiest way to deploy files into a blob container. It uses azcopy to copy the files, however, the task itself will only execute on a Windows agent which is why we need to create a second job.

  - job: Deploy
    displayName: 'Deploy'
    pool:
      vmImage: 'windows-latest'
    dependsOn: 'Infrastructure'

    steps:    
    - task: AzureFileCopy@3
      inputs:
        SourcePath: '$(System.DefaultWorkingDirectory)/code'
        azureSubscription: '$(armConnection)'
        Destination: 'AzureBlob'
        storage: '$(shortcode)$(product)$(environment_name)$(location_short_code)stor'
        ContainerName: '$web'

The deploy job also needs the required infrastructure to exist first, so I’ve set the dependsOn for the entire job to be the Infrastructure job which runs the Terraform.

Static website enabled storage accounts also require the files to be deployed into a specific blob container called $web. It gets created automatically when the static website option is enabled on the storage account.

Bringing it all together, the pipeline now looks like this:

trigger:
- master

variables:
  resource_group_tfstate: 'tfstate-uks-rg'
  product: 'staticsite'
  shortcode: 'lg'  

stages:
- stage: dev
  variables:    
    location: 'uksouth'        
    environment_name: 'dev'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfdev  

  jobs:
  - job: Infrastructure
    displayName: 'Infrastructure'
    pool:
      vmImage: 'ubuntu-latest'

    steps:
    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'ARM Template deployment: Resource Group scope'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: '$(armConnection)'
        subscriptionId: '$(subscription_id)'
        action: 'Create Or Update Resource Group'
        resourceGroupName: '$(resource_group_tfstate)'
        location: '$(location)'
        templateLocation: 'Linked artifact'
        csmFile: '$(System.DefaultWorkingDirectory)/infrastructure/backend/tfbackend.deploy.json'
        deploymentMode: 'Incremental'

    - task: ARM Outputs@6
      inputs:
        ConnectedServiceNameSelector: 'ConnectedServiceNameARM'
        ConnectedServiceNameARM: '$(armConnection)'
        resourceGroupName: '$(resource_group_tfstate)'      
        whenLastDeploymentIsFailed: 'fail'

    - task: qetza.replacetokens.replacetokens-task.replacetokens@3
      displayName: 'Replace tokens in **/*.tfvars'
      inputs:
        rootDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        targetFiles: '**/*.tfvars'
        escapeType: none
        tokenPrefix: '__'
        tokenSuffix: '__'
        enableTelemetry: false

    - task: TerraformInstaller@0
      displayName: 'Install Terraform 0.12.29'
      inputs:
        terraformVersion: 0.12.29

    - task: TerraformTaskV1@0
      displayName: 'Terraform init'
      inputs:
        provider: 'azurerm'
        command: 'init'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-backend-config=$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: '$(backendAzureRmContainerName)'
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform plan'
      inputs:
        provider: 'azurerm'
        command: 'plan'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-var-file="$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars" --out=planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform apply'
      inputs:
        provider: 'azurerm'
        command: 'apply'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-auto-approve planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

  - job: Deploy
    displayName: 'Deploy'
    pool:
      vmImage: 'windows-latest'
    dependsOn: 'Infrastructure'

    steps:    
    - task: AzureFileCopy@3
      inputs:
        SourcePath: '$(System.DefaultWorkingDirectory)/code'
        azureSubscription: '$(armConnection)'
        Destination: 'AzureBlob'
        storage: '$(shortcode)$(product)$(environment_name)$(location_short_code)stor'
        ContainerName: '$web'

Should I execute that right now, it’ll run and deploy my index.html file to the storage account.

Hello there

Adding a second stage

To add in the prod stage, I’m going to run all of the same tasks again but with couple of environment specific variable values and one important addition to the stage definition.

- stage: prod
  dependsOn: dev

  variables:    
    location: 'uksouth'        
    environment_name: 'prod'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfprod

I’m making this stage dependent on dev succeeding and setting values where to prod where it was dev before.

From there, all tasks are identical to the previous step, which I can just copy and paste from the previous stage.

The overall pipeline YAML now looks like the below:

# Starter pipeline
# Start with a minimal pipeline that you can customize to build and deploy your code.
# Add steps that build, run tests, deploy, and more:
# https://aka.ms/yaml

trigger:
- master

variables:
  resource_group_tfstate: 'tfstate-uks-rg'
  product: 'staticsite'
  shortcode: 'lg'  

stages:
- stage: dev
  variables:    
    location: 'uksouth'        
    environment_name: 'dev'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfdev  

  jobs:
  - job: Infrastructure
    displayName: 'Infrastructure'
    pool:
      vmImage: 'ubuntu-latest'

    steps:
    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'ARM Template deployment: Resource Group scope'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: '$(armConnection)'
        subscriptionId: '$(subscription_id)'
        action: 'Create Or Update Resource Group'
        resourceGroupName: '$(resource_group_tfstate)'
        location: '$(location)'
        templateLocation: 'Linked artifact'
        csmFile: '$(System.DefaultWorkingDirectory)/infrastructure/backend/tfbackend.deploy.json'
        deploymentMode: 'Incremental'

    - task: ARM Outputs@6
      inputs:
        ConnectedServiceNameSelector: 'ConnectedServiceNameARM'
        ConnectedServiceNameARM: '$(armConnection)'
        resourceGroupName: '$(resource_group_tfstate)'      
        whenLastDeploymentIsFailed: 'fail'

    - task: qetza.replacetokens.replacetokens-task.replacetokens@3
      displayName: 'Replace tokens in **/*.tfvars'
      inputs:
        rootDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        targetFiles: '**/*.tfvars'
        escapeType: none
        tokenPrefix: '__'
        tokenSuffix: '__'
        enableTelemetry: false

    - task: TerraformInstaller@0
      displayName: 'Install Terraform 0.12.29'
      inputs:
        terraformVersion: 0.12.29

    - task: TerraformTaskV1@0
      displayName: 'Terraform init'
      inputs:
        provider: 'azurerm'
        command: 'init'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-backend-config=$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: '$(backendAzureRmContainerName)'
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform plan'
      inputs:
        provider: 'azurerm'
        command: 'plan'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-var-file="$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars" --out=planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform apply'
      inputs:
        provider: 'azurerm'
        command: 'apply'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-auto-approve planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

  - job: Deploy
    displayName: 'Deploy'
    pool:
      vmImage: 'windows-latest'
    dependsOn: 'Infrastructure'

    steps:    
    - task: AzureFileCopy@3
      inputs:
        SourcePath: '$(System.DefaultWorkingDirectory)/code'
        azureSubscription: '$(armConnection)'
        Destination: 'AzureBlob'
        storage: '$(shortcode)$(product)$(environment_name)$(location_short_code)stor'
        ContainerName: '$web'

- stage: prod
  dependsOn: dev

  variables:    
    location: 'uksouth'        
    environment_name: 'prod'
    location_short_code: 'uks'
    backendAzureRmContainerName: tfstate
    backendAzureRmKey: tfprod

  jobs:
  - job: Infrastructure
    displayName: 'Infrastructure'
    pool:
      vmImage: 'ubuntu-latest'

    steps:
    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'ARM Template deployment: Resource Group scope'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: '$(armConnection)'
        subscriptionId: '$(subscription_id)'
        action: 'Create Or Update Resource Group'
        resourceGroupName: '$(resource_group_tfstate)'
        location: '$(location)'
        templateLocation: 'Linked artifact'
        csmFile: '$(System.DefaultWorkingDirectory)/infrastructure/backend/tfbackend.deploy.json'
        deploymentMode: 'Incremental'

    - task: ARM Outputs@6
      inputs:
        ConnectedServiceNameSelector: 'ConnectedServiceNameARM'
        ConnectedServiceNameARM: '$(armConnection)'
        resourceGroupName: '$(resource_group_tfstate)'      
        whenLastDeploymentIsFailed: 'fail'

    - task: qetza.replacetokens.replacetokens-task.replacetokens@3
      displayName: 'Replace tokens in **/*.tfvars'
      inputs:
        rootDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        targetFiles: '**/*.tfvars'
        escapeType: none
        tokenPrefix: '__'
        tokenSuffix: '__'
        enableTelemetry: false

    - task: TerraformInstaller@0
      displayName: 'Install Terraform 0.12.29'
      inputs:
        terraformVersion: 0.12.29

    - task: TerraformTaskV1@0
      displayName: 'Terraform init'
      inputs:
        provider: 'azurerm'
        command: 'init'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-backend-config=$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: '$(backendAzureRmContainerName)'
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform plan'
      inputs:
        provider: 'azurerm'
        command: 'plan'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-var-file="$(System.DefaultWorkingDirectory)/infrastructure/storage-account/az-storage-account-variables.tfvars" --out=planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

    - task: TerraformTaskV1@0
      displayName: 'Terraform apply'
      inputs:
        provider: 'azurerm'
        command: 'apply'
        workingDirectory: '$(System.DefaultWorkingDirectory)/infrastructure/storage-account'
        commandOptions: '-auto-approve planfile'
        environmentServiceNameAzureRM: '$(armConnection)'
        backendServiceArm: '$(armConnection)'
        backendAzureRmResourceGroupName: '$(resource_group_tfstate)'
        backendAzureRmStorageAccountName: '$(storageAccountName)'
        backendAzureRmContainerName: $(backendAzureRmContainerName)
        backendAzureRmKey: '$(backendAzureRmKey)'

  - job: Deploy
    displayName: 'Deploy'
    pool:
      vmImage: 'windows-latest'
    dependsOn: 'Infrastructure'

    steps:    
    - task: AzureFileCopy@3
      inputs:
        SourcePath: '$(System.DefaultWorkingDirectory)/code'
        azureSubscription: '$(armConnection)'
        Destination: 'AzureBlob'
        storage: '$(shortcode)$(product)$(environment_name)$(location_short_code)stor'
        ContainerName: '$web'        

Heading into Azure DevOps once the pipeline has executed provides me with a view not too dissimilar to the Releases UI.

YAML Pipeline

I can also go into a job in a stage and see the output of each task.

YAML Pipeline Detail

While only a basic setup here, I hope this helps to show you how to get up and running with static sites in Azure with Terraform and Azure DevOps.

Resources

Leave a comment