You’ve built your module, organised your files, and designed your state. Now comes the part that separates a module someone uses once from one that gets maintained properly: automation. Documentation that drifts out of date and no test coverage are the two things that kill module adoption. Neither has to be a big investment.

This article covers the two areas worth automating in a module pipeline: documentation generation with Terraform Docs, and integration testing by actually deploying the resources.


Documentation

One of the easiest wins is automating your documentation with Terraform Docs. In Azure DevOps it’s straightforward to wire this up as a pipeline — the one I use is below.

The pipeline runs on all branches so documentation stays current throughout development. It also doubles as a quick sanity check: if the README fails to generate, something in the code structure is off.

trigger:
- "*"

pool:
  vmImage: ubuntu-latest

steps:
- checkout: self
  persistCredentials: true

- script: |
    curl -sSLo ./terraform-docs.tar.gz https://terraform-docs.io/dl/v0.16.0/terraform-docs-v0.16.0-linux-amd64.tar.gz
    tar -xzf terraform-docs.tar.gz
    chmod +x terraform-docs
    ./terraform-docs ./ -c terraform-docs.yml

    git config --global user.email "ADO-NoReply@rawritscloud.onmicrosoft.com"
    git config --global user.name "Documentation Pipeline"
    git add README.md
    git commit -m "Documentation Autogenerated  [skip ci]"
    git push origin HEAD:$(Build.SourceBranchName)
  displayName: 'Terraform Docs'

Testing

This one is slightly more controversial — depending on how many modules you have, it can get expensive.

Terratest is popular but it requires Go, which is an extra skill overhead depending on your team. I’ve found the simplest and most meaningful approach is to actually stand up the resources: a generic pipeline connected to a dedicated developer subscription used solely for apply and destroy runs.

Every module has an example.tf as part of its documentation — this is what the pipeline applies. It contains placeholder values for anything dependent on other modules, and specifies every variable with sensible example defaults.

We only trigger on main and on Pull Request creation — you don’t want this firing on every feature branch commit. We use stages and Environments so approval gates can be added before anything touches a customer environment.

trigger:
- main

pool:
  vmImage: windows-latest

variables:
- group: bte-management-platform
- group: customer-variables

stages:
- stage: plan
  displayName: 'Terraform Initialise and Plan an Apply'
  jobs:
  - job: init_and_plan
    displayName: 'Initialise and Plan Deployment'
    steps:
    - checkout: self
      persistCredentials: true

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
      displayName: 'Install Terraform latest'

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
      displayName: 'Terraform: init'
      inputs:
        workingDirectory: '$(System.DefaultWorkingDirectory)/examples'
        commandOptions: '-reconfigure -upgrade'
        backendServiceArm: 'Azure DevOps - AzureRM Connection'
        backendAzureRmResourceGroupName: '$(storage-account-resource-group)'
        backendAzureRmStorageAccountName: '$(storage-account-name)'
        backendAzureRmContainerName: terraform
        backendAzureRmKey: $(Build.Repository.Name).tfstate

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
      displayName: 'Terraform: plan'
      inputs:
        command: plan
        workingDirectory: '$(System.DefaultWorkingDirectory)'
        commandOptions: '-out $(Build.BuildNumber)apply.plan'
        environmentServiceNameAzureRM: 'Azure DevOps - AzureRM Connection'

    - task: PublishPipelineArtifact@1
      inputs:
        targetPath: '$(System.DefaultWorkingDirectory)'
        artifact: 'terraformPlanStage'
        publishLocation: 'pipeline'

- stage: Deploy_Apply
  displayName: 'Deploy Terraform Plan'
  jobs:
  - deployment: deploy
    displayName: 'Terraform Deploy'
    environment: $(Build.Repository.Name)
    strategy:
      runOnce:
        deploy:
          steps:
          - download: current
            artifact: terraformPlanStage

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
            displayName: 'Install Terraform latest'

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
            displayName: 'Terraform: init'
            inputs:
              workingDirectory: '$(Pipeline.Workspace)\terraformPlanStage\'
              commandOptions: '-reconfigure -upgrade'
              backendServiceArm: 'Azure DevOps - AzureRM Connection'
              backendAzureRmResourceGroupName: '$(storage-account-resource-group)'
              backendAzureRmStorageAccountName: '$(storage-account-name)'
              backendAzureRmContainerName: terraform
              backendAzureRmKey: $(Build.Repository.Name).tfstate

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
            displayName: 'Terraform: apply'
            inputs:
              command: apply
              workingDirectory: '$(Pipeline.Workspace)\terraformPlanStage\'
              commandOptions: '"$(Build.BuildNumber)apply.plan"'
              environmentServiceNameAzureRM: 'Azure DevOps - AzureRM Connection'

- stage: plan_destroy
  displayName: 'Terraform Initialise and Plan a Destroy'
  condition: eq('$', 'destroy')
  jobs:
  - job: init_and_plan
    displayName: 'Initialise and Plan a Destroy'
    steps:
    - checkout: self
      persistCredentials: true

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
      displayName: 'Install Terraform latest'

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
      displayName: 'Terraform: init'
      inputs:
        workingDirectory: '$(System.DefaultWorkingDirectory)/examples'
        commandOptions: '-reconfigure -upgrade'
        backendServiceArm: 'Azure DevOps - AzureRM Connection'
        backendAzureRmResourceGroupName: '$(storage-account-resource-group)'
        backendAzureRmStorageAccountName: '$(storage-account-name)'
        backendAzureRmContainerName: terraform
        backendAzureRmKey: $(Build.Repository.Name).tfstate

    - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
      displayName: 'Terraform: plan'
      inputs:
        command: plan
        workingDirectory: '$(System.DefaultWorkingDirectory)'
        commandOptions: '-destroy -out $(Build.BuildNumber)destroy.plan'
        environmentServiceNameAzureRM: 'Azure DevOps - AzureRM Connection'

    - task: PublishPipelineArtifact@1
      inputs:
        targetPath: '$(System.DefaultWorkingDirectory)'
        artifact: 'terraformPlanStage'
        publishLocation: 'pipeline'

- stage: Deploy_Destroy
  displayName: 'Deploy Terraform Destroy Plan'
  jobs:
  - deployment: deploy
    displayName: 'Terraform Deploy'
    environment: $(Build.Repository.Name)
    strategy:
      runOnce:
        deploy:
          steps:
          - download: current
            artifact: terraformPlanStage

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
            displayName: 'Install Terraform latest'

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
            displayName: 'Terraform: init'
            inputs:
              workingDirectory: '$(Pipeline.Workspace)\terraformPlanStage\'
              commandOptions: '-reconfigure -upgrade'
              backendServiceArm: 'Azure DevOps - AzureRM Connection'
              backendAzureRmResourceGroupName: '$(storage-account-resource-group)'
              backendAzureRmStorageAccountName: '$(storage-account-name)'
              backendAzureRmContainerName: terraform
              backendAzureRmKey: $(Build.Repository.Name).tfstate

          - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2
            displayName: 'Terraform: apply'
            inputs:
              command: apply
              workingDirectory: '$(Pipeline.Workspace)\terraformPlanStage\'
              commandOptions: '"$(Build.BuildNumber)destroy.plan"'
              environmentServiceNameAzureRM: 'Azure DevOps - AzureRM Connection'

Common Mistakes

Skipping documentation automation entirely. A module with no README is a module nobody can use without reading every .tf file. Terraform Docs takes twenty minutes to wire up and removes that problem permanently.

Running test deployments on every branch. Your testing pipeline should only trigger on main and Pull Requests — not feature branches. Otherwise costs spiral and the pipeline becomes noise rather than a gate.

Using a shared subscription for testing. If your test pipeline shares a subscription with anything real, a destroy gone wrong can cause a bad day. Isolate module testing to a dedicated subscription with no production workloads.

No example.tf. The test pipeline needs something to apply. An example.tf that covers the module’s common usage also serves as documentation for consumers — two problems solved at once.

Summary

Automate your documentation with Terraform Docs — it’s the quickest win available and removes the drift that makes READMEs useless. For testing, actually deploying the resources beats mocking every time: wire up a pipeline to a dedicated dev subscription and use example.tf as the test input. Trigger testing on main and Pull Requests only, and keep your test subscription isolated from anything real.

What to Explore Next

  • Terraform Docs — configuration reference and output formats
  • Terratest — if your team is comfortable with Go and you want assertion-level testing
  • Automating your blog — same Logic App and pipeline patterns applied outside Terraform