Civica Training - Terraform on Azure

Advanced Patterns & CI/CD

Production-ready Terraform for your AKS platform

Module 5 of 5 Advanced

The Final Mile

"Your AKS platform is modularized, state is remote, and environments are separated. Now let's master advanced HCL patterns, automate deployments with CI/CD, and lock down security for production."

Dynamic Blocks

Generate repeated nested blocks from a collection, keeping your code DRY.

Without Dynamic (repetitive)

resource "azurerm_network_security_group" "main" { # ... security_rule { name = "AllowHTTPS" priority = 100 # ... 6 more attributes } security_rule { name = "AllowHTTP" priority = 200 # ... 6 more attributes } security_rule { name = "AllowSSH" priority = 300 # ... 6 more attributes } }

With Dynamic (clean)

resource "azurerm_network_security_group" "main" { # ... dynamic "security_rule" { for_each = var.nsg_rules content { name = security_rule.value.name priority = security_rule.value.priority direction = security_rule.value.direction access = security_rule.value.access protocol = security_rule.value.protocol # ... } } }

Dynamic Block: Full Example

variable "nsg_rules" { type = list(object({ name = string priority = number direction = string access = string protocol = string destination_port_range = string })) default = [ { name = "HTTPS", priority = 100, direction = "Inbound", access = "Allow", protocol = "Tcp", destination_port_range = "443" }, { name = "HTTP", priority = 200, direction = "Inbound", access = "Allow", protocol = "Tcp", destination_port_range = "80" }, ] }
Use dynamic blocks sparingly. Over-use makes code harder to read. If you only have 2-3 blocks, writing them out explicitly can be clearer.

for_each vs count

Aspectcountfor_each
InputA numberA map or set
Addressingresource[0], resource[1]resource["key"]
Remove itemShifts indices (may recreate resources)Only removes that key (stable)
Best forIdentical copiesDistinct resources from a collection

count Example

resource "azurerm_public_ip" "lb" { count = 3 name = "pip-lb-${count.index}" location = var.location # ... }

for_each Example

resource "azurerm_subnet" "main" { for_each = var.subnets name = each.key address_prefixes = each.value.cidrs # ... }

for_each: Best Practices

variable "node_pools" { type = map(object({ vm_size = string min_count = number max_count = number mode = string })) } resource "azurerm_kubernetes_cluster_node_pool" "extra" { for_each = var.node_pools name = each.key kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id vm_size = each.value.vm_size min_count = each.value.min_count max_count = each.value.max_count enable_auto_scaling = true mode = each.value.mode }

Conditional Resources

Create resources only when a condition is true.

variable "enable_monitoring" { type = bool default = true } # Only create if monitoring is enabled resource "azurerm_log_analytics_workspace" "main" { count = var.enable_monitoring ? 1 : 0 name = "log-aks-platform" location = azurerm_resource_group.aks.location resource_group_name = azurerm_resource_group.aks.name sku = "PerGB2018" } # Reference conditionally-created resource log_analytics_workspace_id = var.enable_monitoring ? azurerm_log_analytics_workspace.main[0].id : null

Conditional with for_each

A cleaner pattern using an empty map/set.

variable "create_dns_zone" { type = bool default = false } resource "azurerm_dns_zone" "main" { for_each = var.create_dns_zone ? { "dns" = true } : {} name = "aks.example.com" resource_group_name = azurerm_resource_group.aks.name }

Knowledge Check 1

1. Why is for_each preferred over count for most use cases?

2. What is a dynamic block used for?

3. How do you conditionally create a resource using count?

Provisioners

Execute scripts as part of resource creation or destruction.

local-exec

resource "azurerm_kubernetes_cluster" "main" { # ... config ... provisioner "local-exec" { command = "az aks get-credentials --resource-group ${self.resource_group_name} --name ${self.name}" } }

remote-exec

resource "azurerm_linux_virtual_machine" "jump" { # ... config ... provisioner "remote-exec" { inline = [ "sudo apt-get update", "sudo apt-get install -y kubectl", ] } }

Why Avoid Provisioners?

HashiCorp recommends provisioners as a last resort.

Problems

  • Not tracked in state
  • Can't be planned or previewed
  • Failure handling is limited
  • Creates hidden dependencies
  • Not idempotent by default

Better Alternatives

  • cloud-init for VM configuration
  • Ansible for configuration management
  • Kubernetes provider for K8s resources
  • Helm provider for Helm charts
  • null_resource + triggers if you must

terraform import

Bring existing Azure resources under Terraform management.

# Step 1: Write the resource block in your .tf file resource "azurerm_resource_group" "legacy" { name = "rg-legacy-app" location = "uksouth" } # Step 2: Import the existing resource into state $ terraform import azurerm_resource_group.legacy \ /subscriptions/xxx/resourceGroups/rg-legacy-app # Step 3: Run plan to verify - adjust config until plan shows no changes $ terraform plan No changes. Your infrastructure matches the configuration.

Import Block (Terraform 1.5+)

Declarative imports - no CLI command needed.

# import.tf import { to = azurerm_resource_group.legacy id = "/subscriptions/xxx/resourceGroups/rg-legacy-app" } # Generate config automatically (Terraform 1.5+) $ terraform plan -generate-config-out=generated.tf

Moved Blocks: Refactoring Safely

# Scenario: Extract networking into a module # Before: resource was at root level # resource "azurerm_virtual_network" "main" { ... } # After: resource is inside a module # module "networking" { source = "./modules/networking" } # Tell Terraform it moved (don't destroy/recreate!) moved { from = azurerm_virtual_network.main to = module.networking.azurerm_virtual_network.main } moved { from = azurerm_subnet.aks_nodes to = module.networking.azurerm_subnet.main["snet-aks-nodes"] }

Knowledge Check 2

1. Why does HashiCorp recommend avoiding provisioners?

2. What does terraform plan -generate-config-out=generated.tf do?

3. What happens to the actual Azure resource during a moved block operation?

CI/CD for Terraform

Automate the plan-review-apply cycle in your pipeline.

1. PR Created

Developer pushes code

2. Plan

CI runs terraform plan

3. Review

Team reviews plan output

4. Apply

Merge triggers apply

GitHub Actions Pipeline

# .github/workflows/terraform.yml name: Terraform on: pull_request: paths: ['infrastructure/**'] push: branches: [main] paths: ['infrastructure/**'] permissions: id-token: write # For OIDC auth contents: read pull-requests: write # For plan comments jobs: terraform: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.7.0" - uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: terraform init - run: terraform plan -out=tfplan - run: terraform apply tfplan if: github.ref == 'refs/heads/main'

Azure DevOps Pipeline

# azure-pipelines.yml trigger: branches: include: [main] paths: include: [infrastructure/*] pool: vmImage: 'ubuntu-latest' stages: - stage: Plan jobs: - job: TerraformPlan steps: - task: TerraformInstaller@1 inputs: terraformVersion: '1.7.0' - task: TerraformTaskV4@4 inputs: provider: 'azurerm' command: 'init' backendServiceArm: 'AzureRM-ServiceConnection' backendAzureRmResourceGroupName: 'rg-terraform-state' backendAzureRmStorageAccountName: 'stterraformstate001' backendAzureRmContainerName: 'tfstate' backendAzureRmKey: 'prod.terraform.tfstate' - task: TerraformTaskV4@4 inputs: command: 'plan' environmentServiceNameAzureRM: 'AzureRM-ServiceConnection' - stage: Apply dependsOn: Plan condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) jobs: - deployment: TerraformApply environment: 'production' # Requires approval strategy: runOnce: deploy: steps: - task: TerraformTaskV4@4 inputs: command: 'apply'

Plan Output in PR Comments

Post the terraform plan as a PR comment so reviewers see what will change.

# GitHub Actions step to post plan as PR comment - name: Terraform Plan id: plan run: terraform plan -no-color -out=tfplan continue-on-error: true - name: Post Plan to PR uses: actions/github-script@v7 if: github.event_name == 'pull_request' with: script: | const plan = `${{ steps.plan.outputs.stdout }}`; const body = `#### Terraform Plan \`\`\` ${plan.substring(0, 65000)} \`\`\` *Triggered by @${{ github.actor }}*`; github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body });

Terraform Cloud / Enterprise

What It Provides

  • Remote state management (built-in)
  • Remote plan and apply execution
  • VCS integration (GitHub, ADO, GitLab)
  • Policy as code (Sentinel / OPA)
  • Private module registry
  • Cost estimation

Configuration

terraform { cloud { organization = "civica" workspaces { name = "aks-platform-prod" } } }
  • Free tier: up to 500 resources
  • Replaces the backend block

Knowledge Check 3

1. In a CI/CD pipeline, when should terraform apply run?

2. What authentication method is recommended for GitHub Actions to Azure?

3. What does Terraform Cloud's Sentinel feature provide?

Security: Secrets in State

Terraform state often contains sensitive values: database passwords, connection strings, private keys, access tokens. Treat state as a secret.

Protect State

  • Use remote state with encryption at rest
  • Enable Azure Storage encryption (default)
  • Restrict storage account access with RBAC
  • Enable blob versioning for recovery
  • Never commit state to Git

Minimize Secrets

  • Use managed identities instead of passwords
  • Generate secrets outside Terraform where possible
  • Use Key Vault references, not literal values
  • Mark variables and outputs as sensitive

Security Best Practices

Code Quality Tools

ToolPurposeUsage
terraform fmtFormat code to standard styleterraform fmt -recursive
terraform validateCheck syntax and internal consistencyterraform validate
tflintLinter with cloud-specific rulestflint --init && tflint
tfsec / trivySecurity scanningtrivy config .
checkovPolicy-as-code scanningcheckov -d .
infracostCost estimationinfracost breakdown --path .
terraform-docsAuto-generate module docsterraform-docs markdown .

Pre-commit Hooks

Catch issues before code reaches the repository.

# .pre-commit-config.yaml repos: - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.86.0 hooks: - id: terraform_fmt - id: terraform_validate - id: terraform_tflint - id: terraform_tfsec - id: terraform_docs args: - '--args=--output-file=README.md'
$ pip install pre-commit $ pre-commit install # Now runs automatically on every git commit

Production Checklist

Infrastructure

  • Remote state with encryption + locking
  • Separate state per environment
  • Provider versions pinned
  • Terraform version pinned
  • lifecycle rules on critical resources
  • Tags on all resources
  • Naming conventions enforced

Process

  • CI/CD pipeline for plan + apply
  • Plan output in PR comments
  • Manual approval for production apply
  • Pre-commit hooks (fmt, validate, lint)
  • Security scanning (tfsec/trivy)
  • Cost estimation (infracost)
  • Module documentation generated

Common Anti-Patterns

Anti-PatternWhy It's BadBetter Approach
Hardcoded values everywhereCan't reuse across environmentsVariables + .tfvars per env
One massive main.tfHard to read and maintainSplit by concern, use modules
State in GitSecrets exposed, merge conflictsRemote state (Azure Blob)
No version constraintsBreaking changes on upgradePin providers with ~>
Running apply locallyNo audit trail, inconsistentCI/CD pipeline only
Ignoring plan outputAccidental destroysAlways review before apply
Overusing provisionersNot in state, not idempotentCloud-init, Ansible, providers

Where to Go From Here

Immediate Next Steps

  • Build a dev AKS cluster with modules
  • Set up remote state on Azure Blob
  • Create a CI/CD pipeline
  • Add tflint + tfsec to pre-commit

Advanced Topics

  • Terraform testing framework (1.6+)
  • Custom providers
  • Policy as code (OPA / Sentinel)
  • Terragrunt for multi-env orchestration
  • OpenTofu (open-source fork)

Useful Resources

Course Summary

Module 1

IaC fundamentals, HCL, init/plan/apply, state

Module 2

Azure provider, VNets, NSGs, Key Vault, dependencies

Module 3

AKS clusters, node pools, networking, ACR, monitoring

Module 4

Variables, modules, remote state, workspaces

Module 5

Advanced patterns, CI/CD, security, production readiness

Congratulations!

You now have the knowledge to provision and manage your AKS platform infrastructure with Terraform.

Remember: Infrastructure as Code is a journey, not a destination. Start simple, iterate, and always review your plans before applying.

Course Complete Civica Training
← Back